diff --git a/peach-web/src/router.rs b/peach-web/src/router.rs index 9284e1b..d0bda4e 100644 --- a/peach-web/src/router.rs +++ b/peach-web/src/router.rs @@ -1,3 +1,4 @@ +use log::debug; use rouille::{router, Request, Response}; use crate::{routes, utils::flash::FlashResponse}; @@ -119,6 +120,15 @@ pub fn mount_peachpub_routes(request: &Request) -> Response { Response::html(routes::scuttlebutt::peers::build_template()) }, + (GET) (/scuttlebutt/profile) => { + Response::html(routes::scuttlebutt::profile::build_template(request, None)) + }, + + (GET) (/scuttlebutt/profile/{ssb_id: String}) => { + debug!("ssb_id: {}", ssb_id); + Response::html(routes::scuttlebutt::profile::build_template(request, Some(ssb_id))) + }, + (GET) (/scuttlebutt/search) => { Response::html(routes::scuttlebutt::search::build_template(request)) .reset_flash() diff --git a/peach-web/src/routes/scuttlebutt/profile.rs b/peach-web/src/routes/scuttlebutt/profile.rs new file mode 100644 index 0000000..9ff1643 --- /dev/null +++ b/peach-web/src/routes/scuttlebutt/profile.rs @@ -0,0 +1,180 @@ +use maud::{html, Markup, PreEscaped}; +use peach_lib::sbot::SbotStatus; +use rouille::Request; + +use crate::{ + templates, + utils::{flash::FlashRequest, sbot, sbot::Profile}, +}; + +// ROUTE: /scuttlebutt/profile + +fn public_post_form_template() -> Markup { + html! { + (PreEscaped("")) + form id="postForm" class="center" action="/scuttlebutt/publish" method="post" { + (PreEscaped("")) + textarea id="publicPost" class="center input message-input" name="text" title="Compose Public Post" placeholder="Write a public post..." { } + input id="publishPost" class="button button-primary center" title="Publish" type="submit" value="Publish"; + } + } +} + +fn profile_info_box_template(profile: &Profile) -> Markup { + html! { + (PreEscaped("")) + div class="capsule capsule-profile border-ssb" title="Scuttlebutt account profile information" { + @if profile.is_local_profile { + (PreEscaped("")) + a class="nav-icon-right" href="/scuttlebutt/profile/update" title="Edit your profile" { + img id="editProfile" class="icon-small icon-active" src="/icons/pencil.svg" alt="Edit"; + } + } + // render the profile bio: picture, id, name, image & description + (profile_bio_template(&profile)) + } + } +} + +fn profile_bio_template(profile: &Profile) -> Markup { + html! { + (PreEscaped("")) + (PreEscaped("")) + // only try to render profile pic if we have the blob + @match &profile.blob_path { + Some(blob_path) if profile.blob_exists => { + img id="profilePicture" class="icon-large" src={ "/blob/" (blob_path) } title="Profile picture" alt="Profile picture"; + }, + _ => { + // use a placeholder image if we don't have the blob + img id="peerImage" class="icon icon-active list-icon" src="/icons/user.svg" alt="Placeholder profile image"; + } + } + (PreEscaped("")) + p id="profileName" class="card-text" title="Name" { + @if let Some(name) = &profile.name { + (name) + } @else { + "Name unavailable" + } + } + label class="label-small label-ellipsis font-gray" style="user-select: all;" for="profileName" title="Public Key" { + @if let Some(id) = &profile.id { + (id) + } @else { + "Public key unavailable" + } + } + p id="profileDescription" style="margin-top: 1rem" class="card-text" title="Description" { + @if let Some(description) = &profile.description { + (description) + } @else { + "Description unavailable" + } + } + + } +} + +fn social_interaction_buttons_template(profile: &Profile) -> Markup { + html! { + (PreEscaped("")) + div id="buttons" style="margin-top: 2rem;" { + @match (profile.following, &profile.id) { + (Some(false), Some(ssb_id)) => { + form id="followForm" class="center" action="/scuttlebutt/follow" method="post" { + input type="hidden" id="publicKey" name="public_key" value=(ssb_id); + input id="followPeer" class="button button-primary center" type="submit" title="Follow Peer" value="Follow"; + } + }, + (Some(true), Some(ssb_id)) => { + form id="unfollowForm" class="center" action="/scuttlebutt/unfollow" method="post" { + input type="hidden" id="publicKey" name="public_key" value=(ssb_id); + input id="unfollowPeer" class="button button-primary center" type="submit" title="Unfollow Peer" value="Unfollow"; + } + }, + _ => p { "Unable to determine follow state" } + } + @match (profile.blocking, &profile.id) { + (Some(false), Some(ssb_id)) => { + form id="blockForm" class="center" action="/scuttlebutt/block" method="post" { + input type="hidden" id="publicKey" name="public_key" value=(ssb_id); + input id="blockPeer" class="button button-primary center" type="submit" title="Block Peer" value="Block"; + } + }, + (Some(true), Some(ssb_id)) => { + form id="unblockForm" class="center" action="/scuttlebutt/unblock" method="post" { + input type="hidden" id="publicKey" name="public_key" value=(ssb_id); + input id="unblockPeer" class="button button-primary center" type="submit" title="Unblock Peer" value="Unblock"; + } + }, + _ => p { "Unable to determine block state" } + } + @if let Some(ssb_id) = &profile.id { + form class="center" { + a id="privateMessage" class="button button-primary center" href={ "/scuttlebutt/private?public_key=" (ssb_id) } title="Private Message" { + "Send Private Message" + } + } + } + } + } +} + +/// Scuttlebutt profile template builder. +/// +/// Render a Scuttlebutt profile, either for the local profile or for a peer +/// specified by a public key. If the public key query parameter is not +/// provided, the local profile is displayed (ie. the profile of the public key +/// associated with the local PeachCloud device). +pub fn build_template(request: &Request, ssb_id: Option) -> PreEscaped { + // check for flash cookies; will be (None, None) if no flash cookies are found + let (flash_name, flash_msg) = request.retrieve_flash(); + + let profile_template = match SbotStatus::read() { + Ok(status) if status.state == Some("active".to_string()) => { + // TODO: validate ssb_id and return error template + + // retrieve the profile info + match sbot::get_profile_info(ssb_id) { + Ok(profile) => { + // render the profile template + html! { + (PreEscaped("")) + div class="card card-wide center" { + // render profile info box + (profile_info_box_template(&profile)) + @if profile.is_local_profile { + // render the public post form template + (public_post_form_template()) + } @else { + // render follow / unfollow, block / unblock and + // private message buttons + (social_interaction_buttons_template(&profile)) + } + } + // render flash message if cookies were found in the request + @if let (Some(name), Some(msg)) = (flash_name, flash_msg) { + (PreEscaped("")) + (templates::flash::build_template(name, msg)) + } + } + } + Err(e) => { + // render the sbot error template with the error message + let error_template = templates::error::build_template(e.to_string()); + // wrap the nav bars around the error template content + let body = templates::nav::build_template(error_template, "Profile", Some("/")); + + // render the base template with the provided body + templates::base::build_template(body) + } + } + } + _ => templates::inactive::build_template("Profile is unavailable."), + }; + + let body = templates::nav::build_template(profile_template, "Profile", Some("/")); + + templates::base::build_template(body) +} diff --git a/peach-web/src/utils/sbot.rs b/peach-web/src/utils/sbot.rs index 62d449a..053ed39 100644 --- a/peach-web/src/utils/sbot.rs +++ b/peach-web/src/utils/sbot.rs @@ -120,6 +120,132 @@ pub fn create_invite(uses: u16) -> Result> { }) } +#[derive(Debug)] +pub struct Profile { + // is this the local profile or the profile of a peer? + pub is_local_profile: bool, + // an ssb_id which may or may not be the local public key + pub id: Option, + pub name: Option, + pub description: Option, + pub image: Option, + // the path to the blob defined in the `image` field (aka the profile picture) + pub blob_path: Option, + // whether or not the blob exists in the blobstore (ie. is saved on disk) + pub blob_exists: bool, + // relationship state (if the profile being viewed is not for the local public key) + pub following: Option, + pub blocking: Option, +} + +impl Profile { + pub fn default() -> Self { + Profile { + is_local_profile: true, + id: None, + name: None, + description: None, + image: None, + blob_path: None, + blob_exists: false, + following: None, + blocking: None, + } + } +} + +/// Retrieve the profile info for the given public key. +pub fn get_profile_info(ssb_id: Option) -> Result> { + // retrieve latest go-sbot configuration parameters + let sbot_config = SbotConfig::read().ok(); + + task::block_on(async { + let mut sbot_client = init_sbot_with_config(&sbot_config).await?; + + let local_id = sbot_client.whoami().await?; + + let mut profile = Profile::default(); + + // if an ssb_id has been provided, we assume that the profile info + // being retrieved is for a peer (ie. not for our local profile) + let id = if ssb_id.is_some() { + // we are not dealing with the local profile + profile.is_local_profile = false; + + // we're safe to unwrap here because we know it's `Some(id)` + let peer_id = ssb_id.unwrap(); + + // determine relationship between peer and local id + let follow_query = RelationshipQuery { + source: local_id.clone(), + dest: peer_id.clone(), + }; + + // query follow state + profile.following = match sbot_client.friends_is_following(follow_query).await { + Ok(following) if following == "true" => Some(true), + Ok(following) if following == "false" => Some(false), + _ => None, + }; + + // TODO: i don't like that we have to instantiate the same query object + // twice. see if we can streamline this in golgi + let block_query = RelationshipQuery { + source: local_id.clone(), + dest: peer_id.clone(), + }; + + // query block state + profile.blocking = match sbot_client.friends_is_blocking(block_query).await { + Ok(blocking) if blocking == "true" => Some(true), + Ok(blocking) if blocking == "false" => Some(false), + _ => None, + }; + + peer_id + } else { + // if an ssb_id has not been provided, retrieve the local id using whoami + profile.is_local_profile = true; + + local_id + }; + + // retrieve the profile info for the given id + let info = sbot_client.get_profile_info(&id).await?; + // set each profile field accordingly + for (key, val) in info { + match key.as_str() { + "name" => profile.name = Some(val), + "description" => profile.description = Some(val), + "image" => profile.image = Some(val), + _ => (), + } + } + + // assign the ssb public key + // (could be for the local profile or a peer) + profile.id = Some(id); + + // determine the path to the blob defined by the value of `profile.image` + if let Some(ref blob_id) = profile.image { + profile.blob_path = match blobs::get_blob_path(&blob_id) { + Ok(path) => { + // if we get the path, check if the blob is in the blobstore. + // this allows us to default to a placeholder image in the template + if let Ok(exists) = blob_is_stored_locally(&path).await { + profile.blob_exists = exists + }; + + Some(path) + } + Err(_) => None, + } + } + + Ok(profile) + }) +} + /// Retrieve a list of peers blocked by the local public key. pub fn get_blocks_list() -> Result>, Box> { // retrieve latest go-sbot configuration parameters