From a820ed231c00158d31f404b739e310ec99df5a21 Mon Sep 17 00:00:00 2001 From: glyph Date: Wed, 7 Sep 2022 16:58:09 +0100 Subject: [PATCH] change the name of part 6 --- part_6_ui_layout/Cargo.toml | 20 ++ part_6_ui_layout/README.md | 312 ++++++++++++++++++ part_6_ui_layout/src/db.rs | 205 ++++++++++++ part_6_ui_layout/src/main.rs | 48 +++ part_6_ui_layout/src/routes.rs | 119 +++++++ part_6_ui_layout/src/sbot.rs | 213 ++++++++++++ part_6_ui_layout/src/task_loop.rs | 76 +++++ part_6_ui_layout/src/utils.rs | 32 ++ part_6_ui_layout/static/css/lykin.css | 153 +++++++++ part_6_ui_layout/static/icons/delete_post.png | Bin 0 -> 3961 bytes part_6_ui_layout/static/icons/download.png | Bin 0 -> 3395 bytes .../static/icons/icon_attributions | 1 + part_6_ui_layout/static/icons/read_post.png | Bin 0 -> 4685 bytes part_6_ui_layout/static/icons/unread_post.png | Bin 0 -> 4649 bytes part_6_ui_layout/templates/base.html.tera | 23 ++ .../templates/peer_list.html.tera | 14 + .../templates/post_content.html.tera | 6 + .../templates/post_list.html.tera | 10 + part_6_ui_layout/templates/topbar.html.tera | 25 ++ 19 files changed, 1257 insertions(+) create mode 100644 part_6_ui_layout/Cargo.toml create mode 100644 part_6_ui_layout/README.md create mode 100644 part_6_ui_layout/src/db.rs create mode 100644 part_6_ui_layout/src/main.rs create mode 100644 part_6_ui_layout/src/routes.rs create mode 100644 part_6_ui_layout/src/sbot.rs create mode 100644 part_6_ui_layout/src/task_loop.rs create mode 100644 part_6_ui_layout/src/utils.rs create mode 100644 part_6_ui_layout/static/css/lykin.css create mode 100644 part_6_ui_layout/static/icons/delete_post.png create mode 100644 part_6_ui_layout/static/icons/download.png create mode 100644 part_6_ui_layout/static/icons/icon_attributions create mode 100644 part_6_ui_layout/static/icons/read_post.png create mode 100644 part_6_ui_layout/static/icons/unread_post.png create mode 100644 part_6_ui_layout/templates/base.html.tera create mode 100644 part_6_ui_layout/templates/peer_list.html.tera create mode 100644 part_6_ui_layout/templates/post_content.html.tera create mode 100644 part_6_ui_layout/templates/post_list.html.tera create mode 100644 part_6_ui_layout/templates/topbar.html.tera diff --git a/part_6_ui_layout/Cargo.toml b/part_6_ui_layout/Cargo.toml new file mode 100644 index 0000000..e3afa59 --- /dev/null +++ b/part_6_ui_layout/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "part_6_ui_layout" +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" diff --git a/part_6_ui_layout/README.md b/part_6_ui_layout/README.md new file mode 100644 index 0000000..f50f937 --- /dev/null +++ b/part_6_ui_layout/README.md @@ -0,0 +1,312 @@ +# lykin tutorial + +## Part 6: UI Layout and Peers List + +### Introduction + +Up to this point in the series we've been primarily focused on backend development; we've created a webserver, setup a key-value store, written functions for interacting with an sbot instance and made a task loop to run background processes. It's time to focus on the UI of our application. + +Today we'll write Tera HTML templates and create the layout of our user interface using CSS. We will then begin to populate the templates with data from our key-value store, such as the list of peers we're subscribed to. This is an exciting phase in the development of our application. Let's begin! + +### Outline + +Here's what we'll tackle in this sixth part of the series: + + - Define layout shape + - Download stylesheet and icons + - Mount the fileserver + - Create layout in base template + - Create templates + - Navigation bar + - Peer list + - Post list + - Post content + - Populate peer list with data + +### Define Layout Shape + +Before getting started with code, it might be helpful to know the shape of the layout we'll be building in this installment. + +The layout is composed of a topbar for navigation, a peers column on the left and a column of posts and post content on the right. Here's a diagram to illustrate the basic shape: + +```text +┌───────────────────────────────────────────────────┐ +│ Navigation │ +├──────────────┬────────────────────────────────────┤ +│ Peer List │ Post List │ +│ │ │ +│ │ │ +│ ├────────────────────────────────────┤ +│ │ Post Content │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +└──────────────┴────────────────────────────────────┘ +``` + +### Download Icons and Stylesheet + +We are going to use CSS grid to create the layout of our user interface. I am not going to deal with CSS in-detail in this tutorial so you may want to refer to [A Complete Guide to Grid](https://css-tricks.com/snippets/css/complete-guide-grid/), authored by Chris House on CSS-Tricks, to fill any gaps in your understanding. We will simply download the stylesheet and icons so that we can focus on the rest of the application. + +We begin by creating a `static` directory in the root directory of our application. Next, we create subdirectories named `css` and `icons` inside the static directory. Like so: + +```text +. +├── static +│ ├── css +│ └── icons +``` + +Now we can download the assets from the [lykin repo](https://git.coopcloud.tech/glyph/lykin): + +```bash +# Ensure you are calling these commands from the root directory. +# You can download the files manually if you do not have wget. +# ... +# Download the CSS stylesheet: +wget -O static/css/lykin.css https://git.coopcloud.tech/glyph/lykin/raw/branch/main/static/css/lykin.css +# Move into the icons subdirectory: +cd static/icons +# Download the icons: +wget https://git.coopcloud.tech/glyph/lykin/raw/branch/main/static/icons/delete_post.png +wget https://git.coopcloud.tech/glyph/lykin/raw/branch/main/static/icons/download.png +wget https://git.coopcloud.tech/glyph/lykin/raw/branch/main/static/icons/read_post.png +wget https://git.coopcloud.tech/glyph/lykin/raw/branch/main/static/icons/unread_post.png +# Move back to the root directory: +cd ../.. +``` + +**Note:** The icons we're using were created by [Kiranshastry](https://www.flaticon.com/authors/kiranshastry) and can be found on Flaticon. + +### Mount the Fileserver + +In order to be able to serve the CSS and icons, we need to mount a fileserver to our Rocket application and provide the path to the assets: + +`src/main.rs` + +```rust +use rocket::fs::{FileServer, relative}; + +#[launch] +async fn rocket() -> _ { + // ... + + rocket::build() + .manage(db) + .manage(tx) + .attach(Template::fairing()) + .mount("/", routes![home, subscribe_form, unsubscribe_form]) + // Mount the fileserver and set a relative path with `static` as root. + .mount("/", FileServer::from(relative!("static"))) + .attach(AdHoc::on_shutdown("cancel task loop", |_| { + Box::pin(async move { + tx_clone.send(Task::Cancel).await.unwrap(); + }) + })) +} +``` + +### Create Layout in Base Template + +Now that the assets and fileserver are in place, we can turn our attention to the templates. Let's begin by modifying the base HTML template we wrote previously. In it, we're going to create a grid container and include (`import`) the templates representing each section of the layout. We will then create the templates for each section of the layout. + +`templates/base.html.tera` + +```html + + + + + lykin + + + + + + +

+ lykin +

+ +
+ {% include "topbar" %} + {% include "peer_list" %} + {% include "post_list" %} + {% include "post_content" %} +
+ + +``` + +### Create Navigation Bar Template + +With the base layout in place, we can begin to populate the constituent templates. The navigation / topbar consists of a row of four icons followed by a form for subscribing to and unsubscribing from peers. Clicking on each icon will eventually perform an action: download the latest posts, mark a post as 'read', mark a post as 'unread' and delete a post. We'll create the routes and handlers for those actions later in the series. For now, it's enough to have the icons without any associated actions. + +You're invited to take a peek at the stylesheet (`lykin.css`) if you're curious about any of the classes used in the template, such as `disabled` or `flex-container`. + +`templates/topbar.html.tera` + +```html + +``` + +The `{% ... %}` syntax in the template code is Tera syntax (inspired by Jinja2 and Django templates). Consult the [documentation](https://tera.netlify.app/docs/) if you wish to know more. We will add similar control-flow syntax later in the tutorial series to selectively set the `href` tags of the anchor elements and to enable or disable the navigation elements. + +### Create Peer List Template + +This one couldn't be much simpler. We define a `div` element for our list of peers and populate an unordered list. We first try to display the name of the peer and fallback to the public key if the `name` string is empty. Each peer in this template corresponds with an instance of the `Peer` struct defined in our `src/db.rs` file, hence the `name` and `public_key` fields. + +`templates/peer_list.html.tera` + +```html +
+ +
+``` + +### Create Post List Template + +Now we'll write another simple `for` loop to display a list of posts. Eventually we'll update this template to display the subject of each post authored by the selected peer. Clicking on a peer in the peer list will serve as the trigger to update the selected peer variable, allowing us to define whose posts we should be displaying in this list. + +`templates/post_list.html.tera` + +```html +
+ {% if posts %} + + {% endif %} +
+``` + +### Create Post Content Template + +Finally, we'll write the template to display the content of a selected post. + +`templates/post_content.html.tera` + +```html +
+{% if post %} + {{ post.text }} +{% endif %} +
+``` + +### Populate Peer List with Data + +If we run our application at this point and visit `localhost:8000` in a browser we receive a `500: Internal Server Error`. The output in the Rocket application logs points to the problem: + +```text +>> Error rendering Tera template 'base'. +>> Failed to render 'base' +>> Variable `peers` not found in context while rendering 'peer_list' +>> Template 'base' failed to render. +>> Outcome: Failure +``` + +The `peer_list.html.tera` template expects a `peers` variable which has not been provided. In other words, the template has not been provided with the context it requires to render. What we need to do is revisit our `home` route handler and provide the context by querying our key-value store for a list of peers. + +`src/routes.rs` + +```rust +#[get("/")] +pub async fn home(db: &State, flash: Option>) -> Template { + // Retrieve the list of peers to whom we subscribe. + let peers = db.get_peers(); + + // Render the template with `peers` and `flash`. + Template::render("base", context! { peers: peers, flash: flash }) +} +``` + +Great, the template will now be hydrated with the data it expects. There's just one more problem: the `db.get_peers()` method doesn't exist yet. Let's write it now: + +`src/db.rs` + +```rust +impl Database { + // ... + + // 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 { + debug!("Retrieving data for all peers in the 'peers' database tree"); + // Define an empty vector to store the list of peers. + 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() + ); + // Add a peer to the peers vector. + peers.push(bincode::deserialize(&peer.1).unwrap()) + }); + + peers + } +} +``` + +The above method is very similar to the `get_peer` method we define previously. However, instead of retrieving a specific peer from the peer database tree, we iterate over all key-value pairs in the tree and push the deserialized value to a vector. + +Run the application, visit `localhost:8000` in your browser and you should see a beautiful, colourful layout! After ensuring your instance of go-sbot is running, try to subscribe and unsubscribe to some peers to test things out. Feel free to play around with the styles in `static/css/lykin.css` if you wish to change the colours or other aspects of the design. + +### Conclusion + +In this installment we took strides in improving the visual aspect of our application. We defined a layout using CSS and HTML templates, added a fileserver to serve assets, updated our `home` route handler to provide the required context data to our templates and added a `get_peers()` method to the database. + +Our application has come a long way. We can now subscribe and unsubscribe to the root posts of our peers and display a list of subscribed peers in a neat user interface. + +In the next installment we will return to the database and Scuttlebutt-related code in our application, adding the ability to retrieve only the latest posts for each of our peers from the sbot. This will give us an efficient way of keeping our application up to date with the latest happenings in the Scuttleverse. In doing so, we will add a means of tracking the latest sequence number of each of the peers we subscribe to. + +## 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. diff --git a/part_6_ui_layout/src/db.rs b/part_6_ui_layout/src/db.rs new file mode 100644 index 0000000..9e50d84 --- /dev/null +++ b/part_6_ui_layout/src/db.rs @@ -0,0 +1,205 @@ +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, +} + +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(), + } + } + + /// 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 + } + } +} + +/// 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, +} + +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, + ) -> 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> { + 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> { + 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 { + 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> { + 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) -> 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) + } +} diff --git a/part_6_ui_layout/src/main.rs b/part_6_ui_layout/src/main.rs new file mode 100644 index 0000000..6c849e2 --- /dev/null +++ b/part_6_ui_layout/src/main.rs @@ -0,0 +1,48 @@ +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]) + .mount("/", FileServer::from(relative!("static"))) + .attach(AdHoc::on_shutdown("cancel task loop", |_| { + Box::pin(async move { + tx_clone.send(Task::Cancel).await.unwrap(); + }) + })) +} diff --git a/part_6_ui_layout/src/routes.rs b/part_6_ui_layout/src/routes.rs new file mode 100644 index 0000000..e3c9b83 --- /dev/null +++ b/part_6_ui_layout/src/routes.rs @@ -0,0 +1,119 @@ +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, flash: Option>) -> Template { + let peers = db.get_peers(); + + Template::render("base", context! { peers: peers, flash: flash }) +} + +#[post("/subscribe", data = "")] +pub async fn subscribe_form( + db: &State, + tx: &State>, + peer: Form, +) -> Result> { + 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 = "")] +pub async fn unsubscribe_form( + db: &State, + peer: Form, +) -> Result> { + 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))) +} diff --git a/part_6_ui_layout/src/sbot.rs b/part_6_ui_layout/src/sbot.rs new file mode 100644 index 0000000..2c7b218 --- /dev/null +++ b/part_6_ui_layout/src/sbot.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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>, +) -> (u64, Vec) { + 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) +} diff --git a/part_6_ui_layout/src/task_loop.rs b/part_6_ui_layout/src/task_loop.rs new file mode 100644 index 0000000..d71282e --- /dev/null +++ b/part_6_ui_layout/src/task_loop.rs @@ -0,0 +1,76 @@ +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 + ), + } +} + +/// 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), + 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::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 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; + } + } + } + }); +} diff --git a/part_6_ui_layout/src/utils.rs b/part_6_ui_layout/src/utils.rs new file mode 100644 index 0000000..8f2cccb --- /dev/null +++ b/part_6_ui_layout/src/utils.rs @@ -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(()) +} diff --git a/part_6_ui_layout/static/css/lykin.css b/part_6_ui_layout/static/css/lykin.css new file mode 100644 index 0000000..233e9f3 --- /dev/null +++ b/part_6_ui_layout/static/css/lykin.css @@ -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; +} diff --git a/part_6_ui_layout/static/icons/delete_post.png b/part_6_ui_layout/static/icons/delete_post.png new file mode 100644 index 0000000000000000000000000000000000000000..572528dd4360aab1322a3eb63f202c4c92ccbed0 GIT binary patch literal 3961 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 NtlJUvLsvq^{{X}dCL{m= literal 0 HcmV?d00001 diff --git a/part_6_ui_layout/static/icons/icon_attributions b/part_6_ui_layout/static/icons/icon_attributions new file mode 100644 index 0000000..f990896 --- /dev/null +++ b/part_6_ui_layout/static/icons/icon_attributions @@ -0,0 +1 @@ +Download icons created by Kiranshastry - Flaticon \ No newline at end of file diff --git a/part_6_ui_layout/static/icons/read_post.png b/part_6_ui_layout/static/icons/read_post.png new file mode 100644 index 0000000000000000000000000000000000000000..72b4f841c5d2a7b8186f347d113d1b332164a853 GIT binary patch literal 4685 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

6LI+;C1@7S_dI zLwU+!hK3U-KtH>Q)_hI??J6xj<>_YekODc^{hiZKyc(BC23a!%E zJrH?{kx2U3v4>jj_sDCD)gA2pM$1VPUD(XuJ!GYvlA%EgccTQe%lRM_b@{rfs}ggc z^7Th#^&@M!Pc%83Tt=7av9_F*UAvAcnyI-cyl(bB_B`(Tm4gDk$V^3NU`wfK14+?K zaCfJY5Ijb8ui&L2;$W=ETPA0Gny63(U?4EFKr4DZtB3|lm=Y@08%uKZ6+Zn3_xtsU zuLCLN_pwN(axnga8co^(Yg@dY(4u?hZXm_~hZe`T6#5J~x*e8#oEAs@Y?U7h=HLe> z6rU6?2lb_7T=GKFBQ1n*j|7*5sdwNy*U<%NBRe!Q_i^E&mkh69uHi$Pb+PKUgcJ>k z8Jmkv5{#Gfb!BvrrV>n`%N=5Q73w!1GirNkBZ_@o@Yq$GRrQSG;_bh+c|QlPr{bY7 zYC15b#ZlwjOtz@J<9a|hes^-G{`LvA?LVKJ9)iAwkJeo z{dzu6gMoZsA^aYTizH{yG$cZ~!b_5kG;E#X_4}~2NW)#`tybY$nK&C$P4>Gq6$@RK z;Oc2_{lY4xvHREw>BqjY2LyGiAv_rc)=y+WP*=3`)i&kx`%)uZxW(5<5Kq*2A@dXa zt+uQ!)z;tEl{Ol}f(R{Lqm#L`R37Y(>?J6GFTD(`xsjg1n<8JVPFQ@LF zh?z`N7k73HksfEkcarktpVE>I?jEQ@HG5XG5K`cvQ@`8DWBX@g9ntNP+ ze*)!hgYL&v?Po?d-iG3~Gh!YYM2&5>dCTD6!}1Z0w$xo8EfQHPWOzi-D!H%v-qM^{ z*F}f^XBE@5G;uAVfSBG52k4eu?fg3TD~{SVjzE3a*i`!$2JG0&9o4ISVLNeCyDv4> zRy|C}3=W>RY3T)x>2K8A@S(llYFaG|rH4j>c|V(zlpe_m#9tiv4&%-2RxExI3$3@e zb}2U$9=H`}w(@}&gjkuzzE3j}-Z8OY#R9VEon6*@M_|u|gDmSxckzS>^@|$>7GcwBO%Rb}!uyE~uf@ z&sj?xUe5zikKNYpJHo_ngq7Og*>59E9pP^H4_26zPnZm|try`m`H>Jc>m+{|WD7ctT z(>v#(_!Akcy{x>_a*QFwU4uJ(vi*!wM)I2|oknkFQhMae1J!%8^Rjri%VI`al)jpK z8fa=9um!gDCnVoAGVu@z(++twGr|xw`-aXhzkl_2RZ%_vgRn1?pLep@1M%^)i-vui zGpV|}1}38MshP>eX4^WOuXOdDycZgJI&RU5I-L>}1~n zv&1=ri#}WZ$_*4f`p$8*fnKQZ<`pk{p2FtFN^)T~&2lwv*Y%`_WI;H!Pt>DQargYa zs}B6ccfzmyl5hfaBnFC38+VUOYvF#Sk)rDMYUYs}2_;1DwIEC8b>`H)n9Z3lm(aT! zRlsqFcHQ=wPp2K^AqlymxqEgwQFfge{tLp?{wZFO=k-yNljkWc%!gohZ1Ou_Xvq+? zql!pF^ec#$cw@2s^>sXRj+pDM6%|b93jlz+39ERvsTsxOcqOKMCesdzx~VN&6s>LJ z#$s4b%m`2_^_NSSu$~Z7y_f0d@`Wqn3ToJb&Oaon_?gE z^@Oo{i4uV1&n;D@RR%P_ENO!1fnG1!c#|=wze4%(gv>7399j37aR;c=m H9*O@01x&CW literal 0 HcmV?d00001 diff --git a/part_6_ui_layout/templates/base.html.tera b/part_6_ui_layout/templates/base.html.tera new file mode 100644 index 0000000..7f9c7ad --- /dev/null +++ b/part_6_ui_layout/templates/base.html.tera @@ -0,0 +1,23 @@ + + + + + lykin + + + + + + +

+ lykin +

+ +
+ {% include "topbar" %} + {% include "peer_list" %} + {% include "post_list" %} + {% include "post_content" %} +
+ + diff --git a/part_6_ui_layout/templates/peer_list.html.tera b/part_6_ui_layout/templates/peer_list.html.tera new file mode 100644 index 0000000..e5e40b6 --- /dev/null +++ b/part_6_ui_layout/templates/peer_list.html.tera @@ -0,0 +1,14 @@ +
+
    + {% for peer in peers -%} +
  • + {% if peer.name %} + {{ peer.name }} + {% else %} + {{ peer.public_key }} + {% endif %} +
  • + {%- endfor %} +
+
+ diff --git a/part_6_ui_layout/templates/post_content.html.tera b/part_6_ui_layout/templates/post_content.html.tera new file mode 100644 index 0000000..2227b2f --- /dev/null +++ b/part_6_ui_layout/templates/post_content.html.tera @@ -0,0 +1,6 @@ +
+{% if post %} + {{ post.text }} +{% endif %} +
+ diff --git a/part_6_ui_layout/templates/post_list.html.tera b/part_6_ui_layout/templates/post_list.html.tera new file mode 100644 index 0000000..02c6201 --- /dev/null +++ b/part_6_ui_layout/templates/post_list.html.tera @@ -0,0 +1,10 @@ +
+ {% if posts %} +
    + {% for post in posts -%} + Subject placeholder + {%- endfor %} +
+ {% endif %} +
+ diff --git a/part_6_ui_layout/templates/topbar.html.tera b/part_6_ui_layout/templates/topbar.html.tera new file mode 100644 index 0000000..3d778d4 --- /dev/null +++ b/part_6_ui_layout/templates/topbar.html.tera @@ -0,0 +1,25 @@ +