diff --git a/Cargo.lock b/Cargo.lock index f177a47e..eaeba4b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1697,6 +1697,7 @@ dependencies = [ "lazy_static", "linkify", "nostr-types", + "parking_lot", "rand 0.8.5", "reqwest", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index 7e6ce072..5d17cc77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ image = "0.24" lazy_static = "1.4" linkify = "0.9" nostr-types = { git = "https://github.com/mikedilger/nostr-types" } +parking_lot = "0.12" rand = "0.8" reqwest = { version = "0.11", features = ["json"] } rusqlite = { version = "0.28", features = ["bundled", "chrono", "serde_json"] } diff --git a/src/feed.rs b/src/feed.rs index 436cb24e..6b0fe9a1 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -1,55 +1,141 @@ +use crate::comms::BusMessage; use crate::globals::GLOBALS; +use nostr_types::PublicKeyHex; use nostr_types::{Event, EventKind, Id}; +use parking_lot::RwLock; use std::time::{Duration, Instant}; +#[derive(Clone, Debug)] +pub enum FeedKind { + General, + Thread(Id), + Person(PublicKeyHex), +} + pub struct Feed { - feed: Vec, + current_feed_kind: RwLock, + + general_feed: RwLock>, // We only recompute the feed at specified intervals - interval_ms: u32, - last_computed: Instant, + interval_ms: RwLock, + last_computed: RwLock, // We track these to update subscriptions on them - my_event_ids: Vec, - followed_event_ids: Vec, + my_event_ids: RwLock>, + followed_event_ids: RwLock>, } impl Feed { pub fn new() -> Feed { Feed { - feed: Vec::new(), - interval_ms: 1000, // Every second, until we load from settings - last_computed: Instant::now(), - my_event_ids: Vec::new(), - followed_event_ids: Vec::new(), + current_feed_kind: RwLock::new(FeedKind::General), + general_feed: RwLock::new(Vec::new()), + interval_ms: RwLock::new(1000), // Every second, until we load from settings + last_computed: RwLock::new(Instant::now()), + my_event_ids: RwLock::new(Vec::new()), + followed_event_ids: RwLock::new(Vec::new()), } } - pub fn get(&mut self) -> Vec { + pub fn set_feed_to_general(&self) { + // We are always subscribed to the general feed. Don't resubscribe here + // because it won't have changed, but the relays will shower you with + // all those events again. + *self.current_feed_kind.write() = FeedKind::General; + } + + pub fn set_feed_to_thread(&self, id: Id) { + let _ = GLOBALS.to_minions.send(BusMessage { + target: "all".to_string(), + kind: "subscribe_thread_feed".to_string(), + json_payload: serde_json::to_string(&id).unwrap(), + }); + *self.current_feed_kind.write() = FeedKind::Thread(id); + } + + pub fn set_feed_to_person(&self, pubkey: PublicKeyHex) { + let _ = GLOBALS.to_minions.send(BusMessage { + target: "all".to_string(), + kind: "subscribe_person_feed".to_string(), + json_payload: serde_json::to_string(&pubkey).unwrap(), + }); + *self.current_feed_kind.write() = FeedKind::Person(pubkey); + } + + pub fn get_feed_kind(&self) -> FeedKind { + self.current_feed_kind.read().to_owned() + } + + pub fn get_general(&self) -> Vec { let now = Instant::now(); - if self.last_computed + Duration::from_millis(self.interval_ms as u64) < now { + if *self.last_computed.read() + Duration::from_millis(*self.interval_ms.read() as u64) < now + { self.recompute(); - self.last_computed = now; + *self.last_computed.write() = now; } - self.feed.clone() + self.general_feed.read().clone() + } + + pub fn get_thread_parent(&self, id: Id) -> Id { + let mut event = match GLOBALS.events.blocking_read().get(&id).cloned() { + None => return id, + Some(e) => e, + }; + + // Try for root + if let Some((root, _)) = event.replies_to_root() { + if GLOBALS.events.blocking_read().contains_key(&root) { + return root; + } + } + + // Climb parents as high as we can + while let Some((parent, _)) = event.replies_to() { + if let Some(e) = GLOBALS.events.blocking_read().get(&parent) { + event = e.to_owned(); + } else { + break; + } + } + + // The highest event id we have + event.id + } + + pub fn get_person_feed(&self, person: PublicKeyHex) -> Vec { + let mut events: Vec = GLOBALS + .events + .blocking_read() + .iter() + .map(|(_, e)| e) + .filter(|e| e.kind == EventKind::TextNote) + .filter(|e| e.pubkey.as_hex_string() == person.0) + .filter(|e| !GLOBALS.dismissed.blocking_read().contains(&e.id)) + .map(|e| e.to_owned()) + .collect(); + + events.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at)); + + events.iter().map(|e| e.id).collect() } #[allow(dead_code)] pub fn get_my_event_ids(&self) -> Vec { // we assume the main get() happens fast enough to recompute for us. - self.my_event_ids.clone() + self.my_event_ids.read().clone() } #[allow(dead_code)] pub fn get_followed_event_ids(&self) -> Vec { // we assume the main get() happens fast enough to recompute for us. - self.followed_event_ids.clone() + self.followed_event_ids.read().clone() } - fn recompute(&mut self) { + fn recompute(&self) { let settings = GLOBALS.settings.blocking_read().clone(); - self.interval_ms = settings.feed_recompute_interval_ms; + *self.interval_ms.write() = settings.feed_recompute_interval_ms; let events: Vec = GLOBALS .events @@ -60,22 +146,26 @@ impl Feed { .map(|e| e.to_owned()) .collect(); + let mut pubkeys = GLOBALS.people.blocking_read().get_followed_pubkeys(); + if let Some(pubkey) = GLOBALS.signer.blocking_read().public_key() { + pubkeys.push(pubkey.into()); // add the user + } + // My event ids if let Some(pubkey) = GLOBALS.signer.blocking_read().public_key() { - self.my_event_ids = events + *self.my_event_ids.write() = events .iter() .filter_map(|e| if e.pubkey == pubkey { Some(e.id) } else { None }) .collect(); } else { - self.my_event_ids = vec![]; + *self.my_event_ids.write() = vec![]; } // Followed event ids - let followed_pubkeys = GLOBALS.people.blocking_read().get_followed_pubkeys(); - self.followed_event_ids = events + *self.followed_event_ids.write() = events .iter() .filter_map(|e| { - if followed_pubkeys.contains(&e.pubkey.into()) { + if pubkeys.contains(&e.pubkey.into()) { Some(e.id) } else { None @@ -86,29 +176,14 @@ impl Feed { // Filter further for the feed let mut events: Vec = events .iter() + .filter(|e| pubkeys.contains(&e.pubkey.into())) // something we follow .filter(|e| !GLOBALS.dismissed.blocking_read().contains(&e.id)) - .filter(|e| { - if settings.view_threaded { - e.replies_to().is_none() - } else { - true - } - }) .cloned() .collect(); - if settings.view_threaded { - events.sort_unstable_by(|a, b| { - let a_last = GLOBALS.last_reply.blocking_read().get(&a.id).cloned(); - let b_last = GLOBALS.last_reply.blocking_read().get(&b.id).cloned(); - let a_time = a_last.unwrap_or(a.created_at); - let b_time = b_last.unwrap_or(b.created_at); - b_time.cmp(&a_time) - }); - } else { - events.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at)); - } + // In time order + events.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - self.feed = events.iter().map(|e| e.id).collect(); + *self.general_feed.write() = events.iter().map(|e| e.id).collect(); } } diff --git a/src/globals.rs b/src/globals.rs index c071ceb1..1db9d829 100644 --- a/src/globals.rs +++ b/src/globals.rs @@ -7,7 +7,7 @@ use crate::people::People; use crate::relationship::Relationship; use crate::settings::Settings; use crate::signer::Signer; -use nostr_types::{Event, Id, IdHex, PublicKeyHex, Unixtime, Url}; +use nostr_types::{Event, Id, IdHex, PublicKeyHex, Url}; use rusqlite::Connection; use std::collections::{HashMap, HashSet}; use std::sync::atomic::AtomicBool; @@ -40,10 +40,6 @@ pub struct Globals { /// All relationships between events pub relationships: RwLock>>, - /// The date of the latest reply. Only reply relationships count, not reactions, - /// deletions, or quotes - pub last_reply: RwLock>, - /// Desired events, referred to by others, with possible URLs where we can /// get them. We may already have these, but if not we should ask for them. pub desired_events: RwLock>>, @@ -71,7 +67,7 @@ pub struct Globals { pub event_is_new: RwLock>, /// Feed - pub feed: Mutex, + pub feed: Feed, /// Fetcher pub fetcher: Fetcher, @@ -97,7 +93,6 @@ lazy_static! { events: RwLock::new(HashMap::new()), incoming_events: RwLock::new(Vec::new()), relationships: RwLock::new(HashMap::new()), - last_reply: RwLock::new(HashMap::new()), desired_events: RwLock::new(HashMap::new()), people: RwLock::new(People::new()), relays: RwLock::new(HashMap::new()), @@ -106,7 +101,7 @@ lazy_static! { signer: RwLock::new(Signer::default()), dismissed: RwLock::new(Vec::new()), event_is_new: RwLock::new(Vec::new()), - feed: Mutex::new(Feed::new()), + feed: Feed::new(), fetcher: Fetcher::new(), failed_avatars: RwLock::new(HashSet::new()), } @@ -227,18 +222,6 @@ impl Globals { .or_insert_with(|| vec![r]); } - pub async fn update_last_reply(id: Id, time: Unixtime) { - let mut last_reply = GLOBALS.last_reply.write().await; - last_reply - .entry(id) - .and_modify(|lasttime| { - if time > *lasttime { - *lasttime = time; - } - }) - .or_insert_with(|| time); - } - pub fn get_replies_sync(id: Id) -> Vec { let mut output: Vec = Vec::new(); if let Some(vec) = GLOBALS.relationships.blocking_read().get(&id) { diff --git a/src/overlord/minion/handle_bus.rs b/src/overlord/minion/handle_bus.rs index 5cf60422..70a7d4a3 100644 --- a/src/overlord/minion/handle_bus.rs +++ b/src/overlord/minion/handle_bus.rs @@ -1,18 +1,33 @@ use super::Minion; use crate::{BusMessage, Error}; use futures::SinkExt; -use nostr_types::{ClientMessage, Event, IdHex, PublicKeyHex}; +use nostr_types::{ClientMessage, Event, Id, IdHex, PublicKeyHex}; use tungstenite::protocol::Message as WsMessage; impl Minion { pub(super) async fn handle_bus_message( &mut self, bus_message: BusMessage, - ) -> Result<(), Error> { + ) -> Result { match &*bus_message.kind { - "set_followed_people" => { - let v: Vec = serde_json::from_str(&bus_message.json_payload)?; - self.upsert_following(v).await?; + "shutdown" => { + tracing::info!("{}: Websocket listener shutting down", &self.url); + return Ok(false); + } + //"set_followed_people" => { + // let v: Vec = serde_json::from_str(&bus_message.json_payload)?; + // self.upsert_following(v).await?; + //} + "subscribe_general_feed" => { + self.subscribe_general_feed().await?; + } + "subscribe_person_feed" => { + let pubkeyhex: PublicKeyHex = serde_json::from_str(&bus_message.json_payload)?; + self.subscribe_person_feed(pubkeyhex).await?; + } + "subscribe_thread_feed" => { + let id: Id = serde_json::from_str(&bus_message.json_payload)?; + self.subscribe_thread_feed(id).await?; } "fetch_events" => { let v: Vec = serde_json::from_str(&bus_message.json_payload)?; @@ -38,6 +53,6 @@ impl Minion { ); } } - Ok(()) + Ok(true) } } diff --git a/src/overlord/minion/handle_websocket.rs b/src/overlord/minion/handle_websocket.rs index c07fd78c..7c36d4b1 100644 --- a/src/overlord/minion/handle_websocket.rs +++ b/src/overlord/minion/handle_websocket.rs @@ -29,8 +29,13 @@ impl Minion { .subscriptions .get_handle_by_id(&subid.0) .unwrap_or_else(|| "_".to_owned()); - tracing::trace!("{}: {}: NEW EVENT", &self.url, handle); + tracing::debug!("{}: {}: NEW EVENT", &self.url, handle); + // Try processing everything immediately + crate::process::process_new_event(&event, true, Some(self.url.clone())) + .await?; + + /* if event.kind == EventKind::TextNote { // Just store text notes in incoming GLOBALS @@ -43,6 +48,8 @@ impl Minion { crate::process::process_new_event(&event, true, Some(self.url.clone())) .await?; } + */ + } } RelayMessage::Notice(msg) => { diff --git a/src/overlord/minion/mod.rs b/src/overlord/minion/mod.rs index edb2294f..74240005 100644 --- a/src/overlord/minion/mod.rs +++ b/src/overlord/minion/mod.rs @@ -10,8 +10,9 @@ use futures::{SinkExt, StreamExt}; use futures_util::stream::{SplitSink, SplitStream}; use http::Uri; use nostr_types::{ - EventKind, Filter, IdHex, PublicKeyHex, RelayInformationDocument, Unixtime, Url, + EventKind, Filter, Id, IdHex, PublicKeyHex, RelayInformationDocument, Unixtime, Url, }; +use std::time::Duration; use subscription::Subscriptions; use tokio::net::TcpStream; use tokio::select; @@ -219,13 +220,8 @@ impl Minion { Err(e) => return Err(e.into()) }; #[allow(clippy::collapsible_if)] - if bus_message.target == self.url.inner() { - self.handle_bus_message(bus_message).await?; - } else if &*bus_message.target == "all" { - if &*bus_message.kind == "shutdown" { - tracing::info!("{}: Websocket listener shutting down", &self.url); - keepgoing = false; - } + if bus_message.target == self.url.inner() || bus_message.target == "all" { + keepgoing = self.handle_bus_message(bus_message).await?; } }, } @@ -243,7 +239,180 @@ impl Minion { Ok(()) } + async fn subscribe_general_feed(&mut self) -> Result<(), Error> { + // NOTE if the general feed is already subscribed we shoudn't do anything + // but we may need to update the subscription + + let mut filters: Vec = Vec::new(); + let feed_chunk = GLOBALS.settings.read().await.feed_chunk; + + let followed_pubkeys = GLOBALS.people.read().await.get_followed_pubkeys(); + + if let Some(pubkey) = GLOBALS.signer.read().await.public_key() { + // feed related by me + filters.push(Filter { + authors: vec![pubkey.into()], + kinds: vec![ + EventKind::TextNote, + EventKind::Reaction, + EventKind::EventDeletion, + ], + since: Some(Unixtime::now().unwrap() - Duration::from_secs(feed_chunk)), + ..Default::default() + }); + + // Any mentions of me + filters.push(Filter { + p: vec![pubkey.into()], + since: Some(Unixtime::now().unwrap() - Duration::from_secs(feed_chunk)), + ..Default::default() + }); + + // my metadata + // FIXME TBD + /* + filters.push(Filter { + authors: vec![pubkey], + kinds: vec![EventKind::Metadata, EventKind::RecommendRelay, EventKind::ContactList, EventKind::RelaysList], + since: // last we last checked + .. Default::default() + }); + */ + } + + if !followed_pubkeys.is_empty() { + // feed related by people followed + filters.push(Filter { + authors: followed_pubkeys.clone(), + kinds: vec![ + EventKind::TextNote, + EventKind::Reaction, + EventKind::EventDeletion, + ], + since: Some(Unixtime::now().unwrap() - Duration::from_secs(feed_chunk)), + ..Default::default() + }); + + // metadata by people followed + // FIXME TBD + /* + filters.push(Filter { + authors: pubkeys.clone(), + kinds: vec![EventKind::Metadata, EventKind::RecommendRelay, EventKind::ContactList, EventKind::RelaysList], + since: // last we last checked + .. Default::default() + }); + */ + } + + // reactions to posts by me + // FIXME TBD + + // reactions to posts by people followed + // FIXME TBD + + // NO REPLIES OR ANCESTORS + + if filters.is_empty() { + self.unsubscribe("general_feed").await?; + } else { + self.subscribe(filters, "general_feed").await?; + } + + Ok(()) + } + + async fn subscribe_person_feed(&mut self, pubkey: PublicKeyHex) -> Result<(), Error> { + // NOTE we do not unsubscribe to the general feed + + let mut filters: Vec = Vec::new(); + let feed_chunk = GLOBALS.settings.read().await.feed_chunk; + + // feed related by person + filters.push(Filter { + authors: vec![pubkey], + kinds: vec![ + EventKind::TextNote, + EventKind::Reaction, + EventKind::EventDeletion, + ], + since: Some(Unixtime::now().unwrap() - Duration::from_secs(feed_chunk)), + ..Default::default() + }); + + // persons metadata + // FIXME TBD + /* + filters.push(Filter { + authors: vec![pubkey], + kinds: vec![EventKind::Metadata, EventKind::RecommendRelay, EventKind::ContactList, EventKind::RelaysList], + since: // last we last checked + .. Default::default() + }); + */ + + // reactions to post by person + // FIXME TBD + + // NO REPLIES OR ANCESTORS + + if filters.is_empty() { + self.unsubscribe("person_feed").await?; + } else { + self.subscribe(filters, "person_feed").await?; + } + + Ok(()) + } + + async fn subscribe_thread_feed(&mut self, id: Id) -> Result<(), Error> { + // NOTE we do not unsubscribe to the general feed + + let mut filters: Vec = Vec::new(); + let feed_chunk = GLOBALS.settings.read().await.feed_chunk; + + // This post and ancestors + let mut ids: Vec = vec![id.into()]; + // FIXME - We could have this precalculated like GLOBALS.relationships + // in reverse. It would be potentially more complete having + // iteratively climbed the chain. + if let Some(event) = GLOBALS.events.read().await.get(&id) { + for (id, url) in &event.replies_to_ancestors() { + if let Some(url) = url { + if url == &self.url { + ids.push((*id).into()); + } + } else { + ids.push((*id).into()); + } + } + } + filters.push(Filter { + ids: ids.clone(), + ..Default::default() + }); + + // Replies and reactions to this post and ancestors + filters.push(Filter { + e: ids, + since: Some(Unixtime::now().unwrap() - Duration::from_secs(feed_chunk)), + ..Default::default() + }); + + // Metadata for people in those events + // TBD + + if filters.is_empty() { + self.unsubscribe("thread_feed").await?; + } else { + self.subscribe(filters, "thread_feed").await?; + } + + Ok(()) + } + // Create or replace the following subscription + /* async fn upsert_following(&mut self, pubkeys: Vec) -> Result<(), Error> { let websocket_sink = self.sink.as_mut().unwrap(); @@ -354,6 +523,7 @@ impl Minion { Ok(()) } + */ async fn get_events(&mut self, ids: Vec) -> Result<(), Error> { if ids.is_empty() { diff --git a/src/overlord/mod.rs b/src/overlord/mod.rs index a7abbaac..930f8725 100644 --- a/src/overlord/mod.rs +++ b/src/overlord/mod.rs @@ -220,10 +220,10 @@ impl Overlord { // Fire off a minion to handle this relay self.start_minion(best_relay.relay.url.clone()).await?; - // Tell it to follow the chosen people + // Subscribe to the general feed let _ = self.to_minions.send(BusMessage { target: best_relay.relay.url.clone(), - kind: "set_followed_people".to_string(), + kind: "subscribe_general_feed".to_string(), json_payload: serde_json::to_string(&best_relay.pubkeys).unwrap(), }); diff --git a/src/process.rs b/src/process.rs index f54fe362..1b4c10ff 100644 --- a/src/process.rs +++ b/src/process.rs @@ -141,18 +141,6 @@ pub async fn process_new_event( // Insert into relationships Globals::add_relationship(id, event.id, Relationship::Reply).await; - - // Update last_reply - let mut id = id; - Globals::update_last_reply(id, event.created_at).await; - while let Some(ev) = GLOBALS.events.read().await.get(&id).cloned() { - if let Some((pid, _)) = ev.replies_to() { - id = pid; - Globals::update_last_reply(id, event.created_at).await; - } else { - break; - } - } } // We desire all ancestors diff --git a/src/settings.rs b/src/settings.rs index 87bff0ec..89e9490b 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -7,7 +7,6 @@ pub const DEFAULT_FEED_CHUNK: u64 = 43200; // 12 hours pub const DEFAULT_OVERLAP: u64 = 600; // 10 minutes pub const DEFAULT_VIEW_POSTS_REFERRED_TO: bool = true; pub const DEFAULT_VIEW_POSTS_REFERRING_TO: bool = false; -pub const DEFAULT_VIEW_THREADED: bool = true; pub const DEFAULT_NUM_RELAYS_PER_PERSON: u8 = 4; pub const DEFAULT_MAX_RELAYS: u8 = 15; pub const DEFAULT_MAX_FPS: u32 = 30; @@ -21,7 +20,6 @@ pub struct Settings { pub overlap: u64, pub view_posts_referred_to: bool, pub view_posts_referring_to: bool, - pub view_threaded: bool, pub num_relays_per_person: u8, pub max_relays: u8, pub public_key: Option, @@ -39,7 +37,6 @@ impl Default for Settings { overlap: DEFAULT_OVERLAP, view_posts_referred_to: DEFAULT_VIEW_POSTS_REFERRED_TO, view_posts_referring_to: DEFAULT_VIEW_POSTS_REFERRING_TO, - view_threaded: DEFAULT_VIEW_THREADED, num_relays_per_person: DEFAULT_NUM_RELAYS_PER_PERSON, max_relays: DEFAULT_MAX_RELAYS, public_key: None, @@ -76,7 +73,6 @@ impl Settings { "view_posts_referring_to" => { settings.view_posts_referring_to = numstr_to_bool(row.1) } - "view_threaded" => settings.view_threaded = numstr_to_bool(row.1), "num_relays_per_person" => { settings.num_relays_per_person = row.1.parse::().unwrap_or(DEFAULT_NUM_RELAYS_PER_PERSON) @@ -130,7 +126,6 @@ impl Settings { ('overlap', ?),\ ('view_posts_referred_to', ?),\ ('view_posts_referring_to', ?),\ - ('view_threaded', ?),\ ('num_relays_per_person', ?),\ ('max_relays', ?),\ ('max_fps', ?),\ @@ -143,7 +138,6 @@ impl Settings { self.overlap, bool_to_numstr(self.view_posts_referred_to), bool_to_numstr(self.view_posts_referring_to), - bool_to_numstr(self.view_threaded), self.num_relays_per_person, self.max_relays, self.max_fps, diff --git a/src/ui/feed.rs b/src/ui/feed.rs index 55cc8ed4..0d3e1510 100644 --- a/src/ui/feed.rs +++ b/src/ui/feed.rs @@ -1,11 +1,12 @@ use super::{GossipUi, Page}; use crate::comms::BusMessage; +use crate::feed::FeedKind; use crate::globals::{Globals, GLOBALS}; use crate::ui::widgets::{CopyButton, ReplyButton}; use eframe::egui; use egui::{ - Align, Color32, Context, Frame, Image, Label, Layout, RichText, ScrollArea, Sense, TextEdit, - Ui, Vec2, + Align, Color32, Context, Frame, Image, Layout, RichText, ScrollArea, SelectableLabel, Sense, + TextEdit, Ui, Vec2, }; use linkify::{LinkFinder, LinkKind}; use nostr_types::{EventKind, Id, PublicKeyHex}; @@ -18,7 +19,36 @@ struct FeedPostParams { } pub(super) fn update(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Frame, ui: &mut Ui) { - let feed = GLOBALS.feed.blocking_lock().get(); + let mut feed_kind = GLOBALS.feed.get_feed_kind(); + app.page = match feed_kind { + FeedKind::General => Page::FeedGeneral, + FeedKind::Thread(_) => Page::FeedThread, + FeedKind::Person(_) => Page::FeedPerson, + }; + + ui.horizontal(|ui| { + if ui + .add(SelectableLabel::new( + app.page == Page::FeedGeneral, + "Following", + )) + .clicked() + { + app.page = Page::FeedGeneral; + GLOBALS.feed.set_feed_to_general(); + feed_kind = FeedKind::General; + } + ui.separator(); + if matches!(feed_kind, FeedKind::Thread(..)) { + ui.selectable_value(&mut app.page, Page::FeedThread, "Thread"); + ui.separator(); + } + if matches!(feed_kind, FeedKind::Person(..)) { + ui.selectable_value(&mut app.page, Page::FeedPerson, "Person"); + ui.separator(); + } + }); + ui.separator(); Globals::trim_desired_events_sync(); let desired_count: isize = match GLOBALS.desired_events.try_read() { @@ -57,12 +87,6 @@ pub(super) fn update(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram }); } - if ui.button("close all").clicked() { - app.hides = feed.clone(); - } - if ui.button("open all").clicked() { - app.hides.clear(); - } ui.label(&format!( "RIF={}", GLOBALS @@ -148,8 +172,30 @@ pub(super) fn update(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram ui.separator(); - let threaded = GLOBALS.settings.blocking_read().view_threaded; + match feed_kind { + FeedKind::General => { + let feed = GLOBALS.feed.get_general(); + render_a_feed(app, ctx, frame, ui, feed, false); + } + FeedKind::Thread(id) => { + let parent = GLOBALS.feed.get_thread_parent(id); + render_a_feed(app, ctx, frame, ui, vec![parent], true); + } + FeedKind::Person(pubkeyhex) => { + let feed = GLOBALS.feed.get_person_feed(pubkeyhex); + render_a_feed(app, ctx, frame, ui, feed, false); + } + } +} +fn render_a_feed( + app: &mut GossipUi, + ctx: &Context, + frame: &mut eframe::Frame, + ui: &mut Ui, + feed: Vec, + threaded: bool, +) { ScrollArea::vertical().show(ui, |ui| { let bgcolor = if ctx.style().visuals.dark_mode { Color32::BLACK @@ -215,7 +261,7 @@ fn render_post_maybe_fake( ui.add_space(height); // Yes, and we need to fake render threads to get their approx height too. - if threaded && !as_reply_to && !app.hides.contains(&id) { + if threaded && !as_reply_to { let replies = Globals::get_replies_sync(event.id); for reply_id in replies { render_post_maybe_fake( @@ -329,17 +375,6 @@ fn render_post_actual( ui.horizontal(|ui| { // Indents first (if threaded) if threaded { - #[allow(clippy::collapsible_else_if)] - if app.hides.contains(&id) { - if ui.add(Label::new("▶").sense(Sense::click())).clicked() { - app.hides.retain(|e| *e != id) - } - } else { - if ui.add(Label::new("▼").sense(Sense::click())).clicked() { - app.hides.push(id); - } - } - let space = 16.0 * (10.0 - (100.0 / (indent as f32 + 10.0))); ui.add_space(space); if indent > 0 { @@ -383,6 +418,10 @@ fn render_post_actual( ui.with_layout(Layout::right_to_left(Align::TOP), |ui| { ui.menu_button(RichText::new("≡").size(28.0), |ui| { + if ui.button("View Thread").clicked() { + GLOBALS.feed.set_feed_to_thread(event.id); + app.page = Page::FeedThread; + } if ui.button("Copy ID").clicked() { ui.output().copied_text = event.id.as_hex_string(); } @@ -401,6 +440,11 @@ fn render_post_actual( } }); + if ui.button("➤").clicked() { + GLOBALS.feed.set_feed_to_thread(event.id); + app.page = Page::FeedThread; + } + ui.label( RichText::new(crate::date_ago::date_ago(event.created_at)) .italics() @@ -452,7 +496,7 @@ fn render_post_actual( ui.separator(); - if threaded && !as_reply_to && !app.hides.contains(&id) { + if threaded && !as_reply_to { let replies = Globals::get_replies_sync(event.id); for reply_id in replies { render_post_maybe_fake( diff --git a/src/ui/help/about.rs b/src/ui/help/about.rs new file mode 100644 index 00000000..b5165c01 --- /dev/null +++ b/src/ui/help/about.rs @@ -0,0 +1,71 @@ +use super::GossipUi; +use eframe::egui; +use egui::{Align, Context, Layout, RichText, TextStyle, Ui}; + +pub(super) fn update(app: &mut GossipUi, _ctx: &Context, _frame: &mut eframe::Frame, ui: &mut Ui) { + ui.with_layout(Layout::top_down(Align::Center), |ui| { + + ui.add_space(30.0); + + ui.image(&app.icon, app.icon.size_vec2()); + + ui.add_space(15.0); + + ui.label( + RichText::new(&app.about.name).strong() + ); + + ui.add_space(15.0); + + ui.label( + RichText::new(&app.about.version) + .text_style(TextStyle::Body) + ); + + ui.add_space(15.0); + + ui.label( + RichText::new(&app.about.description) + .text_style(TextStyle::Body) + ); + + ui.add_space(35.0); + + ui.label( + RichText::new(format!("nostr is a protocol and specification for storing and retrieving social media events onto servers called relays. Many users store their events onto multiple relays for reliability, censorship resistance, and to spread their reach. If you didn't store an event on a particular relay, don't expect anyone to find it there because relays normally don't share events with each other. + +Users are defined by their keypair, and are known by the public key of that pair. All events they generate are signed by their private key, and verifiable by their public key. + +We are storing data on your system in this file: {}. This data is only used locally by this client - the nostr protocol does not use clients as a store of data for other people. We are storing your settings, your private and public key, information about relays, and a cache of events. We cache events in your feed so that we don't have to ask relays for them again, which means less network traffic and faster startup times. +", app.about.storage_path)) + .text_style(TextStyle::Body) + ); + + ui.add_space(22.0); + + ui.hyperlink_to("Learn More about Nostr", "https://github.com/nostr-protocol/nostr"); + + ui.add_space(30.0); + + ui.hyperlink_to("Source Code", &app.about.homepage); + ui.label( + RichText::new("by") + .text_style(TextStyle::Small) + ); + ui.label( + RichText::new(&app.about.authors) + .text_style(TextStyle::Small) + ); + + ui.add_space(15.0); + + ui.label( + RichText::new("This program comes with absolutely no warranty.") + .text_style(TextStyle::Small) + ); + ui.label( + RichText::new("See the MIT License for details.") + .text_style(TextStyle::Small) + ); + }); +} diff --git a/src/ui/help.rs b/src/ui/help/mod.rs similarity index 71% rename from src/ui/help.rs rename to src/ui/help/mod.rs index b1941995..0932ecb3 100644 --- a/src/ui/help.rs +++ b/src/ui/help/mod.rs @@ -1,16 +1,20 @@ use super::{GossipUi, Page}; use eframe::egui; -use egui::{Align, Context, Layout, RichText, ScrollArea, TextStyle, TopBottomPanel, Ui}; +use egui::{Context, ScrollArea, Ui}; + +mod about; +mod stats; pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Frame, ui: &mut Ui) { - TopBottomPanel::top("help_menu").show(ctx, |ui| { - ui.horizontal(|ui| { - ui.selectable_value(&mut app.page, Page::HelpHelp, "Help"); - ui.separator(); - ui.selectable_value(&mut app.page, Page::HelpAbout, "About"); - ui.separator(); - }); + ui.horizontal(|ui| { + ui.selectable_value(&mut app.page, Page::HelpHelp, "Help"); + ui.separator(); + ui.selectable_value(&mut app.page, Page::HelpStats, "Stats"); + ui.separator(); + ui.selectable_value(&mut app.page, Page::HelpAbout, "About"); + ui.separator(); }); + ui.separator(); if app.page == Page::HelpHelp { ui.add_space(24.0); @@ -127,71 +131,9 @@ pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Fra ui.add_space(10.0); }); + } else if app.page == Page::HelpStats { + stats::update(app, ctx, _frame, ui); } else if app.page == Page::HelpAbout { - ui.with_layout(Layout::top_down(Align::Center), |ui| { - - ui.add_space(30.0); - - ui.image(&app.icon, app.icon.size_vec2()); - - ui.add_space(15.0); - - ui.label( - RichText::new(&app.about.name).strong() - ); - - ui.add_space(15.0); - - ui.label( - RichText::new(&app.about.version) - .text_style(TextStyle::Body) - ); - - ui.add_space(15.0); - - ui.label( - RichText::new(&app.about.description) - .text_style(TextStyle::Body) - ); - - ui.add_space(35.0); - - ui.label( - RichText::new(format!("nostr is a protocol and specification for storing and retrieving social media events onto servers called relays. Many users store their events onto multiple relays for reliability, censorship resistance, and to spread their reach. If you didn't store an event on a particular relay, don't expect anyone to find it there because relays normally don't share events with each other. - -Users are defined by their keypair, and are known by the public key of that pair. All events they generate are signed by their private key, and verifiable by their public key. - -We are storing data on your system in this file: {}. This data is only used locally by this client - the nostr protocol does not use clients as a store of data for other people. We are storing your settings, your private and public key, information about relays, and a cache of events. We cache events in your feed so that we don't have to ask relays for them again, which means less network traffic and faster startup times. -", app.about.storage_path)) - .text_style(TextStyle::Body) - ); - - ui.add_space(22.0); - - ui.hyperlink_to("Learn More about Nostr", "https://github.com/nostr-protocol/nostr"); - - ui.add_space(30.0); - - ui.hyperlink_to("Source Code", &app.about.homepage); - ui.label( - RichText::new("by") - .text_style(TextStyle::Small) - ); - ui.label( - RichText::new(&app.about.authors) - .text_style(TextStyle::Small) - ); - - ui.add_space(15.0); - - ui.label( - RichText::new("This program comes with absolutely no warranty.") - .text_style(TextStyle::Small) - ); - ui.label( - RichText::new("See the MIT License for details.") - .text_style(TextStyle::Small) - ); - }); + about::update(app, ctx, _frame, ui); } } diff --git a/src/ui/stats.rs b/src/ui/help/stats.rs similarity index 100% rename from src/ui/stats.rs rename to src/ui/help/stats.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9ae597f2..922031db 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,7 +3,6 @@ mod help; mod people; mod relays; mod settings; -mod stats; mod style; mod widgets; mod you; @@ -16,8 +15,8 @@ use crate::settings::Settings; use crate::ui::widgets::CopyButton; use eframe::{egui, IconData, Theme}; use egui::{ - ColorImage, Context, ImageData, Label, RichText, Sense, TextStyle, TextureHandle, - TextureOptions, Ui, + ColorImage, Context, ImageData, Label, RichText, SelectableLabel, Sense, TextStyle, + TextureHandle, TextureOptions, Ui, }; use nostr_types::{Id, PublicKey, PublicKeyHex}; use std::collections::HashMap; @@ -55,15 +54,17 @@ pub fn run() -> Result<(), Error> { #[derive(PartialEq)] enum Page { - Feed, - PeopleFollow, + FeedGeneral, + FeedThread, + FeedPerson, PeopleList, + PeopleFollow, Person, You, Relays, Settings, - Stats, HelpHelp, + HelpStats, HelpAbout, } @@ -84,7 +85,6 @@ struct GossipUi { import_bech32: String, import_hex: String, replying_to: Option, - hides: Vec, person_view_pubkey: Option, avatars: HashMap, new_relay_url: String, @@ -140,7 +140,7 @@ impl GossipUi { GossipUi { next_frame: Instant::now(), - page: Page::Feed, + page: Page::FeedGeneral, status: "Welcome to Gossip. Status messages will appear here. Click them to dismiss them." .to_owned(), @@ -157,7 +157,6 @@ impl GossipUi { import_bech32: "".to_owned(), import_hex: "".to_owned(), replying_to: None, - hides: Vec::new(), person_view_pubkey: None, avatars: HashMap::new(), new_relay_url: "".to_owned(), @@ -187,19 +186,65 @@ impl eframe::App for GossipUi { egui::TopBottomPanel::top("menu").show(ctx, |ui| { ui.horizontal(|ui| { - ui.selectable_value(&mut self.page, Page::Feed, "Feed"); + if ui + .add(SelectableLabel::new( + self.page == Page::FeedGeneral + || self.page == Page::FeedThread + || self.page == Page::FeedPerson, + "Feed", + )) + .clicked() + { + self.page = Page::FeedGeneral; + } ui.separator(); - ui.selectable_value(&mut self.page, Page::PeopleList, "People"); + if ui + .add(SelectableLabel::new( + self.page == Page::PeopleList + || self.page == Page::PeopleFollow + || self.page == Page::Person, + "People", + )) + .clicked() + { + self.page = Page::PeopleList; + } ui.separator(); - ui.selectable_value(&mut self.page, Page::You, "You"); + if ui + .add(SelectableLabel::new(self.page == Page::You, "You")) + .clicked() + { + self.page = Page::You; + } ui.separator(); - ui.selectable_value(&mut self.page, Page::Relays, "Relays"); + if ui + .add(SelectableLabel::new(self.page == Page::Relays, "Relays")) + .clicked() + { + self.page = Page::Relays; + } ui.separator(); - ui.selectable_value(&mut self.page, Page::Settings, "Settings"); + if ui + .add(SelectableLabel::new( + self.page == Page::Settings, + "Settings", + )) + .clicked() + { + self.page = Page::Settings; + } ui.separator(); - ui.selectable_value(&mut self.page, Page::Stats, "Stats"); - ui.separator(); - ui.selectable_value(&mut self.page, Page::HelpHelp, "Help"); + if ui + .add(SelectableLabel::new( + self.page == Page::HelpHelp + || self.page == Page::HelpStats + || self.page == Page::HelpAbout, + "Help", + )) + .clicked() + { + self.page = Page::HelpHelp; + } ui.separator(); }); }); @@ -216,16 +261,18 @@ impl eframe::App for GossipUi { }); egui::CentralPanel::default().show(ctx, |ui| match self.page { - Page::Feed => feed::update(self, ctx, frame, ui), - Page::PeopleList => people::update(self, ctx, frame, ui), - Page::PeopleFollow => people::update(self, ctx, frame, ui), - Page::Person => people::update(self, ctx, frame, ui), + Page::FeedGeneral | Page::FeedThread | Page::FeedPerson => { + feed::update(self, ctx, frame, ui) + } + Page::PeopleList | Page::PeopleFollow | Page::Person => { + people::update(self, ctx, frame, ui) + } Page::You => you::update(self, ctx, frame, ui), Page::Relays => relays::update(self, ctx, frame, ui), Page::Settings => settings::update(self, ctx, frame, ui, darkmode), - Page::Stats => stats::update(self, ctx, frame, ui), - Page::HelpHelp => help::update(self, ctx, frame, ui), - Page::HelpAbout => help::update(self, ctx, frame, ui), + Page::HelpHelp | Page::HelpStats | Page::HelpAbout => { + help::update(self, ctx, frame, ui) + } }); } } diff --git a/src/ui/people.rs b/src/ui/people.rs deleted file mode 100644 index a390c3da..00000000 --- a/src/ui/people.rs +++ /dev/null @@ -1,237 +0,0 @@ -use super::{GossipUi, Page}; -use crate::comms::BusMessage; -use crate::db::DbPerson; -use crate::globals::GLOBALS; -use eframe::egui; -use egui::{Context, Image, RichText, ScrollArea, Sense, TextEdit, TopBottomPanel, Ui, Vec2}; - -pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Frame, ui: &mut Ui) { - let maybe_person = if let Some(pubkeyhex) = &app.person_view_pubkey { - GLOBALS.people.blocking_write().get(pubkeyhex) - } else { - None - }; - - TopBottomPanel::top("people_menu").show(ctx, |ui| { - ui.horizontal(|ui| { - ui.selectable_value(&mut app.page, Page::PeopleList, "Followed"); - ui.separator(); - ui.selectable_value(&mut app.page, Page::PeopleFollow, "Follow Someone New"); - ui.separator(); - if let Some(person) = &maybe_person { - ui.selectable_value(&mut app.page, Page::Person, get_name(person)); - ui.separator(); - } - }); - }); - - if app.page == Page::PeopleFollow { - ui.add_space(30.0); - - ui.heading("NOTICE: Gossip doesn't update the filters when you follow someone yet, so you have to restart the client to fetch their events. Will fix soon."); - - ui.heading("NOTICE: Gossip is not synchronizing with data on the nostr relays. This is a separate list and it won't overwrite anything."); - - ui.label("NOTICE: use CTRL-V to paste (middle/right click wont work)"); - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - ui.heading("NIP-35: Follow a DNS ID"); - - ui.horizontal(|ui| { - ui.label("Enter user@domain"); - ui.add(TextEdit::singleline(&mut app.nip35follow).hint_text("user@domain")); - }); - if ui.button("follow").clicked() { - let tx = GLOBALS.to_overlord.clone(); - let _ = tx.send(BusMessage { - target: "overlord".to_string(), - kind: "follow_nip35".to_string(), - json_payload: serde_json::to_string(&app.nip35follow).unwrap(), - }); - app.nip35follow = "".to_owned(); - } - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - ui.heading("Follow a bech32 public key"); - - ui.horizontal(|ui| { - ui.label("Enter bech32 public key"); - ui.add(TextEdit::singleline(&mut app.follow_bech32_pubkey).hint_text("npub1...")); - }); - ui.horizontal(|ui| { - ui.label("Enter a relay URL where we can find them"); - ui.add(TextEdit::singleline(&mut app.follow_pubkey_at_relay).hint_text("wss://...")); - }); - if ui.button("follow").clicked() { - let tx = GLOBALS.to_overlord.clone(); - let _ = tx.send(BusMessage { - target: "overlord".to_string(), - kind: "follow_bech32".to_string(), - json_payload: serde_json::to_string(&( - &app.follow_bech32_pubkey, - &app.follow_pubkey_at_relay, - )) - .unwrap(), - }); - app.follow_bech32_pubkey = "".to_owned(); - app.follow_pubkey_at_relay = "".to_owned(); - } - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - ui.heading("Follow a hex public key"); - - ui.horizontal(|ui| { - ui.label("Enter hex-encoded public key"); - ui.add( - TextEdit::singleline(&mut app.follow_hex_pubkey).hint_text("0123456789abcdef..."), - ); - }); - ui.horizontal(|ui| { - ui.label("Enter a relay URL where we can find them"); - ui.add(TextEdit::singleline(&mut app.follow_pubkey_at_relay).hint_text("wss://...")); - }); - if ui.button("follow").clicked() { - let tx = GLOBALS.to_overlord.clone(); - let _ = tx.send(BusMessage { - target: "overlord".to_string(), - kind: "follow_hexkey".to_string(), - json_payload: serde_json::to_string(&( - &app.follow_hex_pubkey, - &app.follow_pubkey_at_relay, - )) - .unwrap(), - }); - app.follow_hex_pubkey = "".to_owned(); - app.follow_pubkey_at_relay = "".to_owned(); - } - } else if app.page == Page::PeopleList { - ui.add_space(24.0); - - ui.heading("NOTICE: Gossip is not synchronizing with data on the nostr relays. This is a separate list and it won't overwrite anything."); - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - - ui.heading("People Followed"); - ui.add_space(18.0); - - let people = GLOBALS.people.blocking_write().get_all(); - - ScrollArea::vertical().show(ui, |ui| { - for person in people.iter() { - if person.followed != 1 { - continue; - } - - ui.horizontal(|ui| { - // Avatar first - let avatar = if let Some(avatar) = app.try_get_avatar(ctx, &person.pubkey) { - avatar - } else { - app.placeholder_avatar.clone() - }; - if ui - .add( - Image::new( - &avatar, - Vec2 { - x: crate::AVATAR_SIZE_F32, - y: crate::AVATAR_SIZE_F32, - }, - ) - .sense(Sense::click()), - ) - .clicked() - { - set_person_view(app, person); - }; - - ui.vertical(|ui| { - ui.label(RichText::new(GossipUi::hex_pubkey_short(&person.pubkey)).weak()); - GossipUi::render_person_name_line(ui, Some(person)); - }); - }); - - ui.add_space(4.0); - - ui.separator(); - } - }); - } else if app.page == Page::Person { - if maybe_person.is_none() || app.person_view_pubkey.is_none() { - ui.label("ERROR"); - } else { - let person = maybe_person.as_ref().unwrap(); - let pubkeyhex = app.person_view_pubkey.as_ref().unwrap().clone(); - - ui.add_space(24.0); - - ui.heading(get_name(person)); - - ui.horizontal(|ui| { - // Avatar first - let avatar = if let Some(avatar) = app.try_get_avatar(ctx, &pubkeyhex) { - avatar - } else { - app.placeholder_avatar.clone() - }; - ui.image(&avatar, Vec2 { x: 36.0, y: 36.0 }); - - ui.vertical(|ui| { - ui.label(RichText::new(GossipUi::hex_pubkey_short(&pubkeyhex)).weak()); - GossipUi::render_person_name_line(ui, Some(person)); - }); - }); - - ui.add_space(12.0); - - if let Some(about) = person.about.as_deref() { - ui.label(about); - } - - ui.add_space(12.0); - - #[allow(clippy::collapsible_else_if)] - if person.followed == 0 { - if ui.button("FOLLOW").clicked() { - GLOBALS.people.blocking_write().follow(&pubkeyhex, true); - } - } else { - if ui.button("UNFOLLOW").clicked() { - GLOBALS.people.blocking_write().follow(&pubkeyhex, false); - } - } - - if ui.button("UPDATE METADATA").clicked() { - let _ = GLOBALS.to_overlord.send(BusMessage { - target: "overlord".to_string(), - kind: "update_metadata".to_string(), - json_payload: serde_json::to_string(&pubkeyhex).unwrap(), - }); - } - } - } -} - -fn get_name(person: &DbPerson) -> String { - if let Some(name) = &person.name { - name.to_owned() - } else { - GossipUi::hex_pubkey_short(&person.pubkey) - } -} - -fn set_person_view(app: &mut GossipUi, person: &DbPerson) { - app.person_view_pubkey = Some(person.pubkey.clone()); - app.page = Page::Person; -} diff --git a/src/ui/people/follow.rs b/src/ui/people/follow.rs new file mode 100644 index 00000000..00a554d3 --- /dev/null +++ b/src/ui/people/follow.rs @@ -0,0 +1,93 @@ +use super::GossipUi; +use crate::comms::BusMessage; +use crate::globals::GLOBALS; +use eframe::egui; +use egui::{Context, TextEdit, Ui}; + +pub(super) fn update(app: &mut GossipUi, _ctx: &Context, _frame: &mut eframe::Frame, ui: &mut Ui) { + ui.add_space(30.0); + + ui.heading("NOTICE: Gossip doesn't update the filters when you follow someone yet, so you have to restart the client to fetch their events. Will fix soon."); + + ui.heading("NOTICE: Gossip is not synchronizing with data on the nostr relays. This is a separate list and it won't overwrite anything."); + + ui.label("NOTICE: use CTRL-V to paste (middle/right click wont work)"); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.heading("NIP-35: Follow a DNS ID"); + + ui.horizontal(|ui| { + ui.label("Enter user@domain"); + ui.add(TextEdit::singleline(&mut app.nip35follow).hint_text("user@domain")); + }); + if ui.button("follow").clicked() { + let tx = GLOBALS.to_overlord.clone(); + let _ = tx.send(BusMessage { + target: "overlord".to_string(), + kind: "follow_nip35".to_string(), + json_payload: serde_json::to_string(&app.nip35follow).unwrap(), + }); + app.nip35follow = "".to_owned(); + } + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.heading("Follow a bech32 public key"); + + ui.horizontal(|ui| { + ui.label("Enter bech32 public key"); + ui.add(TextEdit::singleline(&mut app.follow_bech32_pubkey).hint_text("npub1...")); + }); + ui.horizontal(|ui| { + ui.label("Enter a relay URL where we can find them"); + ui.add(TextEdit::singleline(&mut app.follow_pubkey_at_relay).hint_text("wss://...")); + }); + if ui.button("follow").clicked() { + let tx = GLOBALS.to_overlord.clone(); + let _ = tx.send(BusMessage { + target: "overlord".to_string(), + kind: "follow_bech32".to_string(), + json_payload: serde_json::to_string(&( + &app.follow_bech32_pubkey, + &app.follow_pubkey_at_relay, + )) + .unwrap(), + }); + app.follow_bech32_pubkey = "".to_owned(); + app.follow_pubkey_at_relay = "".to_owned(); + } + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.heading("Follow a hex public key"); + + ui.horizontal(|ui| { + ui.label("Enter hex-encoded public key"); + ui.add(TextEdit::singleline(&mut app.follow_hex_pubkey).hint_text("0123456789abcdef...")); + }); + ui.horizontal(|ui| { + ui.label("Enter a relay URL where we can find them"); + ui.add(TextEdit::singleline(&mut app.follow_pubkey_at_relay).hint_text("wss://...")); + }); + if ui.button("follow").clicked() { + let tx = GLOBALS.to_overlord.clone(); + let _ = tx.send(BusMessage { + target: "overlord".to_string(), + kind: "follow_hexkey".to_string(), + json_payload: serde_json::to_string(&( + &app.follow_hex_pubkey, + &app.follow_pubkey_at_relay, + )) + .unwrap(), + }); + app.follow_hex_pubkey = "".to_owned(); + app.follow_pubkey_at_relay = "".to_owned(); + } +} diff --git a/src/ui/people/mod.rs b/src/ui/people/mod.rs new file mode 100644 index 00000000..861412a2 --- /dev/null +++ b/src/ui/people/mod.rs @@ -0,0 +1,101 @@ +use super::{GossipUi, Page}; +use crate::db::DbPerson; +use crate::globals::GLOBALS; +use eframe::egui; +use egui::{Context, Image, RichText, ScrollArea, Sense, Ui, Vec2}; + +mod follow; +mod person; + +pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Frame, ui: &mut Ui) { + let maybe_person = if let Some(pubkeyhex) = &app.person_view_pubkey { + GLOBALS.people.blocking_write().get(pubkeyhex) + } else { + None + }; + + ui.horizontal(|ui| { + ui.selectable_value(&mut app.page, Page::PeopleList, "Followed"); + ui.separator(); + ui.selectable_value(&mut app.page, Page::PeopleFollow, "Follow Someone New"); + ui.separator(); + if let Some(person) = &maybe_person { + ui.selectable_value(&mut app.page, Page::Person, get_name(person)); + ui.separator(); + } + }); + ui.separator(); + + if app.page == Page::PeopleList { + ui.add_space(24.0); + + ui.heading("NOTICE: Gossip is not synchronizing with data on the nostr relays. This is a separate list and it won't overwrite anything."); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.heading("People Followed"); + ui.add_space(18.0); + + let people = GLOBALS.people.blocking_write().get_all(); + + ScrollArea::vertical().show(ui, |ui| { + for person in people.iter() { + if person.followed != 1 { + continue; + } + + ui.horizontal(|ui| { + // Avatar first + let avatar = if let Some(avatar) = app.try_get_avatar(ctx, &person.pubkey) { + avatar + } else { + app.placeholder_avatar.clone() + }; + if ui + .add( + Image::new( + &avatar, + Vec2 { + x: crate::AVATAR_SIZE_F32, + y: crate::AVATAR_SIZE_F32, + }, + ) + .sense(Sense::click()), + ) + .clicked() + { + set_person_view(app, person); + }; + + ui.vertical(|ui| { + ui.label(RichText::new(GossipUi::hex_pubkey_short(&person.pubkey)).weak()); + GossipUi::render_person_name_line(ui, Some(person)); + }); + }); + + ui.add_space(4.0); + + ui.separator(); + } + }); + } else if app.page == Page::PeopleFollow { + follow::update(app, ctx, _frame, ui); + } else if app.page == Page::Person { + person::update(app, ctx, _frame, ui); + } +} + +fn get_name(person: &DbPerson) -> String { + if let Some(name) = &person.name { + name.to_owned() + } else { + GossipUi::hex_pubkey_short(&person.pubkey) + } +} + +fn set_person_view(app: &mut GossipUi, person: &DbPerson) { + app.person_view_pubkey = Some(person.pubkey.clone()); + app.page = Page::Person; +} diff --git a/src/ui/people/person.rs b/src/ui/people/person.rs new file mode 100644 index 00000000..6ef4f720 --- /dev/null +++ b/src/ui/people/person.rs @@ -0,0 +1,80 @@ +use super::{GossipUi, Page}; +use crate::comms::BusMessage; +use crate::db::DbPerson; +use crate::globals::GLOBALS; +use eframe::egui; +use egui::{Context, RichText, Ui, Vec2}; + +pub(super) fn update(app: &mut GossipUi, ctx: &Context, _frame: &mut eframe::Frame, ui: &mut Ui) { + let maybe_person = if let Some(pubkeyhex) = &app.person_view_pubkey { + GLOBALS.people.blocking_write().get(pubkeyhex) + } else { + None + }; + + if maybe_person.is_none() || app.person_view_pubkey.is_none() { + ui.label("ERROR"); + } else { + let person = maybe_person.as_ref().unwrap(); + let pubkeyhex = app.person_view_pubkey.as_ref().unwrap().clone(); + + ui.add_space(24.0); + + ui.heading(get_name(person)); + + ui.horizontal(|ui| { + // Avatar first + let avatar = if let Some(avatar) = app.try_get_avatar(ctx, &pubkeyhex) { + avatar + } else { + app.placeholder_avatar.clone() + }; + ui.image(&avatar, Vec2 { x: 36.0, y: 36.0 }); + + ui.vertical(|ui| { + ui.label(RichText::new(GossipUi::hex_pubkey_short(&pubkeyhex)).weak()); + GossipUi::render_person_name_line(ui, Some(person)); + }); + }); + + ui.add_space(12.0); + + if let Some(about) = person.about.as_deref() { + ui.label(about); + } + + ui.add_space(12.0); + + #[allow(clippy::collapsible_else_if)] + if person.followed == 0 { + if ui.button("FOLLOW").clicked() { + GLOBALS.people.blocking_write().follow(&pubkeyhex, true); + } + } else { + if ui.button("UNFOLLOW").clicked() { + GLOBALS.people.blocking_write().follow(&pubkeyhex, false); + } + } + + if ui.button("UPDATE METADATA").clicked() { + let _ = GLOBALS.to_overlord.send(BusMessage { + target: "overlord".to_string(), + kind: "update_metadata".to_string(), + json_payload: serde_json::to_string(&pubkeyhex).unwrap(), + }); + } + + if ui.button("VIEW THEIR FEED").clicked() { + GLOBALS.feed.set_feed_to_person(pubkeyhex.clone()); + app.page = Page::FeedPerson; + } + } +} + +fn get_name(person: &DbPerson) -> String { + if let Some(name) = &person.name { + name.to_owned() + } else { + GossipUi::hex_pubkey_short(&person.pubkey) + } +} diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 49c506e7..7e712af6 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -57,10 +57,6 @@ pub(super) fn update( ui.heading("Feed"); - ui.checkbox(&mut app.settings.view_threaded, "Threaded feed") - .on_hover_text("If selected, replies are under what they reply to and the newest replied-to thread comes first. Otherwise all posts are independent and in time order."); - - ui.add_space(24.0); ui.horizontal(|ui| { ui.label("Recompute feed every (milliseconds): ") .on_hover_text(