add part 8 draft tutorial

This commit is contained in:
glyph 2022-09-08 13:54:36 +01:00
parent 8593e17294
commit eb24df9ce6
22 changed files with 1652 additions and 14 deletions

265
Cargo.lock generated
View File

@ -298,6 +298,15 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@ -342,6 +351,12 @@ version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1ad822118d20d2c234f427000d5acc36eabe1e29a348c89b63dd60b13f28e5d"
[[package]]
name = "byteorder"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
[[package]]
name = "bytes"
version = "1.2.1"
@ -385,8 +400,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1"
dependencies = [
"iana-time-zone",
"js-sys",
"num-integer",
"num-traits",
"time 0.1.44",
"wasm-bindgen",
"winapi 0.3.9",
]
@ -444,7 +462,7 @@ dependencies = [
"rand",
"sha2",
"subtle",
"time",
"time 0.3.14",
"version_check",
]
@ -463,6 +481,29 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d"
dependencies = [
"cfg-if 1.0.0",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "045ebe27666471bb549370b4b0b3e51b07f56325befa4284db65fc89c02511b1"
dependencies = [
"autocfg",
"cfg-if 1.0.0",
"crossbeam-utils",
"memoffset",
"once_cell",
"scopeguard",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.11"
@ -562,6 +603,15 @@ dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
@ -635,6 +685,16 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi 0.3.9",
]
[[package]]
name = "fsevent"
version = "0.4.0"
@ -774,6 +834,15 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "gcc"
version = "0.3.55"
@ -833,7 +902,7 @@ checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6"
dependencies = [
"cfg-if 1.0.0",
"libc",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
]
[[package]]
@ -1173,7 +1242,7 @@ dependencies = [
"async-std",
"async-stream 0.2.1",
"base64 0.11.0",
"dirs",
"dirs 2.0.2",
"futures",
"get_if_addrs",
"hex",
@ -1276,6 +1345,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "memoffset"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.16"
@ -1309,7 +1387,7 @@ checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf"
dependencies = [
"libc",
"log",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys",
]
@ -1451,6 +1529,17 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72"
[[package]]
name = "parking_lot"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
dependencies = [
"instant",
"lock_api",
"parking_lot_core 0.8.5",
]
[[package]]
name = "parking_lot"
version = "0.12.1"
@ -1458,7 +1547,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
dependencies = [
"lock_api",
"parking_lot_core",
"parking_lot_core 0.9.3",
]
[[package]]
name = "parking_lot_core"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216"
dependencies = [
"cfg-if 1.0.0",
"instant",
"libc",
"redox_syscall",
"smallvec",
"winapi 0.3.9",
]
[[package]]
@ -1501,6 +1604,110 @@ dependencies = [
"rocket_dyn_templates",
]
[[package]]
name = "part_3_database_follows"
version = "0.1.0"
dependencies = [
"bincode",
"golgi",
"log",
"rocket",
"rocket_dyn_templates",
"serde",
"sled",
"xdg",
]
[[package]]
name = "part_4_posts_streams"
version = "0.1.0"
dependencies = [
"async-std",
"bincode",
"chrono",
"futures",
"golgi",
"log",
"rocket",
"rocket_dyn_templates",
"serde",
"serde_json",
"sled",
"xdg",
]
[[package]]
name = "part_5_task_loop"
version = "0.1.0"
dependencies = [
"async-std",
"bincode",
"chrono",
"futures",
"golgi",
"log",
"rocket",
"rocket_dyn_templates",
"serde",
"serde_json",
"sled",
"xdg",
]
[[package]]
name = "part_6_ui_layout"
version = "0.1.0"
dependencies = [
"async-std",
"bincode",
"chrono",
"futures",
"golgi",
"log",
"rocket",
"rocket_dyn_templates",
"serde",
"serde_json",
"sled",
"xdg",
]
[[package]]
name = "part_7_latest_posts"
version = "0.1.0"
dependencies = [
"async-std",
"bincode",
"chrono",
"futures",
"golgi",
"log",
"rocket",
"rocket_dyn_templates",
"serde",
"serde_json",
"sled",
"xdg",
]
[[package]]
name = "part_8_ui_posts"
version = "0.1.0"
dependencies = [
"async-std",
"bincode",
"chrono",
"futures",
"golgi",
"log",
"rocket",
"rocket_dyn_templates",
"serde",
"serde_json",
"sled",
"xdg",
]
[[package]]
name = "pear"
version = "0.2.3"
@ -1819,7 +2026,7 @@ dependencies = [
"memchr",
"multer",
"num_cpus",
"parking_lot",
"parking_lot 0.12.1",
"pin-project-lite",
"rand",
"ref-cast",
@ -1828,7 +2035,7 @@ dependencies = [
"serde",
"state",
"tempfile",
"time",
"time 0.3.14",
"tokio",
"tokio-stream",
"tokio-util",
@ -1888,7 +2095,7 @@ dependencies = [
"smallvec",
"stable-pattern",
"state",
"time",
"time 0.3.14",
"tokio",
"uncased",
]
@ -2023,6 +2230,22 @@ dependencies = [
"autocfg",
]
[[package]]
name = "sled"
version = "0.34.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935"
dependencies = [
"crc32fast",
"crossbeam-epoch",
"crossbeam-utils",
"fs2",
"fxhash",
"libc",
"log",
"parking_lot 0.11.2",
]
[[package]]
name = "slug"
version = "0.1.4"
@ -2154,6 +2377,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "time"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
dependencies = [
"libc",
"wasi 0.10.0+wasi-snapshot-preview1",
"winapi 0.3.9",
]
[[package]]
name = "time"
version = "0.3.14"
@ -2463,6 +2697,12 @@ dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.10.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
@ -2693,6 +2933,15 @@ dependencies = [
"winapi-build",
]
[[package]]
name = "xdg"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c4583db5cbd4c4c0303df2d15af80f0539db703fa1c68802d4cbbd2dd0f88f6"
dependencies = [
"dirs 4.0.0",
]
[[package]]
name = "yansi"
version = "0.5.1"

View File

@ -3,9 +3,10 @@
members = [
"part_1_sbot_rocket",
"part_2_subscribe_form",
#"part_3_database_follows",
#"part_4_posts_streams",
#"part_5_task_loop",
#"part_6_ui_layout",
#"part_7_latest_posts"
"part_3_database_follows",
"part_4_posts_streams",
"part_5_task_loop",
"part_6_ui_layout",
"part_7_latest_posts",
"part_8_ui_posts"
]

View File

@ -15,7 +15,7 @@ Author: [@glyph](https://mycelial.technology/)
- Part 5: Task Loop
- Part 6: UI Layout and Peers List
- Part 7: Latest Posts and Names
- Part 8: Posts List and Post Content
- Part 8: Post List and Post Content
- Part 9: Read, Unread and Delete
- Part 10: Extension Ideas and Conclusion

View File

@ -0,0 +1,20 @@
[package]
name = "part_8_ui_posts"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-std = "1.10"
bincode = "1.3"
chrono = "0.4"
futures = "0.3"
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" }
log = "0.4"
rocket = "0.5.0-rc.1"
rocket_dyn_templates = { version = "0.1.0-rc.1", features = ["tera"] }
serde = "1"
serde_json = "1"
sled = "0.34"
xdg = "2.4.1"

280
part_8_ui_posts/README.md Normal file
View File

@ -0,0 +1,280 @@
# lykin tutorial
## Part 8: Post List and Post Content
### Introduction
In the last tutorial installment we added the ability to sync the latest posts and names for each peer we subscribe to. The goal of this installment is to update the web interface to display a list of posts when a peer is selected from the peer list and to display the text of a post when one is selected. In order to achieve this, we'll need to add methods to the key-value database to retrieve a post or a batch of posts. We'll also need to add a number of endpoints to our webserver and update the control-flow logic in our templates.
### Outline
- Get a post from the database
- Get a batch of posts from the database
- Add a posts route handler
- Add a post route handler
- Mount the post route handlers
- Update templates
- Peer list
- Post list
- Post content
### Get a Post From the Database
We previously wrote database methods for adding a single post and a batch of posts to the key-value store. Now we need to write methods for retrieving that data. Let's start with a method to retrieve a single post. The method will take a public key and a message ID (aka. message reference or sigil link) as parameters, concatenate those values to create a `post_key` and then attempt to get the value of that key from the database.
`src/db.rs`
```rust
impl Database {
// ...
// Get a single post from the post tree, authored by the given public key
// and defined by the given message ID. The byte value for the matching
// entry, if found, is deserialized from bincode into an instance of the
// Post struct.
pub fn get_post(&self, public_key: &str, msg_id: &str) -> Result<Option<Post>> {
let post_key = format!("{}_{}", public_key, msg_id);
debug!(
"Retrieving post data for {} from 'posts' database tree",
&post_key
);
let post = self
.post_tree
.get(post_key.as_bytes())
.unwrap()
.map(|post| {
debug!("Deserializing post data for {} from bincode", &post_key);
bincode::deserialize(&post).unwrap()
});
Ok(post)
}
}
```
### Get a Batch of Posts From the Database
The corresponding method for retrieving a batch of posts is very similar. We pass in the public key of the desired peer as a parameter and retrieve all posts with a key beginning with that public key (notice `scan_prefix()` in the code below). Once we've populated a vector with all the posts by the given public key, we sort the list according to the timestamp of each post. This will allow us to easily display the posts in descending chronological order in the web interface.
`src/routes.rs`
```rust
impl Database {
// ...
// Get a list of all posts in the post tree authored by the given public
// key and sort them by timestamp in descending order. The byte value for
// each matching entry is deserialized from bincode into an instance of
// the Post struct.
pub fn get_posts(&self, public_key: &str) -> Result<Vec<Post>> {
debug!("Retrieving data for all posts in the 'posts' database tree");
let mut posts = Vec::new();
self.post_tree
.scan_prefix(public_key.as_bytes())
.map(|post| post.unwrap())
.for_each(|post| {
debug!(
"Deserializing post data for {} from bincode",
String::from_utf8_lossy(&post.0).into_owned()
);
posts.push(bincode::deserialize(&post.1).unwrap())
});
posts.sort_by(|a: &Post, b: &Post| b.timestamp.cmp(&a.timestamp));
Ok(posts)
}
}
```
### Add a Posts Route Handler
Imagine interacting with the lykin interface for a moment: we load the application and see a list of peers down the left-hand side; these are the peers we subscribe to. When we click on the name of one of the peers in the list, we want to see a list of posts authored by that peer - each one with a subject line and date. Then, when we click on one of the posts in the list, we want to see the content of that post.
Let's write an endpoint that will take a public key and render the user interface with a list of posts:
`src/routes.rs`
```rust
#[get("/posts/<public_key>")]
pub async fn posts(db: &State<Database>, public_key: &str) -> Template {
// Fetch the list of peers we subscribe to.
let peers = db.get_peers();
// Fetch the posts for the given peer from the key-value database.
let posts = db.get_posts(public_key).unwrap();
// Define context data to be rendered in the template.
let context = context! {
peers: &peers,
// This variable allows us to track which peer is currently selected
// from within the template. We'll use this variable to render the
// name of the selected peer in bold.
selected_peer: &public_key,
posts: &posts
};
Template::render("base", context)
}
```
There's not much to the code above: get the peers, get the posts, generate a template context from the data, render the template and return it to the caller.
### Add a Post Route Handler
Now we want to add an endpoint that will return a template populated with a list of peers, a list of posts _and_ the content of a specific post. This is the route handler that will be called when we click on a post in the post list.
`src/routes.rs`
```rust
#[get("/posts/<public_key>/<msg_id>")]
pub async fn post(db: &State<Database>, public_key: &str, msg_id: &str) -> Template {
let peers = db.get_peers();
let posts = db.get_posts(public_key).unwrap();
let post = db.get_post(public_key, msg_id).unwrap();
let context = context! {
peers: &peers,
selected_peer: &public_key,
selected_post: &msg_id,
posts: &posts,
post: &post
};
Template::render("base", context)
}
```
The code above is almost identical to the code in the `posts` route handler, with the exception of the `msg_id` parameter, the `get_post` database call and the addition of `post` and `selected_post` to the template context. As with `selected_peer`, `selected_post` gives us a means of bolding the text of the selected post in the list of posts. If this is at all confusing, things should become clearer as we update the templates. Let's turn to that task now.
### Mount the Post Route Handlers
Let's register the `post` and `posts` route handlers by mounting them to our Rocket instance.
`src/main.rs`
```rust
#[launch]
async fn rocket() -> _ {
// ...
info!("Launching web server");
rocket::build()
.manage(db)
.manage(tx)
.mount(
"/",
routes![
home,
subscribe_form,
unsubscribe_form,
download_latest_posts,
post,
posts
],
)
.mount("/", FileServer::from(relative!("static")))
.attach(Template::fairing())
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}
```
### Update Peer List Template
We need to update the peer list template so that each name in the list is wrapped in an anchor element with an `href` tag value of `/posts/<public_key>`. The `selected_peer` context variable will also come in handy here: we can use it to render the name of a peer as bold text if it is the currently selected peer.
`templates/peer_list.html.tera`
```html
<div class="peers">
<ul>
{% for peer in peers -%}
<li>
<a class="flex-container" href="/posts/{{ peer.public_key | urlencode_strict }}">
<code{% if selected_peer and peer.public_key == selected_peer %} style="font-weight: bold;"{% endif %}>
{% if peer.name %}
{{ peer.name }}
{% else %}
{{ peer.public_key }}
{% endif %}
</code>
</a>
</li>
{%- endfor %}
</ul>
</div>
```
Notice the `href` tag value above: `/posts/{{ peer.public_key | urlencode_strict }}`. `urlencode_strict` is a Tera filter that encodes all non-alphanumeric characters in a string including forward slashes (see [the docs](https://tera.netlify.app/docs/#urlencode-strict)).
We also check if the `selected_peer` context variable exists. If it does, and if it matches the value of the peer's public key, we render the name in bold text.
One other small improvement introduced here is selective rendering of the peer name. It's possible that our local key-value database may not contain a name for a peer we've subscribed to (for instance, if that peer is outside of our hops range or we simply haven't replicated any data for it yet). In the case that the peer's name is not known, we simply render the public key instead.
### Update Post List Template
When we wrote the initial post list template we simply printed `Subject placeholder` for each post in the list. Let's update that to display the subject and date for each post.
`templates/post_list.html.tera`
```html
<div class="posts">
{% if posts %}
<ul>
{% for post in posts -%}
<li{% if selected_post and post.key == selected_post %} class="selected"{% endif %}>
<a class="flex-container" href="/posts/{{ selected_peer | urlencode_strict }}/{{ post.key | urlencode_strict }}">
<code>
{% if post.subject %}
{{ post.subject | trim_start_matches(pat='"') }}...
{% else %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') }}
{% endif %}
</code>
<p>{{ post.date }}</p>
</a>
</li>
{%- endfor %}
</ul>
{% endif %}
</div>
```
Here we see the `selected_post` context variable in action, in much the same way as the `selected_peer` variable was utilised in the peer list template. The `selected` class is applied to the selected post; this changes the background colour of the element to make it stand out from the rest of the posts.
The `href` tag value of each post in the list is constructed using the `selected_peer` and `post.key` values, both of which are strictly URL encoded using a Tera filter. Then comes the code to display the post subject, if it exists, along with the post date. If `post.subject` is `None` then we display the `post.text` instead. This would occur if the post text contains less than 52 characters (the length defined for the subject text). Finally, the `post.date` is displayed as the last element in the list item.
### Update Post Content Template
Before wrapping up this installment of series, we're going to make one small change to the post content template to remove the inverted commas which wrap the text of each post in our database. We'll also call the `trim` Tera filter to remove any leading and trailing whitespace characters:
`templates/post_content.html.tera`
```html
<div class="content">
{% if post %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') | trim }}
{% endif %}
</div>
```
### Conclusion
In this installment we brought our user-interface to life by added the ability to list posts and display post content. We wrote methods to retrieve posts from the key-value database and added route handlers to render post lists and post content. We also updated the HTML templates of our application to render the post-related data.
Most of the core logic of our application is now complete! In the next installment we'll add the ability to mark individual posts as read or unread. We'll also add a means of deleting individual posts; all via the web interface.
## Funding
This work has been funded by a Scuttlebutt Community Grant.
## Contributions
I would love to continue working on the Rust Scuttlebutt ecosystem, writing code and documentation, but I need your help. Please consider contributing to [my Liberapay account](https://liberapay.com/glyph) to support me in my coding and cultivation efforts.

263
part_8_ui_posts/src/db.rs Normal file
View File

@ -0,0 +1,263 @@
use std::path::Path;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use sled::{Batch, Db, IVec, Result, Tree};
/// Scuttlebutt peer data.
#[derive(Debug, Deserialize, Serialize)]
pub struct Peer {
pub public_key: String,
pub name: String,
pub latest_sequence: u64,
}
impl Peer {
/// Create a new instance of the Peer struct using the given public
/// key. A default value is set for name.
pub fn new(public_key: &str) -> Peer {
Peer {
public_key: public_key.to_string(),
name: "".to_string(),
latest_sequence: 0,
}
}
/// Modify the name field of an instance of the Peer struct, leaving
/// the other values unchanged.
pub fn set_name(self, name: &str) -> Peer {
Self {
name: name.to_string(),
..self
}
}
/// Modify the latest_sequence field of an instance of the Peer struct,
/// leaving the other values unchanged.
pub fn set_latest_sequence(self, latest_sequence: u64) -> Peer {
Self {
latest_sequence,
..self
}
}
}
/// The text and metadata of a Scuttlebutt root post.
#[derive(Debug, Deserialize, Serialize)]
pub struct Post {
/// The key of the post-type message, also known as a message reference.
pub key: String,
/// The text of the post (may be formatted as markdown).
pub text: String,
/// The date the post was published (e.g. 17 May 2021).
pub date: String,
/// The sequence number of the post-type message.
pub sequence: u64,
/// The read state of the post; true if read, false if unread.
pub read: bool,
/// The timestamp representing the date the post was published.
pub timestamp: i64,
/// The subject of the post, represented as the first 53 characters of
/// the post text.
pub subject: Option<String>,
}
impl Post {
// Create a new instance of the Post struct. A default value of `false` is
// set for `read`.
pub fn new(
key: String,
text: String,
date: String,
sequence: u64,
timestamp: i64,
subject: Option<String>,
) -> Post {
Post {
key,
text,
date,
sequence,
timestamp,
subject,
read: false,
}
}
}
/// An instance of the key-value database and relevant trees.
#[allow(dead_code)]
#[derive(Clone)]
pub struct Database {
/// The sled database instance.
db: Db,
/// A database tree containing Peer struct instances for all the peers
/// we are subscribed to.
peer_tree: Tree,
/// A database tree containing Post struct instances for all of the posts
/// we have downloaded from the peer to whom we subscribe.
pub post_tree: Tree,
}
impl Database {
/// Initialise the database by opening the database file, loading the
/// peers tree and returning an instantiated Database struct.
pub fn init(path: &Path) -> Self {
// Open the database at the given path.
// The database will be created if it does not yet exist.
// This code will panic if an IO error is encountered.
info!("Initialising sled database");
let db = sled::open(path).expect("Failed to open database");
debug!("Opening 'peers' database tree");
let peer_tree = db
.open_tree("peers")
.expect("Failed to open 'peers' database tree");
debug!("Opening 'posts' database tree");
let post_tree = db
.open_tree("posts")
.expect("Failed to open 'posts' database tree");
Database {
db,
peer_tree,
post_tree,
}
}
/// Add a peer to the database by inserting the public key into the peer
/// tree.
pub fn add_peer(&self, peer: Peer) -> Result<Option<IVec>> {
debug!("Serializing peer data for {} to bincode", &peer.public_key);
let peer_bytes = bincode::serialize(&peer).unwrap();
debug!(
"Inserting peer {} into 'peers' database tree",
&peer.public_key
);
self.peer_tree.insert(&peer.public_key, peer_bytes)
}
/// Get a single peer from the peer tree, defined by the given public key.
/// The byte value for the matching entry, if found, is deserialized from
/// bincode into an instance of the Peer struct.
pub fn get_peer(&self, public_key: &str) -> Result<Option<Peer>> {
debug!(
"Retrieving peer data for {} from 'peers' database tree",
&public_key
);
let peer = self
.peer_tree
.get(public_key.as_bytes())
.unwrap()
.map(|peer| {
debug!("Deserializing peer data for {} from bincode", &public_key);
bincode::deserialize(&peer).unwrap()
});
Ok(peer)
}
/// Get a list of all peers in the peer tree. The byte value for each
/// peer entry is deserialized from bincode into an instance of the Peer
/// struct.
pub fn get_peers(&self) -> Vec<Peer> {
debug!("Retrieving data for all peers in the 'peers' database tree");
let mut peers = Vec::new();
self.peer_tree
.iter()
.map(|peer| peer.unwrap())
.for_each(|peer| {
debug!(
"Deserializing peer data for {} from bincode",
String::from_utf8_lossy(&peer.0).into_owned()
);
peers.push(bincode::deserialize(&peer.1).unwrap())
});
peers
}
/// Remove a peer from the database, as represented by the given public
/// key.
pub fn remove_peer(&self, public_key: &str) -> Result<()> {
debug!("Removing peer {} from 'peers' database tree", &public_key);
self.peer_tree.remove(&public_key).map(|_| ())
}
/// Add a post to the database by inserting an instance of the Post struct
/// into the post tree.
pub fn add_post(&self, public_key: &str, post: Post) -> Result<Option<IVec>> {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);
let post_bytes = bincode::serialize(&post).unwrap();
debug!("Inserting post {} into 'posts' database tree", &post_key);
self.post_tree.insert(post_key.as_bytes(), post_bytes)
}
/// Add a batch of posts to the database by inserting a vector of instances
/// of the Post struct into the post tree.
pub fn add_post_batch(&self, public_key: &str, posts: Vec<Post>) -> Result<()> {
let mut post_batch = Batch::default();
for post in posts {
let post_key = format!("{}_{}", public_key, post.key);
debug!("Serializing post data for {} to bincode", &post_key);
let post_bytes = bincode::serialize(&post).unwrap();
debug!("Inserting post {} into 'posts' database tree", &post_key);
post_batch.insert(post_key.as_bytes(), post_bytes)
}
debug!("Applying batch insertion into 'posts' database tree");
self.post_tree.apply_batch(post_batch)
}
/// Get a list of all posts in the post tree authored by the given public
/// key and sort them by timestamp in descending order. The byte value for
/// each matching entry is deserialized from bincode into an instance of
/// the Post struct.
pub fn get_posts(&self, public_key: &str) -> Result<Vec<Post>> {
debug!("Retrieving data for all posts in the 'posts' database tree");
let mut posts = Vec::new();
self.post_tree
.scan_prefix(public_key.as_bytes())
.map(|post| post.unwrap())
.for_each(|post| {
debug!(
"Deserializing post data for {} from bincode",
String::from_utf8_lossy(&post.0).into_owned()
);
posts.push(bincode::deserialize(&post.1).unwrap())
});
posts.sort_by(|a: &Post, b: &Post| b.timestamp.cmp(&a.timestamp));
Ok(posts)
}
/// Get a single post from the post tree, authored by the given public key
/// and defined by the given message ID. The byte value for the matching
/// entry, if found, is deserialized from bincode into an instance of the
/// Post struct.
pub fn get_post(&self, public_key: &str, msg_id: &str) -> Result<Option<Post>> {
let post_key = format!("{}_{}", public_key, msg_id);
debug!(
"Retrieving post data for {} from 'posts' database tree",
&post_key
);
let post = self
.post_tree
.get(post_key.as_bytes())
.unwrap()
.map(|post| {
debug!("Deserializing post data for {} from bincode", &post_key);
bincode::deserialize(&post).unwrap()
});
Ok(post)
}
}

View File

@ -0,0 +1,58 @@
mod db;
mod routes;
mod sbot;
mod task_loop;
mod utils;
use async_std::channel;
use log::info;
use rocket::{
fairing::AdHoc,
fs::{relative, FileServer},
launch, routes,
};
use rocket_dyn_templates::Template;
use xdg::BaseDirectories;
use crate::{db::Database, routes::*, task_loop::Task};
#[launch]
async fn rocket() -> _ {
// Create the key-value database.
let xdg_dirs = BaseDirectories::with_prefix("lykin").unwrap();
let db_path = xdg_dirs
.place_config_file("database")
.expect("cannot create database directory");
let db = Database::init(&db_path);
let db_clone = db.clone();
// Create a message passing channel.
let (tx, rx) = channel::unbounded();
let tx_clone = tx.clone();
// Spawn the task loop, passing in the receiver half of the channel.
info!("Spawning task loop");
task_loop::spawn(db_clone, rx).await;
rocket::build()
.manage(db)
.manage(tx)
.attach(Template::fairing())
.mount(
"/",
routes![
home,
subscribe_form,
unsubscribe_form,
download_latest_posts,
post,
posts
],
)
.mount("/", FileServer::from(relative!("static")))
.attach(AdHoc::on_shutdown("cancel task loop", |_| {
Box::pin(async move {
tx_clone.send(Task::Cancel).await.unwrap();
})
}))
}

View File

@ -0,0 +1,179 @@
use async_std::channel::Sender;
use log::{info, warn};
use rocket::{
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
uri, FromForm, State,
};
use rocket_dyn_templates::{context, Template};
use crate::{
db::{Database, Peer},
sbot,
task_loop::Task,
utils,
};
#[derive(FromForm)]
pub struct PeerForm {
pub public_key: String,
}
#[get("/")]
pub async fn home(db: &State<Database>, flash: Option<FlashMessage<'_>>) -> Template {
let peers = db.get_peers();
Template::render("base", context! { peers: peers, flash: flash })
}
#[post("/subscribe", data = "<peer>")]
pub async fn subscribe_form(
db: &State<Database>,
tx: &State<Sender<Task>>,
peer: Form<PeerForm>,
) -> Result<Redirect, Flash<Redirect>> {
if let Err(e) = utils::validate_public_key(&peer.public_key) {
let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
warn!("{}", validation_err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
} else {
info!("Public key {} is valid", &peer.public_key);
// Retrieve the name of the peer to which we are subscribing.
let peer_name = match sbot::get_name(&peer.public_key).await {
Ok(name) => name,
Err(e) => {
warn!("Failed to fetch name for peer {}: {}", &peer.public_key, e);
// Return an empty string if an error occurs.
String::from("")
}
};
let peer_info = Peer::new(&peer.public_key).set_name(&peer_name);
match sbot::follow_if_not_following(&peer.public_key).await {
Ok(_) => {
// Add the peer to the database.
if db.add_peer(peer_info).is_ok() {
info!("Added {} to 'peers' database tree", &peer.public_key);
let peer_id = peer.public_key.to_string();
// Fetch all root posts authored by the peer we're subscribing
// to. Posts will be added to the key-value database.
if let Err(e) = tx.send(Task::FetchAllPosts(peer_id)).await {
warn!("Task loop error: {}", e)
}
} else {
let err_msg = format!(
"Failed to add peer {} to 'peers' database tree",
&peer.public_key
);
warn!("{}", err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), err_msg));
}
}
Err(e) => {
warn!("{}", e);
return Err(Flash::error(Redirect::to(uri!(home)), e));
}
}
}
Ok(Redirect::to(uri!(home)))
}
#[post("/unsubscribe", data = "<peer>")]
pub async fn unsubscribe_form(
db: &State<Database>,
peer: Form<PeerForm>,
) -> Result<Redirect, Flash<Redirect>> {
if let Err(e) = utils::validate_public_key(&peer.public_key) {
let validation_err_msg = format!("Public key {} is invalid: {}", &peer.public_key, e);
warn!("{}", validation_err_msg);
return Err(Flash::error(Redirect::to(uri!(home)), validation_err_msg));
} else {
info!("Public key {} is valid", &peer.public_key);
match sbot::unfollow_if_following(&peer.public_key).await {
Ok(_) => {
// Remove the peer from the database.
if db.remove_peer(&peer.public_key).is_ok() {
info!(
"Removed peer {} from 'peers' database tree",
&peer.public_key
);
} else {
warn!(
"Failed to remove peer {} from 'peers' database tree",
&peer.public_key
);
}
}
Err(e) => {
warn!("{}", e);
return Err(Flash::error(Redirect::to(uri!(home)), e));
}
}
}
Ok(Redirect::to(uri!(home)))
}
#[get("/posts/download_latest")]
pub async fn download_latest_posts(db: &State<Database>, tx: &State<Sender<Task>>) -> Redirect {
for peer in db.get_peers() {
// Fetch the latest root posts authored by each peer we're
// subscribed to. Posts will be added to the key-value database.
if let Err(e) = tx
.send(Task::FetchLatestPosts(peer.public_key.clone()))
.await
{
warn!("Task loop error: {}", e)
}
// Fetch the latest name for each peer we're subscribed to and update
// the database.
if let Err(e) = tx.send(Task::FetchLatestName(peer.public_key)).await {
warn!("Task loop error: {}", e)
}
}
Redirect::to(uri!(home))
}
#[get("/posts/<public_key>")]
pub async fn posts(db: &State<Database>, public_key: &str) -> Template {
// Fetch the list of peers we subscribe to.
let peers = db.get_peers();
// Fetch the posts for the given peer from the key-value database.
let posts = db.get_posts(public_key).unwrap();
// Define context data to be rendered in the template.
let context = context! {
peers: &peers,
// This variable allows us to track which peer is currently selected
// from within the template. We'll use this variable to render the
// name of the selected peer in bold.
selected_peer: &public_key,
posts: &posts
};
Template::render("base", context)
}
#[get("/posts/<public_key>/<msg_id>")]
pub async fn post(db: &State<Database>, public_key: &str, msg_id: &str) -> Template {
let peers = db.get_peers();
let posts = db.get_posts(public_key).unwrap();
let post = db.get_post(public_key, msg_id).unwrap();
let context = context! {
peers: &peers,
selected_peer: &public_key,
selected_post: &msg_id,
posts: &posts,
post: &post
};
Template::render("base", context)
}

213
part_8_ui_posts/src/sbot.rs Normal file
View File

@ -0,0 +1,213 @@
use std::env;
use async_std::stream::StreamExt;
use chrono::NaiveDateTime;
use golgi::{
api::{friends::RelationshipQuery, history_stream::CreateHistoryStream},
messages::{SsbMessageContentType, SsbMessageKVT},
sbot::Keystore,
GolgiError, Sbot,
};
use log::{info, warn};
use serde_json::value::Value;
use crate::db::Post;
/// Initialise a connection to a Scuttlebutt server.
pub async fn init_sbot() -> Result<Sbot, String> {
let go_sbot_port = env::var("GO_SBOT_PORT").unwrap_or_else(|_| "8021".to_string());
let keystore = Keystore::GoSbot;
let ip_port = Some(format!("127.0.0.1:{}", go_sbot_port));
let net_id = None;
Sbot::init(keystore, ip_port, net_id)
.await
.map_err(|e| e.to_string())
}
/// Return the public key of the local sbot instance.
pub async fn whoami() -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.whoami().await.map_err(|e| e.to_string())
}
/// Check follow status.
///
/// Is peer A (`public_key_a`) following peer B (`public_key_b`)?
pub async fn is_following(public_key_a: &str, public_key_b: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
let query = RelationshipQuery {
source: public_key_a.to_string(),
dest: public_key_b.to_string(),
};
sbot.friends_is_following(query)
.await
.map_err(|e| e.to_string())
}
/// Follow a peer.
pub async fn follow_peer(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.follow(public_key).await.map_err(|e| e.to_string())
}
/// Unfollow a peer.
pub async fn unfollow_peer(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.unfollow(public_key).await.map_err(|e| e.to_string())
}
/// Return the name (self-identifier) for the peer associated with the given
/// public key.
///
/// The public key of the peer will be returned if a name is not found.
pub async fn get_name(public_key: &str) -> Result<String, String> {
let mut sbot = init_sbot().await?;
sbot.get_name(public_key).await.map_err(|e| e.to_string())
}
/// Check the follow status of a remote peer and follow them if not already
/// following.
pub async fn follow_if_not_following(remote_peer: &str) -> Result<(), String> {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "false" => match follow_peer(remote_peer).await {
Ok(_) => {
info!("Followed peer {}", &remote_peer);
Ok(())
}
Err(e) => {
let err_msg = format!("Failed to follow peer {}: {}", &remote_peer, e);
warn!("{}", err_msg);
Err(err_msg)
}
},
Ok(status) if status.as_str() == "true" => {
info!(
"Already following peer {}. No further action taken",
&remote_peer
);
Ok(())
}
_ => Err(
"Failed to determine follow status: received unrecognised response from local sbot"
.to_string(),
),
}
} else {
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
warn!("{}", err_msg);
Err(err_msg)
}
}
/// Check the follow status of a remote peer and unfollow them if already
/// following.
pub async fn unfollow_if_following(remote_peer: &str) -> Result<(), String> {
if let Ok(whoami) = whoami().await {
match is_following(&whoami, remote_peer).await {
Ok(status) if status.as_str() == "true" => {
info!("Unfollowing peer {}", &remote_peer);
match unfollow_peer(remote_peer).await {
Ok(_) => {
info!("Unfollowed peer {}", &remote_peer);
Ok(())
}
Err(e) => {
let err_msg = format!("Failed to unfollow peer {}: {}", &remote_peer, e);
warn!("{}", err_msg);
Err(err_msg)
}
}
}
_ => Err(
"Failed to determine follow status: received unrecognised response from local sbot"
.to_string(),
),
}
} else {
let err_msg = String::from("Received an error during `whoami` RPC call. Please ensure the go-sbot is running and try again");
warn!("{}", err_msg);
Err(err_msg)
}
}
/// Return a stream of messages authored by the given public key.
///
/// This returns all messages regardless of type.
pub async fn get_message_stream(
public_key: &str,
sequence_number: u64,
) -> impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>> {
let mut sbot = init_sbot().await.unwrap();
let history_stream_args = CreateHistoryStream::new(public_key.to_string())
.keys_values(true, true)
.after_seq(sequence_number);
sbot.create_history_stream(history_stream_args)
.await
.unwrap()
}
/// Filter a stream of messages and return a vector of root posts.
///
/// Each returned vector element includes the key of the post, the content
/// text, the date the post was published, the sequence number of the post
/// and whether it is read or unread.
pub async fn get_root_posts(
history_stream: impl futures::Stream<Item = Result<SsbMessageKVT, GolgiError>>,
) -> (u64, Vec<Post>) {
let mut latest_sequence = 0;
let mut posts = Vec::new();
futures::pin_mut!(history_stream);
while let Some(res) = history_stream.next().await {
match res {
Ok(msg) => {
if msg.value.is_message_type(SsbMessageContentType::Post) {
let content = msg.value.content.to_owned();
if let Value::Object(content_map) = content {
if !content_map.contains_key("root") {
latest_sequence = msg.value.sequence;
let text = match content_map.get_key_value("text") {
Some(value) => value.1.to_string(),
None => String::from(""),
};
let timestamp = msg.value.timestamp.round() as i64 / 1000;
let datetime = NaiveDateTime::from_timestamp(timestamp, 0);
let date = datetime.format("%d %b %Y").to_string();
let subject = text.get(0..52).map(|s| s.to_string());
let post = Post::new(
msg.key.to_owned(),
text,
date,
msg.value.sequence,
timestamp,
subject,
);
posts.push(post)
}
}
}
}
Err(err) => {
// Print the `GolgiError` of this element to `stderr`.
warn!("err: {:?}", err);
}
}
}
(latest_sequence, posts)
}

View File

@ -0,0 +1,96 @@
use async_std::{channel::Receiver, task};
use log::{info, warn};
use crate::{sbot, Database};
async fn fetch_posts_and_update_db(db: &Database, peer_id: String, after_sequence: u64) {
let peer_msgs = sbot::get_message_stream(&peer_id, after_sequence).await;
let (latest_sequence, root_posts) = sbot::get_root_posts(peer_msgs).await;
match db.add_post_batch(&peer_id, root_posts) {
Ok(_) => {
info!(
"Inserted batch of posts into database post tree for peer: {}",
&peer_id
)
}
Err(e) => warn!(
"Failed to insert batch of posts into database post tree for peer: {}: {}",
&peer_id, e
),
}
// Update the value of the latest sequence number for
// the peer (this is stored in the database).
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
db.add_peer(peer.set_latest_sequence(latest_sequence))
.unwrap();
}
}
/// Request the name of the peer represented by the given public key (ID)
/// and update the existing entry in the database.
async fn fetch_name_and_update_db(db: &Database, peer_id: String) {
match sbot::get_name(&peer_id).await {
Ok(name) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
let updated_peer = peer.set_name(&name);
match db.add_peer(updated_peer) {
Ok(_) => info!("Updated name for peer: {}", &peer_id),
Err(e) => {
warn!("Failed to update name for peer: {}: {}", &peer_id, e)
}
}
}
}
Err(e) => warn!("Failed to fetch name for {}: {}", &peer_id, e),
}
}
pub enum Task {
Cancel,
FetchAllPosts(String),
FetchLatestPosts(String),
FetchLatestName(String),
}
/// Spawn an asynchronous loop which receives tasks over an unbounded channel
/// and invokes task functions accordingly.
pub async fn spawn(db: Database, rx: Receiver<Task>) {
task::spawn(async move {
while let Ok(task) = rx.recv().await {
match task {
// Fetch all messages authored by the given peer, filter
// the root posts and insert them into the posts tree of the
// database.
Task::FetchAllPosts(peer_id) => {
info!("Fetching all posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, 0).await;
}
// Fetch only the latest messages authored by the given peer,
// ie. messages with sequence numbers greater than those
// which are already stored in the database.
//
// Retrieve the root posts from those messages and insert them
// into the posts tree of the database.
Task::FetchLatestPosts(peer_id) => {
if let Ok(Some(peer)) = db.get_peer(&peer_id) {
info!("Fetching latest posts for peer: {}", peer_id);
fetch_posts_and_update_db(&db, peer_id, peer.latest_sequence).await;
}
}
// Fetch the latest name for the given peer and update the
// peer entry in the peers tree of the database.
Task::FetchLatestName(peer_id) => {
info!("Fetching latest name for peer: {}", peer_id);
fetch_name_and_update_db(&db, peer_id).await;
}
// Break out of the task loop.
Task::Cancel => {
info!("Exiting task loop...");
break;
}
}
}
});
}

View File

@ -0,0 +1,32 @@
//! Public key validation.
/// Ensure that the given public key is a valid ed25519 key.
///
/// Return an error string if the key is invalid.
pub fn validate_public_key(public_key: &str) -> Result<(), String> {
// Ensure the ID starts with the correct sigil link.
if !public_key.starts_with('@') {
return Err("expected '@' sigil as first character".to_string());
}
// Find the dot index denoting the start of the algorithm definition tag.
let dot_index = match public_key.rfind('.') {
Some(index) => index,
None => return Err("no dot index was found".to_string()),
};
// Check the hashing algorithm (must end with ".ed25519").
if !&public_key.ends_with(".ed25519") {
return Err("hashing algorithm must be ed25519".to_string());
}
// Obtain the base64 portion (substring) of the public key.
let base64_str = &public_key[1..dot_index];
// Ensure the length of the base64 encoded ed25519 public key is correct.
if base64_str.len() != 44 {
return Err("base64 data length is incorrect".to_string());
}
Ok(())
}

View File

@ -0,0 +1,153 @@
.content {
background-color: lightyellow;
border: 5px solid #ffd700;
border-radius: 1rem;
grid-area: content;
padding: 1.5rem;
overflow-y: scroll;
word-wrap: anywhere;
}
.container {
height: 100%;
width: 100%;
margin: 0;
}
.disabled {
opacity: 0.4;
pointer-events: none;
}
.flash-message {
margin-left: auto;
margin-right: 0;
margin-top: 0;
margin-bottom: 0;
color: red;
}
.flex-container {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.flex-container > input {
margin: 0.3rem;
}
.grid-container {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 1fr 3fr;
grid-template-areas: 'nav' 'peers' 'posts' 'content';
grid-gap: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.2rem;
overflow: hidden;
height: 85vh;
}
@media only screen and (min-width: 600px) {
.grid-container {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: 1fr 3fr 4fr;
grid-template-areas:
'nav nav nav nav nav'
'peers posts posts posts posts'
'peers content content content content';
}
}
.icon {
margin-left: 1rem;
}
.nav {
background-color: lightgreen;
border: 5px solid #19a974;
border-radius: 1rem;
grid-area: nav;
padding: 1rem;
}
.peers {
background-color: lightblue;
border: 5px solid #357edd;
border-radius: 1rem;
grid-area: peers;
text-align: left;
}
.peers > ul {
padding-left: 1rem;
}
.peers > ul > li > a {
justify-content: space-between;
}
.peers > ul > li > a > p {
margin: 0;
font-weight: bold;
padding-right: 1rem;
}
.post > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts {
background-color: bisque;
border: 5px solid #ff6300;
border-radius: 1rem;
grid-area: posts;
overflow-y: scroll;
}
.posts > ul {
padding-left: 1rem;
padding-right: 1rem;
}
.posts > ul > li > a {
justify-content: space-between;
}
.posts > ul > li > a > p {
margin: 0;
}
.selected {
background-color: #f9c587;
}
a {
text-decoration: none;
color: black;
}
code {
word-wrap: anywhere;
}
form {
margin-left: auto;
margin-right: 0.5rem;
}
h1 {
margin-left: 1rem;
}
img {
width: 3rem;
}
li {
list-style: none;
font-size: 12px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1 @@
<a href="https://www.flaticon.com/free-icons/download" title="download icons">Download icons created by Kiranshastry - Flaticon</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>lykin</title>
<meta name="description" content="lykin: an SSB tutorial application">
<meta name="author" content="glyph">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/lykin.css">
</head>
<body class="container">
<h1>
<a href="/">lykin</a>
</h1>
</a>
<div class="grid-container">
{% include "topbar" %}
{% include "peer_list" %}
{% include "post_list" %}
{% include "post_content" %}
</div>
</body>
</html>

View File

@ -0,0 +1,17 @@
<div class="peers">
<ul>
{% for peer in peers -%}
<li>
<a class="flex-container" href="/posts/{{ peer.public_key | urlencode_strict }}">
<code{% if selected_peer and peer.public_key == selected_peer %} style="font-weight: bold;"{% endif %}>
{% if peer.name %}
{{ peer.name }}
{% else %}
{{ peer.public_key }}
{% endif %}
</code>
</a>
</li>
{%- endfor %}
</ul>
</div>

View File

@ -0,0 +1,5 @@
<div class="content">
{% if post %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') | trim }}
{% endif %}
</div>

View File

@ -0,0 +1,20 @@
<div class="posts">
{% if posts %}
<ul>
{% for post in posts -%}
<li{% if selected_post and post.key == selected_post %} class="selected"{% endif %}>
<a class="flex-container" href="/posts/{{ selected_peer | urlencode_strict }}/{{ post.key | urlencode_strict }}">
<code>
{% if post.subject %}
{{ post.subject | trim_start_matches(pat='"') }}...
{% else %}
{{ post.text | trim_start_matches(pat='"') | trim_end_matches(pat='"') }}
{% endif %}
</code>
<p>{{ post.date }}</p>
</a>
</li>
{%- endfor %}
</ul>
{% endif %}
</div>

View File

@ -0,0 +1,28 @@
<div class="nav">
<div class="flex-container">
<a href="/posts/download_latest" title="Download latest posts">
<img src="/icons/download.png">
</a>
<a class="disabled icon" title="Download latest posts">
<img src="/icons/download.png">
</a>
<a class="disabled icon" title="Mark as read">
<img src="/icons/read_post.png">
</a>
<a class="disabled icon" title="Mark as unread">
<img src="/icons/unread_post.png">
</a>
<a class="disabled icon" title="Delete post">
<img src="/icons/delete_post.png">
</a>
<form class="flex-container" action="/subscribe" method="post">
<label for="public_key">Public Key</label>
<input type="text" id="public_key" name="public_key" maxlength=53>
<input type="submit" value="Subscribe">
<input type="submit" value="Unsubscribe" formaction="/unsubscribe">
</form>
{% if flash and flash.kind == "error" %}
<p class="flash-message">[ {{ flash.message }} ]</p>
{% endif %}
</div>
</div>