notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit d6cf2cf6f3570ef88e738ec733c9136de948817e
parent 9645ecb70fe25f3334a72824e9cc7d12130bbc56
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 18 Dec 2025 16:02:22 -0800

Merge messages nip17 DM app by kernel #1223

kernelkind (37):
      refactor(clippy): appease
      fix: re-add debug features
      refactor(route): extract Router to notedeck core
      refactor(nav): Move BodyResponse to notedeck crate
      refactor(nav): rename BodyResponse -> DragResponse
      refactor(ContactsListView): move to notedeck_ui crate
      feat(ContactsListView): add ability to use HashSet as well
      refactor(ContactsListView): migrate away from NoteContext
      refactor(ContactsListView): generify action naming
      refactor(assets): previous icon had weird artifacts on border
      refactor(nav): move chevron to notedeck_ui
      feat(messages-app): app wiring
      feat(notedeck-ui): HorizontalHeader
      feat(giftwrap): make sure to sub init filter
      refactor(process-event): extract shared logic to notedeck crate
      refactor(egui-nav): bump
      feat(nip44): add nostr crate nip44 feature
      feat(dm-relay-list): send default DMs relay list on acc creation
      feat(note): helper to get all p tags from note
      feat(cargo): add messages deps
      feat(router): move route_to_replaced to Router & add other impl
      feat(messages): conversation ID registry
      feat(message-store): add MessageStore
      feat(convo-state): add `ConversationStates`
      feat(conv-renderable): add `ConversationRenderable`
      feat(nip17): Nip17 helpers
      feat(convo-cache): add `ConversationCache`
      feat(nip17): send conversation message
      feat(msgs-nav): process responses/actions
      feat(msgs-ui): helpers
      feat(msgs-ui): add `ConversationUi`
      feat(msgs-ui): add `ConversationListUi`
      feat(msgs-ui): add `CreateConvoUi`
      feat(msgs-ui): add navigation UI
      feat(msgs-ui): messages UI
      feat(msgs-ui): main UI
      toggle messages app ON by default

Diffstat:
MCargo.lock | 24+++++++++++++++++++++++-
MCargo.toml | 6++++--
Mcrates/enostr/src/lib.rs | 2+-
Mcrates/notedeck/Cargo.toml | 2++
Mcrates/notedeck/src/account/accounts.rs | 10++++++++++
Mcrates/notedeck/src/app.rs | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck/src/args.rs | 2++
Mcrates/notedeck/src/lib.rs | 10++++++----
Mcrates/notedeck/src/media/network.rs | 2+-
Acrates/notedeck/src/nav.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/note/mod.rs | 21+++++++++++++++++++++
Mcrates/notedeck/src/options.rs | 3+++
Mcrates/notedeck/src/route.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/theme.rs | 2--
Mcrates/notedeck/src/unknowns.rs | 12++++++++++++
Mcrates/notedeck_chrome/Cargo.toml | 3+--
Mcrates/notedeck_chrome/src/app.rs | 3+++
Mcrates/notedeck_chrome/src/chrome.rs | 10++++++++++
Mcrates/notedeck_columns/src/accounts/mod.rs | 10+++++-----
Mcrates/notedeck_columns/src/app.rs | 138++++++++++++++++++-------------------------------------------------------------
Mcrates/notedeck_columns/src/column.rs | 12++++++------
Mcrates/notedeck_columns/src/nav.rs | 162++++++++++++++++++++++---------------------------------------------------------
Mcrates/notedeck_columns/src/profile.rs | 22++++++++++++++++++++++
Mcrates/notedeck_columns/src/route.rs | 109+++++++++++++++++++++++++++++++++++++------------------------------------------
Mcrates/notedeck_columns/src/timeline/route.rs | 10+++++-----
Mcrates/notedeck_columns/src/ui/accounts.rs | 8+++-----
Mcrates/notedeck_columns/src/ui/column/header.rs | 26+++-----------------------
Mcrates/notedeck_columns/src/ui/mentions_picker.rs | 10++++------
Mcrates/notedeck_columns/src/ui/note/post.rs | 13++++++-------
Mcrates/notedeck_columns/src/ui/note/quote_repost.rs | 9++++-----
Mcrates/notedeck_columns/src/ui/note/reply.rs | 9++++-----
Mcrates/notedeck_columns/src/ui/onboarding.rs | 10+++++-----
Dcrates/notedeck_columns/src/ui/profile/contacts_list.rs | 98-------------------------------------------------------------------------------
Mcrates/notedeck_columns/src/ui/profile/edit.rs | 7+++----
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 11++++-------
Mcrates/notedeck_columns/src/ui/relay.rs | 7+++----
Mcrates/notedeck_columns/src/ui/search/mod.rs | 16++++++++--------
Mcrates/notedeck_columns/src/ui/settings.rs | 12++++--------
Mcrates/notedeck_columns/src/ui/thread.rs | 6+++---
Mcrates/notedeck_columns/src/ui/timeline.rs | 12++++++------
Acrates/notedeck_messages/Cargo.toml | 23+++++++++++++++++++++++
Acrates/notedeck_messages/src/cache/conversation.rs | 352+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/cache/message_store.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/cache/mod.rs | 13+++++++++++++
Acrates/notedeck_messages/src/cache/registry.rs | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/cache/state.rs | 33+++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/convo_renderable.rs | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/lib.rs | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/nav.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/nip17/message.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/nip17/mod.rs | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/ui/convo.rs | 612+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/ui/convo_list.rs | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/ui/create_convo.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/ui/messages.rs | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/ui/mod.rs | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_messages/src/ui/nav.rs | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_ui/src/app_images.rs | 2+-
Acrates/notedeck_ui/src/contacts_list.rs | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_ui/src/header.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_ui/src/lib.rs | 3+++
61 files changed, 4077 insertions(+), 508 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1706,7 +1706,7 @@ dependencies = [ [[package]] name = "egui_nav" version = "0.2.0" -source = "git+https://github.com/damus-io/egui-nav?rev=4a54a6c7c34243e4bcfdeb37cc15de3536811719#4a54a6c7c34243e4bcfdeb37cc15de3536811719" +source = "git+https://github.com/kernelkind/egui-nav?rev=8d8e93b7ea4e87c70af9627fa8e3591489abafd0#8d8e93b7ea4e87c70af9627fa8e3591489abafd0" dependencies = [ "bitflags 2.9.1", "egui", @@ -3939,6 +3939,7 @@ dependencies = [ "notedeck_clndash", "notedeck_columns", "notedeck_dave", + "notedeck_messages", "notedeck_notebook", "notedeck_ui", "profiling", @@ -4057,6 +4058,27 @@ dependencies = [ ] [[package]] +name = "notedeck_messages" +version = "0.7.1" +dependencies = [ + "chrono", + "egui", + "egui_extras", + "egui_nav", + "egui_virtual_list", + "enostr", + "hashbrown 0.15.4", + "nostr 0.37.0", + "nostrdb", + "notedeck", + "notedeck_ui", + "profiling", + "tempfile", + "tracing", + "uuid", +] + +[[package]] name = "notedeck_notebook" version = "0.7.1" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/notedeck_chrome", "crates/notedeck_columns", "crates/notedeck_dave", + "crates/notedeck_messages", "crates/notedeck_notebook", "crates/notedeck_ui", "crates/notedeck_clndash", @@ -28,7 +29,7 @@ egui = { version = "0.31.1", features = ["serde"] } egui-wgpu = "0.31.1" egui_extras = { version = "0.31.1", features = ["all_loaders"] } egui-winit = { version = "0.31.1", features = ["android-game-activity", "clipboard"] } -egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "4a54a6c7c34243e4bcfdeb37cc15de3536811719" } +egui_nav = { git = "https://github.com/kernelkind/egui-nav", rev = "8d8e93b7ea4e87c70af9627fa8e3591489abafd0" } egui_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "6eb91740577b374a8a6658c09c9a4181299734d0" } #egui_virtual_list = "0.6.0" egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b6794f5e707a2f4109633770e02b02fb722e1" } @@ -48,7 +49,7 @@ image = { version = "0.25", features = ["jpeg", "png", "webp"] } indexmap = "2.6.0" log = "0.4.17" md5 = "0.7.0" -nostr = { version = "0.37.0", default-features = false, features = ["std", "nip49"] } +nostr = { version = "0.37.0", default-features = false, features = ["std", "nip44", "nip49"] } nwc = "0.39.0" mio = { version = "1.0.3", features = ["os-poll", "net"] } nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "8148dd3cea9bc8ff0bc510c720d0c51f327a0a1a" } @@ -58,6 +59,7 @@ notedeck_chrome = { path = "crates/notedeck_chrome" } notedeck_clndash = { path = "crates/notedeck_clndash" } notedeck_columns = { path = "crates/notedeck_columns" } notedeck_dave = { path = "crates/notedeck_dave" } +notedeck_messages = { path = "crates/notedeck_messages" } notedeck_notebook = { path = "crates/notedeck_notebook" } notedeck_ui = { path = "crates/notedeck_ui" } tokenator = { path = "crates/tokenator" } diff --git a/crates/enostr/src/lib.rs b/crates/enostr/src/lib.rs @@ -17,7 +17,7 @@ pub use note::{Note, NoteId}; pub use profile::ProfileState; pub use pubkey::{Pubkey, PubkeyRef}; pub use relay::message::{RelayEvent, RelayMessage}; -pub use relay::pool::{PoolEvent, PoolRelay, RelayPool}; +pub use relay::pool::{PoolEvent, PoolEventBuf, PoolRelay, RelayPool}; pub use relay::subs_debug::{OwnedRelayEvent, RelayLogEvent, SubsDebug, TransferStats}; pub use relay::{Relay, RelayStatus}; diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml @@ -72,3 +72,5 @@ ndk-context = "0.1" [features] puffin = ["puffin_egui", "dep:puffin"] +debug-widget-callstack = ["egui/callstack"] +debug-interactive-widgets = [] diff --git a/crates/notedeck/src/account/accounts.rs b/crates/notedeck/src/account/accounts.rs @@ -303,6 +303,16 @@ impl Accounts { ), relay_url, ); + if let Some(cur_pk) = self.selected_filled().map(|s| s.pubkey) { + let giftwraps_filter = nostrdb::Filter::new() + .kinds([1059]) + .pubkeys([cur_pk.bytes()]) + .build(); + pool.send_to( + &ClientMessage::req(self.subs.giftwraps.remote.clone(), vec![giftwraps_filter]), + relay_url, + ); + } } pub fn update(&mut self, ndb: &mut Ndb, pool: &mut RelayPool, ctx: &egui::Context) { diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs @@ -1,6 +1,7 @@ use crate::account::FALLBACK_PUBKEY; use crate::i18n::Localization; use crate::persist::{AppSizeHandler, SettingsHandler}; +use crate::unknowns::unknown_id_send; use crate::wallet::GlobalWallet; use crate::zaps::Zaps; use crate::NotedeckOptions; @@ -13,7 +14,7 @@ use crate::{JobPool, MediaJobs}; use egui::Margin; use egui::ThemePreference; use egui_winit::clipboard::Clipboard; -use enostr::RelayPool; +use enostr::{PoolEventBuf, PoolRelay, RelayEvent, RelayMessage, RelayPool}; use nostrdb::{Config, Ndb, Transaction}; use std::cell::RefCell; use std::collections::BTreeSet; @@ -426,3 +427,94 @@ pub fn install_crypto() { let provider = rustls::crypto::aws_lc_rs::default_provider(); let _ = provider.install_default(); } + +pub fn try_process_events_core( + app_ctx: &mut AppContext<'_>, + ctx: &egui::Context, + mut receive: impl FnMut(&mut AppContext, PoolEventBuf), +) { + let ctx2 = ctx.clone(); + let wakeup = move || { + ctx2.request_repaint(); + }; + + app_ctx.pool.keepalive_ping(wakeup); + + // NOTE: we don't use the while let loop due to borrow issues + #[allow(clippy::while_let_loop)] + loop { + profiling::scope!("receiving events"); + let ev = if let Some(ev) = app_ctx.pool.try_recv() { + ev.into_owned() + } else { + break; + }; + + match (&ev.event).into() { + RelayEvent::Opened => { + tracing::trace!("Opened relay {}", ev.relay); + app_ctx + .accounts + .send_initial_filters(app_ctx.pool, &ev.relay); + } + RelayEvent::Closed => tracing::warn!("{} connection closed", &ev.relay), + RelayEvent::Other(msg) => { + tracing::trace!("relay {} sent other event {:?}", ev.relay, &msg) + } + RelayEvent::Error(error) => error!("relay {} had error: {error:?}", &ev.relay), + RelayEvent::Message(msg) => { + process_message_core(app_ctx, &ev.relay, &msg); + } + } + + receive(app_ctx, ev); + } + + if app_ctx.unknown_ids.ready_to_send() { + unknown_id_send(app_ctx.unknown_ids, app_ctx.pool); + } +} + +fn process_message_core(ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessage) { + match msg { + RelayMessage::Event(_subid, ev) => { + let relay = if let Some(relay) = ctx.pool.relays.iter().find(|r| r.url() == relay) { + relay + } else { + error!("couldn't find relay {} for note processing!?", relay); + return; + }; + + match relay { + PoolRelay::Websocket(_) => { + //info!("processing event {}", event); + tracing::trace!("processing event {ev}"); + if let Err(err) = ctx.ndb.process_event_with( + ev, + nostrdb::IngestMetadata::new() + .client(false) + .relay(relay.url()), + ) { + error!("error processing event {ev}: {err}"); + } + } + PoolRelay::Multicast(_) => { + // multicast events are client events + if let Err(err) = ctx.ndb.process_event_with( + ev, + nostrdb::IngestMetadata::new() + .client(true) + .relay(relay.url()), + ) { + error!("error processing multicast event {ev}: {err}"); + } + } + } + } + RelayMessage::Notice(msg) => tracing::warn!("Notice from {}: {}", relay, msg), + RelayMessage::OK(cr) => info!("OK {:?}", cr), + RelayMessage::Eose(id) => { + tracing::trace!("Relay {} received eose: {id}", relay) + } + } +} diff --git a/crates/notedeck/src/args.rs b/crates/notedeck/src/args.rs @@ -128,6 +128,8 @@ impl Args { res.options.set(NotedeckOptions::FeatureNotebook, true); } else if arg == "--clndash" { res.options.set(NotedeckOptions::FeatureClnDash, true); + } else if arg == "--messages" { + res.options.set(NotedeckOptions::FeatureMessages, true); } else { unrecognized_args.insert(arg.clone()); } diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -17,6 +17,7 @@ pub mod jobs; pub mod media; mod muted; pub mod name; +pub mod nav; mod nip51_set; pub mod note; mod notecache; @@ -46,7 +47,7 @@ pub use account::accounts::{AccountData, AccountSubs, Accounts}; pub use account::contacts::{ContactState, IsFollowing}; pub use account::relay::RelayAction; pub use account::FALLBACK_PUBKEY; -pub use app::{App, AppAction, AppResponse, Notedeck}; +pub use app::{try_process_events_core, App, AppAction, AppResponse, Notedeck}; pub use args::Args; pub use context::{AppContext, SoftKeyboardContext}; pub use error::{show_one_error_message, Error, FilterError, ZapError}; @@ -67,10 +68,11 @@ pub use media::{ }; pub use muted::{MuteFun, Muted}; pub use name::NostrName; +pub use nav::DragResponse; pub use nip51_set::{create_nip51_set, Nip51Set, Nip51SetCache}; pub use note::{ - BroadcastContext, ContextSelection, NoteAction, NoteContext, NoteContextSelection, NoteRef, - RootIdError, RootNoteId, RootNoteIdBuf, ScrollInfo, ZapAction, + get_p_tags, BroadcastContext, ContextSelection, NoteAction, NoteContext, NoteContextSelection, + NoteRef, RootIdError, RootNoteId, RootNoteIdBuf, ScrollInfo, ZapAction, }; pub use notecache::{CachedNote, NoteCache}; pub use options::NotedeckOptions; @@ -79,7 +81,7 @@ pub use profile::*; pub use relay_debug::RelayDebugView; pub use relayspec::RelaySpec; pub use result::Result; -pub use route::DrawerRouter; +pub use route::{DrawerRouter, ReplacementType, Router}; pub use storage::{AccountStorage, DataPath, DataPathType, Directory}; pub use style::NotedeckTextStyle; pub use theme::ColorTheme; diff --git a/crates/notedeck/src/media/network.rs b/crates/notedeck/src/media/network.rs @@ -147,7 +147,7 @@ impl Error for HyperHttpError { impl fmt::Display for HyperHttpError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Hyper(e) => write!(f, "Hyper error: {}", e), + Self::Hyper(e) => write!(f, "Hyper error: {e}"), Self::Host => write!(f, "Missing host in URL"), Self::Uri => write!(f, "Invalid URI"), Self::BodyTooLarge => write!(f, "Body too large"), diff --git a/crates/notedeck/src/nav.rs b/crates/notedeck/src/nav.rs @@ -0,0 +1,82 @@ +use egui::scroll_area::ScrollAreaOutput; + +pub struct DragResponse<R> { + pub drag_id: Option<egui::Id>, // the id which was used for dragging. + pub output: Option<R>, +} + +impl<R> DragResponse<R> { + pub fn none() -> Self { + Self { + drag_id: None, + output: None, + } + } + + pub fn scroll(output: ScrollAreaOutput<Option<R>>) -> Self { + Self { + drag_id: Some(Self::scroll_output_to_drag_id(output.id)), + output: output.inner, + } + } + + pub fn set_scroll_id(&mut self, output: &ScrollAreaOutput<Option<R>>) { + self.drag_id = Some(Self::scroll_output_to_drag_id(output.id)); + } + + pub fn output(output: Option<R>) -> Self { + Self { + drag_id: None, + output, + } + } + + pub fn set_output(&mut self, output: R) { + self.output = Some(output); + } + + /// The id of an `egui::ScrollAreaOutput` + /// Should use `Self::scroll` when possible + pub fn scroll_raw(mut self, id: egui::Id) -> Self { + self.drag_id = Some(Self::scroll_output_to_drag_id(id)); + self + } + + /// The id which is directly used for dragging + pub fn set_drag_id_raw(&mut self, id: egui::Id) { + self.drag_id = Some(id); + } + + fn scroll_output_to_drag_id(id: egui::Id) -> egui::Id { + id.with("area") + } + + pub fn map_output<S>(self, f: impl FnOnce(R) -> S) -> DragResponse<S> { + DragResponse { + drag_id: self.drag_id, + output: self.output.map(f), + } + } + + pub fn map_output_maybe<S>(self, f: impl FnOnce(R) -> Option<S>) -> DragResponse<S> { + DragResponse { + drag_id: self.drag_id, + output: self.output.and_then(f), + } + } + + pub fn maybe_map_output<S>(self, f: impl FnOnce(Option<R>) -> S) -> DragResponse<S> { + DragResponse { + drag_id: self.drag_id, + output: Some(f(self.output)), + } + } + + /// insert the contents of the new DragResponse if they are empty in Self + pub fn insert(&mut self, body: DragResponse<R>) { + self.drag_id = self.drag_id.or(body.drag_id); + if self.output.is_none() { + self.output = body.output; + } + } +} diff --git a/crates/notedeck/src/note/mod.rs b/crates/notedeck/src/note/mod.rs @@ -239,3 +239,24 @@ pub fn count_hashtags(note: &Note) -> usize { count } + +pub fn get_p_tags<'a>(note: &Note<'a>) -> Vec<&'a [u8; 32]> { + let mut items = Vec::new(); + for tag in note.tags() { + if tag.count() < 2 { + continue; + } + + if tag.get_str(0) != Some("p") { + continue; + } + + let Some(item) = tag.get_id(1) else { + continue; + }; + + items.push(item); + } + + items +} diff --git a/crates/notedeck/src/options.rs b/crates/notedeck/src/options.rs @@ -29,6 +29,9 @@ bitflags! { /// Is clndash enabled? const FeatureClnDash = 1 << 33; + + /// Is the Notedeck DMs app enabled? + const FeatureMessages = 1 << 34; } } diff --git a/crates/notedeck/src/route.rs b/crates/notedeck/src/route.rs @@ -29,3 +29,114 @@ impl DrawerRouter { self.drawer_focused = true; } } + +#[derive(Clone, Debug)] +pub struct Router<R: Clone> { + pub routes: Vec<R>, + pub returning: bool, + pub navigating: bool, + replacing: Option<ReplacementType>, +} + +#[derive(Clone, Debug)] +pub enum ReplacementType { + Single, + All, +} + +impl<R: Clone> Router<R> { + pub fn new(routes: Vec<R>) -> Self { + if routes.is_empty() { + panic!("routes can't be empty") + } + let returning = false; + let navigating = false; + let replacing = None; + + Self { + routes, + returning, + replacing, + navigating, + } + } + + pub fn route_to(&mut self, route: R) { + self.navigating = true; + self.routes.push(route); + } + + // Route to R. Then when it is successfully placed, should call `remove_previous_routes` to remove all previous routes + pub fn route_to_replaced(&mut self, route: R, replacement_type: ReplacementType) { + self.replacing = Some(replacement_type); + self.route_to(route); + } + + /// Go back, start the returning process + pub fn go_back(&mut self) -> Option<R> { + if self.returning || self.routes.len() == 1 { + return None; + } + self.returning = true; + + if self.routes.len() == 1 { + return None; + } + + self.prev().cloned() + } + + pub fn pop(&mut self) -> Option<R> { + if self.routes.len() == 1 { + return None; + } + + self.returning = false; + self.routes.pop() + } + + pub fn top(&self) -> &R { + self.routes.last().expect("routes can't be empty") + } + + pub fn prev(&self) -> Option<&R> { + self.routes.get(self.routes.len() - 2) + } + + pub fn routes(&self) -> &Vec<R> { + &self.routes + } + + pub fn len(&self) -> usize { + self.routes.len() + } + + pub fn is_empty(&self) -> bool { + self.routes.is_empty() + } + + pub fn is_replacing(&self) -> bool { + self.replacing.is_some() + } + + pub fn complete_replacement(&mut self) { + let num_routes = self.len(); + + self.returning = false; + let Some(replacement) = self.replacing.take() else { + return; + }; + if num_routes < 2 { + return; + } + + match replacement { + ReplacementType::Single => { + self.routes.remove(num_routes - 2); + } + ReplacementType::All => { + self.routes.drain(..num_routes - 1); + } + } + } +} diff --git a/crates/notedeck/src/theme.rs b/crates/notedeck/src/theme.rs @@ -204,7 +204,6 @@ pub fn add_custom_style(is_mobile: bool, style: &mut Style) { // debug: show callstack for the current widget on hover if all // modifier keys are pressed down. - /* #[cfg(feature = "debug-widget-callstack")] { #[cfg(not(debug_assertions))] @@ -225,7 +224,6 @@ pub fn add_custom_style(is_mobile: bool, style: &mut Style) { ); style.debug.show_interactive_widgets = true; } - */ } pub fn light_mode() -> Visuals { diff --git a/crates/notedeck/src/unknowns.rs b/crates/notedeck/src/unknowns.rs @@ -383,3 +383,15 @@ fn get_unknown_ids_filter(ids: &[&UnknownId]) -> Option<Vec<Filter>> { Some(filters) } + +pub fn unknown_id_send(unknown_ids: &mut UnknownIds, pool: &mut enostr::RelayPool) { + tracing::debug!("unknown_id_send called on: {:?}", &unknown_ids); + let filter = unknown_ids.filter().expect("filter"); + tracing::debug!( + "Getting {} unknown ids from relays", + unknown_ids.ids_iter().len() + ); + let msg = enostr::ClientMessage::req("unknownids".to_string(), filter); + unknown_ids.clear(); + pool.send(&msg); +} diff --git a/crates/notedeck_chrome/Cargo.toml b/crates/notedeck_chrome/Cargo.toml @@ -18,6 +18,7 @@ egui = { workspace = true } notedeck_columns = { workspace = true } notedeck_ui = { workspace = true } notedeck_dave = { workspace = true } +notedeck_messages = { workspace = true } notedeck_notebook = { workspace = true } notedeck_clndash = { workspace = true } notedeck = { workspace = true } @@ -53,8 +54,6 @@ default = [] memory = ["re_memory"] puffin = ["profiling/profile-with-puffin", "dep:puffin"] tracy = ["profiling/profile-with-tracy"] -debug-widget-callstack = ["egui/callstack"] -debug-interactive-widgets = [] [target.'cfg(target_os = "android")'.dependencies] tracing-logcat = "0.1.0" diff --git a/crates/notedeck_chrome/src/app.rs b/crates/notedeck_chrome/src/app.rs @@ -2,6 +2,7 @@ use notedeck::{AppContext, AppResponse}; use notedeck_clndash::ClnDash; use notedeck_columns::Damus; use notedeck_dave::Dave; +use notedeck_messages::MessagesApp; use notedeck_notebook::Notebook; #[allow(clippy::large_enum_variant)] @@ -10,6 +11,7 @@ pub enum NotedeckApp { Columns(Box<Damus>), Notebook(Box<Notebook>), ClnDash(Box<ClnDash>), + Messages(Box<MessagesApp>), Other(Box<dyn notedeck::App>), } @@ -21,6 +23,7 @@ impl notedeck::App for NotedeckApp { NotedeckApp::Columns(columns) => columns.update(ctx, ui), NotedeckApp::Notebook(notebook) => notebook.update(ctx, ui), NotedeckApp::ClnDash(clndash) => clndash.update(ctx, ui), + NotedeckApp::Messages(dms) => dms.update(ctx, ui), NotedeckApp::Other(other) => other.update(ctx, ui), } } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -26,6 +26,7 @@ use notedeck::{ }; use notedeck_columns::{timeline::TimelineKind, Damus}; use notedeck_dave::{Dave, DaveAvatar}; +use notedeck_messages::MessagesApp; use notedeck_ui::{app_images, expanding_button, galley_centered_pos, ProfilePic}; use std::collections::HashMap; @@ -154,6 +155,8 @@ impl Chrome { chrome.add_app(NotedeckApp::Columns(Box::new(columns))); chrome.add_app(NotedeckApp::Dave(Box::new(dave))); + chrome.add_app(NotedeckApp::Messages(Box::new(MessagesApp::new()))); + if notedeck.has_option(NotedeckOptions::FeatureNotebook) { chrome.add_app(NotedeckApp::Notebook(Box::default())); } @@ -771,6 +774,9 @@ fn topdown_sidebar( let text = match &app { NotedeckApp::Dave(_) => tr!(loc, "Dave", "Button to go to the Dave app"), NotedeckApp::Columns(_) => tr!(loc, "Columns", "Button to go to the Columns app"), + NotedeckApp::Messages(_) => { + tr!(loc, "Messaging", "Button to go to the messaging app") + } NotedeckApp::Notebook(_) => { tr!(loc, "Notebook", "Button to go to the Notebook app") } @@ -802,6 +808,10 @@ fn topdown_sidebar( ); } + NotedeckApp::Messages(_dms) => { + ui.add(app_images::new_message_image()); + } + NotedeckApp::ClnDash(_clndash) => { clndash_button(ui); } diff --git a/crates/notedeck_columns/src/accounts/mod.rs b/crates/notedeck_columns/src/accounts/mod.rs @@ -1,15 +1,14 @@ use enostr::{FullKeypair, Pubkey}; use nostrdb::{Ndb, Transaction}; -use notedeck::{Accounts, AppContext, Localization, SingleUnkIdAction, UnknownIds}; +use notedeck::{Accounts, AppContext, DragResponse, Localization, SingleUnkIdAction, UnknownIds}; use notedeck_ui::nip51_set::Nip51SetUiCache; pub use crate::accounts::route::AccountsResponse; use crate::app::get_active_columns_mut; use crate::decks::DecksCache; -use crate::nav::BodyResponse; use crate::onboarding::Onboarding; -use crate::profile::send_new_contact_list; +use crate::profile::{send_default_dms_relay_list, send_new_contact_list}; use crate::subscriptions::Subscriptions; use crate::ui::onboarding::{FollowPackOnboardingView, FollowPacksResponse, OnboardingResponse}; use crate::{ @@ -81,7 +80,7 @@ pub fn render_accounts_route( onboarding: &mut Onboarding, follow_packs_ui: &mut Nip51SetUiCache, route: AccountsRoute, -) -> BodyResponse<AccountsResponse> { +) -> DragResponse<AccountsResponse> { match route { AccountsRoute::Accounts => AccountsView::new( app_ctx.ndb, @@ -99,7 +98,7 @@ pub fn render_accounts_route( .inner .map(AccountsRouteResponse::AddAccount) .map(AccountsResponse::Account); - BodyResponse::output(action) + DragResponse::output(action) } AccountsRoute::Onboarding => FollowPackOnboardingView::new( onboarding, @@ -185,6 +184,7 @@ pub fn process_login_view_response( let kp = FullKeypair::generate(); send_new_contact_list(kp.to_filled(), app_ctx.ndb, app_ctx.pool, pks_to_follow); + send_default_dms_relay_list(kp.to_filled(), app_ctx.ndb, app_ctx.pool); cur_router.go_back(); onboarding.end_onboarding(app_ctx.pool, app_ctx.ndb); diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -17,12 +17,12 @@ use crate::{ Result, }; use egui_extras::{Size, StripBuilder}; -use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool}; +use enostr::{ClientMessage, Pubkey, RelayEvent, RelayMessage}; use nostrdb::Transaction; use notedeck::{ - tr, ui::is_narrow, Accounts, AppAction, AppContext, AppResponse, DataPath, DataPathType, - FilterState, Images, Localization, MediaJobSender, NotedeckOptions, SettingsHandler, - UnknownIds, + tr, try_process_events_core, ui::is_narrow, Accounts, AppAction, AppContext, AppResponse, + DataPath, DataPathType, FilterState, Images, Localization, MediaJobSender, NotedeckOptions, + SettingsHandler, }; use notedeck_ui::{ media::{MediaViewer, MediaViewerFlags, MediaViewerState}, @@ -30,7 +30,7 @@ use notedeck_ui::{ }; use std::collections::{BTreeSet, HashMap}; use std::path::Path; -use tracing::{debug, error, info, trace, warn}; +use tracing::{error, info, warn}; use uuid::Uuid; #[derive(Debug, Eq, PartialEq, Clone)] @@ -161,47 +161,22 @@ fn try_process_event( get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, &mut damus.decks_cache); ctx.input(|i| handle_egui_events(i, current_columns, damus.hovered_column)); - let ctx2 = ctx.clone(); - let wakeup = move || { - ctx2.request_repaint(); - }; - - app_ctx.pool.keepalive_ping(wakeup); - - // NOTE: we don't use the while let loop due to borrow issues - #[allow(clippy::while_let_loop)] - loop { - profiling::scope!("receiving events"); - let ev = if let Some(ev) = app_ctx.pool.try_recv() { - ev.into_owned() - } else { - break; - }; - - match (&ev.event).into() { - RelayEvent::Opened => { - app_ctx - .accounts - .send_initial_filters(app_ctx.pool, &ev.relay); - - timeline::send_initial_timeline_filters( - damus.options.contains(AppOptions::SinceOptimize), - &mut damus.timeline_cache, - &mut damus.subscriptions, - app_ctx.pool, - &ev.relay, - app_ctx.accounts, - ); - } - // TODO: handle reconnects - RelayEvent::Closed => warn!("{} connection closed", &ev.relay), - RelayEvent::Error(e) => error!("{}: {}", &ev.relay, e), - RelayEvent::Other(msg) => trace!("other event {:?}", &msg), - RelayEvent::Message(msg) => { - process_message(damus, app_ctx, &ev.relay, &msg); - } + try_process_events_core(app_ctx, ctx, |app_ctx, ev| match (&ev.event).into() { + RelayEvent::Opened => { + timeline::send_initial_timeline_filters( + damus.options.contains(AppOptions::SinceOptimize), + &mut damus.timeline_cache, + &mut damus.subscriptions, + app_ctx.pool, + &ev.relay, + app_ctx.accounts, + ); } - } + RelayEvent::Message(msg) => { + process_message(damus, app_ctx, &ev.relay, &msg); + } + _ => {} + }); for (kind, timeline) in &mut damus.timeline_cache { let is_ready = timeline::is_timeline_ready( @@ -239,25 +214,9 @@ fn try_process_event( follow_packs.poll_for_notes(app_ctx.ndb, app_ctx.unknown_ids); } - if app_ctx.unknown_ids.ready_to_send() { - unknown_id_send(app_ctx.unknown_ids, app_ctx.pool); - } - Ok(()) } -fn unknown_id_send(unknown_ids: &mut UnknownIds, pool: &mut RelayPool) { - debug!("unknown_id_send called on: {:?}", &unknown_ids); - let filter = unknown_ids.filter().expect("filter"); - debug!( - "Getting {} unknown ids from relays", - unknown_ids.ids_iter().len() - ); - let msg = ClientMessage::req("unknownids".to_string(), filter); - unknown_ids.clear(); - pool.send(&msg); -} - #[profiling::function] fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Context) { app_ctx.img_cache.urls.cache.handle_io(); @@ -381,53 +340,18 @@ fn handle_eose( } fn process_message(damus: &mut Damus, ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessage) { - match msg { - RelayMessage::Event(_subid, ev) => { - let relay = if let Some(relay) = ctx.pool.relays.iter().find(|r| r.url() == relay) { - relay - } else { - error!("couldn't find relay {} for note processing!?", relay); - return; - }; + let RelayMessage::Eose(sid) = msg else { + return; + }; - match relay { - PoolRelay::Websocket(_) => { - //info!("processing event {}", event); - if let Err(err) = ctx.ndb.process_event_with( - ev, - nostrdb::IngestMetadata::new() - .client(false) - .relay(relay.url()), - ) { - error!("error processing event {ev}: {err}"); - } - } - PoolRelay::Multicast(_) => { - // multicast events are client events - if let Err(err) = ctx.ndb.process_event_with( - ev, - nostrdb::IngestMetadata::new() - .client(true) - .relay(relay.url()), - ) { - error!("error processing multicast event {ev}: {err}"); - } - } - } - } - RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg), - RelayMessage::OK(cr) => info!("OK {:?}", cr), - RelayMessage::Eose(sid) => { - if let Err(err) = handle_eose( - &damus.subscriptions, - &mut damus.timeline_cache, - ctx, - sid, - relay, - ) { - error!("error handling eose: {}", err); - } - } + if let Err(err) = handle_eose( + &damus.subscriptions, + &mut damus.timeline_cache, + ctx, + sid, + relay, + ) { + error!("error handling eose: {}", err); } } diff --git a/crates/notedeck_columns/src/column.rs b/crates/notedeck_columns/src/column.rs @@ -1,6 +1,6 @@ use crate::{ actionbar::TimelineOpenResult, - route::{Route, Router, SingletonRouter}, + route::{ColumnsRouter, Route, SingletonRouter}, timeline::{Timeline, TimelineCache, TimelineKind}, }; use enostr::RelayPool; @@ -11,24 +11,24 @@ use tracing::warn; #[derive(Clone, Debug)] pub struct Column { - pub router: Router<Route>, + pub router: ColumnsRouter<Route>, pub sheet_router: SingletonRouter<Route>, } impl Column { pub fn new(routes: Vec<Route>) -> Self { - let router = Router::new(routes); + let router = ColumnsRouter::new(routes); Column { router, sheet_router: SingletonRouter::default(), } } - pub fn router(&self) -> &Router<Route> { + pub fn router(&self) -> &ColumnsRouter<Route> { &self.router } - pub fn router_mut(&mut self) -> &mut Router<Route> { + pub fn router_mut(&mut self) -> &mut ColumnsRouter<Route> { &mut self.router } } @@ -164,7 +164,7 @@ impl Columns { // Get the first router in the columns if there are columns present. // Otherwise, create a new column picker and return the router - pub fn get_selected_router(&mut self) -> &mut Router<Route> { + pub fn get_selected_router(&mut self) -> &mut ColumnsRouter<Route> { self.ensure_column(); self.selected_mut().router_mut() } diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -7,7 +7,7 @@ use crate::{ options::AppOptions, profile::{ProfileAction, SaveProfileChanges}, repost::RepostAction, - route::{Route, Router, SingletonRouter}, + route::{ColumnsRouter, Route, SingletonRouter}, subscriptions::Subscriptions, timeline::{ kind::ListKind, @@ -32,17 +32,18 @@ use crate::{ Damus, }; -use egui::scroll_area::ScrollAreaOutput; use egui_nav::{ Nav, NavAction, NavResponse, NavUiType, PopupResponse, PopupSheet, RouteResponse, Split, }; use enostr::{ProfileState, RelayPool}; use nostrdb::{Filter, Ndb, Transaction}; use notedeck::{ - get_current_default_msats, tr, ui::is_narrow, Accounts, AppContext, NoteAction, NoteCache, - NoteContext, RelayAction, + get_current_default_msats, nav::DragResponse, tr, ui::is_narrow, Accounts, AppContext, + NoteAction, NoteCache, NoteContext, RelayAction, +}; +use notedeck_ui::{ + contacts_list::ContactsCollection, ContactsListAction, ContactsListView, NoteOptions, }; -use notedeck_ui::NoteOptions; use tracing::error; /// The result of processing a nav response @@ -316,7 +317,7 @@ fn process_nav_resp( .columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut(); - cur_router.navigating = false; + cur_router.navigating_mut(false); if cur_router.is_replacing() { cur_router.remove_previous_routes(); } @@ -431,7 +432,7 @@ pub enum RouterType { Stack, } -fn go_back(stack: &mut Router<Route>, sheet: &mut SingletonRouter<Route>) { +fn go_back(stack: &mut ColumnsRouter<Route>, sheet: &mut SingletonRouter<Route>) { if sheet.route().is_some() { sheet.go_back(); } else { @@ -442,7 +443,7 @@ fn go_back(stack: &mut Router<Route>, sheet: &mut SingletonRouter<Route>) { impl RouterAction { pub fn process_router_action( self, - stack_router: &mut Router<Route>, + stack_router: &mut ColumnsRouter<Route>, sheet_router: &mut SingletonRouter<Route>, ) -> Option<ProcessNavResult> { match self { @@ -626,7 +627,7 @@ fn render_nav_body( depth: usize, col: usize, inner_rect: egui::Rect, -) -> BodyResponse<RenderNavAction> { +) -> DragResponse<RenderNavAction> { let mut note_context = NoteContext { ndb: ctx.ndb, accounts: ctx.accounts, @@ -723,7 +724,7 @@ fn render_nav_body( "Reply to unknown note", "Error message when reply note cannot be found" )); - return BodyResponse::none(); + return DragResponse::none(); }; let note = if let Ok(note) = ctx.ndb.get_note_by_id(&txn, id.bytes()) { @@ -734,11 +735,11 @@ fn render_nav_body( "Reply to unknown note", "Error message when reply note cannot be found" )); - return BodyResponse::none(); + return DragResponse::none(); }; let Some(poster) = ctx.accounts.selected_filled() else { - return BodyResponse::none(); + return DragResponse::none(); }; let resp = { @@ -772,11 +773,11 @@ fn render_nav_body( "Quote of unknown note", "Error message when quote note cannot be found" )); - return BodyResponse::none(); + return DragResponse::none(); }; let Some(poster) = ctx.accounts.selected_filled() else { - return BodyResponse::none(); + return DragResponse::none(); }; let draft = app.drafts.quote_mut(note.id()); @@ -796,13 +797,13 @@ fn render_nav_body( } Route::ComposeNote => { let Some(kp) = ctx.accounts.get_selected_account().key.to_full() else { - return BodyResponse::none(); + return DragResponse::none(); }; let navigating = get_active_columns_mut(note_context.i18n, ctx.accounts, &mut app.decks_cache) .column(col) .router() - .navigating; + .navigating(); let draft = app.drafts.compose_mut(); if navigating { @@ -827,11 +828,11 @@ fn render_nav_body( Route::AddColumn(route) => { render_add_column_routes(ui, app, ctx, col, route); - BodyResponse::none() + DragResponse::none() } Route::Support => { SupportView::new(&mut app.support, ctx.i18n).show(ui); - BodyResponse::none() + DragResponse::none() } Route::Search => { let id = ui.id().with(("search", depth, col)); @@ -839,7 +840,7 @@ fn render_nav_body( get_active_columns_mut(note_context.i18n, ctx.accounts, &mut app.decks_cache) .column(col) .router() - .navigating; + .navigating(); let search_buffer = app.view_state.searches.entry(id).or_default(); let txn = Transaction::new(ctx.ndb).expect("txn"); @@ -880,7 +881,7 @@ fn render_nav_body( .go_back(); } - BodyResponse::output(resp) + DragResponse::output(resp) } Route::EditDeck(index) => { let mut action = None; @@ -912,19 +913,19 @@ fn render_nav_body( .go_back(); } - BodyResponse::output(action) + DragResponse::output(action) } Route::EditProfile(pubkey) => { let Some(kp) = ctx.accounts.get_full(pubkey) else { error!("Pubkey in EditProfile route did not have an nsec attached in Accounts"); - return BodyResponse::none(); + return DragResponse::none(); }; let Some(state) = app.view_state.pubkey_to_profile_state.get_mut(kp.pubkey) else { tracing::error!( "No profile state when navigating to EditProfile... was handle_navigating_edit_profile not called?" ); - return BodyResponse::none(); + return DragResponse::none(); }; EditProfileView::new( @@ -1000,15 +1001,21 @@ fn render_nav_body( (txn, contacts) }; - crate::ui::profile::ContactsListView::new(pubkey, contacts, &mut note_context, &txn) - .ui(ui) - .map_output(|action| match action { - crate::ui::profile::ContactsListAction::OpenProfile(pk) => { - RenderNavAction::NoteAction(NoteAction::Profile(pk)) - } - }) + ContactsListView::new( + ContactsCollection::Vec(&contacts), + note_context.jobs, + note_context.ndb, + note_context.img_cache, + &txn, + ) + .ui(ui) + .map_output(|action| match action { + ContactsListAction::Select(pk) => { + RenderNavAction::NoteAction(NoteAction::Profile(pk)) + } + }) } - Route::FollowedBy(_pubkey) => BodyResponse::none(), + Route::FollowedBy(_pubkey) => DragResponse::none(), Route::Wallet(wallet_type) => { let state = match wallet_type { notedeck::WalletType::Auto => 's: { @@ -1054,13 +1061,13 @@ fn render_nav_body( } }; - BodyResponse::output(WalletView::new(state, ctx.i18n, ctx.clipboard).ui(ui)) + DragResponse::output(WalletView::new(state, ctx.i18n, ctx.clipboard).ui(ui)) .map_output(RenderNavAction::WalletAction) } Route::CustomizeZapAmount(target) => { let txn = Transaction::new(ctx.ndb).expect("txn"); let default_msats = get_current_default_msats(ctx.accounts, ctx.global_wallet); - BodyResponse::output( + DragResponse::output( CustomZapView::new( ctx.i18n, ctx.img_cache, @@ -1086,93 +1093,12 @@ fn render_nav_body( }) } Route::RepostDecision(note_id) => { - BodyResponse::output(RepostDecisionView::new(note_id).show(ui)) + DragResponse::output(RepostDecisionView::new(note_id).show(ui)) .map_output(RenderNavAction::RepostAction) } } } -pub struct BodyResponse<R> { - pub drag_id: Option<egui::Id>, // the id which was used for dragging. - pub output: Option<R>, -} - -impl<R> BodyResponse<R> { - pub fn none() -> Self { - Self { - drag_id: None, - output: None, - } - } - - pub fn scroll(output: ScrollAreaOutput<Option<R>>) -> Self { - Self { - drag_id: Some(Self::scroll_output_to_drag_id(output.id)), - output: output.inner, - } - } - - pub fn set_scroll_id(&mut self, output: &ScrollAreaOutput<Option<R>>) { - self.drag_id = Some(Self::scroll_output_to_drag_id(output.id)); - } - - pub fn output(output: Option<R>) -> Self { - Self { - drag_id: None, - output, - } - } - - pub fn set_output(&mut self, output: R) { - self.output = Some(output); - } - - /// The id of an `egui::ScrollAreaOutput` - /// Should use `Self::scroll` when possible - pub fn scroll_raw(mut self, id: egui::Id) -> Self { - self.drag_id = Some(Self::scroll_output_to_drag_id(id)); - self - } - - /// The id which is directly used for dragging - pub fn set_drag_id_raw(&mut self, id: egui::Id) { - self.drag_id = Some(id); - } - - fn scroll_output_to_drag_id(id: egui::Id) -> egui::Id { - id.with("area") - } - - pub fn map_output<S>(self, f: impl FnOnce(R) -> S) -> BodyResponse<S> { - BodyResponse { - drag_id: self.drag_id, - output: self.output.map(f), - } - } - - pub fn map_output_maybe<S>(self, f: impl FnOnce(R) -> Option<S>) -> BodyResponse<S> { - BodyResponse { - drag_id: self.drag_id, - output: self.output.and_then(f), - } - } - - pub fn maybe_map_output<S>(self, f: impl FnOnce(Option<R>) -> S) -> BodyResponse<S> { - BodyResponse { - drag_id: self.drag_id, - output: Some(f(self.output)), - } - } - - /// insert the contents of the new BodyResponse if they are empty in Self - pub fn insert(&mut self, body: BodyResponse<R>) { - self.drag_id = self.drag_id.or(body.drag_id); - if self.output.is_none() { - self.output = body.output; - } - } -} - #[must_use = "RenderNavResponse must be handled by calling .process_render_nav_response(..)"] #[profiling::function] pub fn render_nav( @@ -1246,13 +1172,13 @@ pub fn render_nav( app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() - .navigating, + .navigating(), ) .returning( app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() - .returning, + .returning(), ) .animate_transitions(ctx.settings.get_settings_mut().animate_nav_transitions) .show_mut(ui, |ui, render_type, nav| match render_type { @@ -1279,7 +1205,7 @@ pub fn render_nav( let resp = if let Some(top) = nav.routes().last() { render_nav_body(ui, app, ctx, top, nav.routes().len(), col, inner_rect) } else { - BodyResponse::none() + DragResponse::none() }; RouteResponse { diff --git a/crates/notedeck_columns/src/profile.rs b/crates/notedeck_columns/src/profile.rs @@ -42,6 +42,7 @@ pub enum ProfileAction { } impl ProfileAction { + #[allow(clippy::too_many_arguments)] pub fn process_profile_action( &self, app: &mut Damus, @@ -287,3 +288,24 @@ fn construct_new_contact_list<'a>(pks: Vec<Pubkey>) -> NoteBuilder<'a> { builder } + +pub fn send_default_dms_relay_list(kp: FilledKeypair<'_>, ndb: &Ndb, pool: &mut RelayPool) { + send_note_builder(construct_default_dms_relay_list(), ndb, pool, kp); +} + +fn construct_default_dms_relay_list<'a>() -> NoteBuilder<'a> { + let mut builder = NoteBuilder::new() + .content("") + .kind(10050) + .options(NoteBuildOptions::default()); + + for relay in default_dms_relays() { + builder = builder.start_tag().tag_str("relay").tag_str(relay); + } + + builder +} + +fn default_dms_relays() -> Vec<&'static str> { + vec!["wss://relay.damus.io", "wss://nos.lol"] +} diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs @@ -1,6 +1,8 @@ use egui_nav::Percent; use enostr::{NoteId, Pubkey}; -use notedeck::{tr, Localization, NoteZapTargetOwned, RootNoteIdBuf, WalletType}; +use notedeck::{ + tr, Localization, NoteZapTargetOwned, ReplacementType, RootNoteIdBuf, Router, WalletType, +}; use std::ops::Range; use crate::{ @@ -418,39 +420,30 @@ impl Route { // TODO: add this to egui-nav so we don't have to deal with returning // and navigating headaches #[derive(Clone, Debug)] -pub struct Router<R: Clone> { - routes: Vec<R>, - pub returning: bool, - pub navigating: bool, - replacing: bool, +pub struct ColumnsRouter<R: Clone> { + router_internal: Router<R>, forward_stack: Vec<R>, // An overlay captures a range of routes where only one will persist when going back, the most recent added overlay_ranges: Vec<Range<usize>>, } -impl<R: Clone> Router<R> { +impl<R: Clone> ColumnsRouter<R> { pub fn new(routes: Vec<R>) -> Self { if routes.is_empty() { panic!("routes can't be empty") } - let returning = false; - let navigating = false; - let replacing = false; - Router { - routes, - returning, - navigating, - replacing, + let router_internal = Router::new(routes); + ColumnsRouter { + router_internal, forward_stack: Vec::new(), overlay_ranges: Vec::new(), } } pub fn route_to(&mut self, route: R) { - self.navigating = true; + self.router_internal.route_to(route); self.forward_stack.clear(); - self.routes.push(route); } pub fn route_to_overlaid(&mut self, route: R) { @@ -465,17 +458,15 @@ impl<R: Clone> Router<R> { // Route to R. Then when it is successfully placed, should call `remove_previous_routes` to remove all previous routes pub fn route_to_replaced(&mut self, route: R) { - self.navigating = true; - self.replacing = true; - self.routes.push(route); + self.router_internal + .route_to_replaced(route, ReplacementType::All); } /// Go back, start the returning process pub fn go_back(&mut self) -> Option<R> { - if self.returning || self.routes.len() == 1 { + if self.router_internal.returning || self.router_internal.len() == 1 { return None; } - self.returning = true; if let Some(range) = self.overlay_ranges.pop() { tracing::debug!("Going back, found overlay: {:?}", range); @@ -484,17 +475,12 @@ impl<R: Clone> Router<R> { tracing::debug!("Going back, no overlay"); } - if self.routes.len() == 1 { - return None; - } - - self.prev().cloned() + self.router_internal.go_back() } pub fn go_forward(&mut self) -> bool { if let Some(route) = self.forward_stack.pop() { - self.navigating = true; - self.routes.push(route); + self.router_internal.route_to(route); true } else { false @@ -503,7 +489,7 @@ impl<R: Clone> Router<R> { /// Pop a route, should only be called on a NavRespose::Returned reseponse pub fn pop(&mut self) -> Option<R> { - if self.routes.len() == 1 { + if self.router_internal.len() == 1 { return None; } @@ -512,7 +498,7 @@ impl<R: Clone> Router<R> { break 's false; }; - if last_range.end != self.routes.len() { + if last_range.end != self.router_internal.len() { break 's false; } @@ -525,30 +511,20 @@ impl<R: Clone> Router<R> { true }; - self.returning = false; - let popped = self.routes.pop(); + let popped = self.router_internal.pop()?; if !is_overlay { - if let Some(ref route) = popped { - self.forward_stack.push(route.clone()); - } + self.forward_stack.push(popped.clone()); } - popped + Some(popped) } pub fn remove_previous_routes(&mut self) { - let num_routes = self.routes.len(); - if num_routes <= 1 { - return; - } - - self.returning = false; - self.replacing = false; - self.routes.drain(..num_routes - 1); + self.router_internal.complete_replacement(); } /// Removes all routes in the overlay besides the last fn remove_overlay(&mut self, overlay_range: Range<usize>) { - let num_routes = self.routes.len(); + let num_routes = self.router_internal.routes.len(); if num_routes <= 1 { return; } @@ -557,46 +533,63 @@ impl<R: Clone> Router<R> { return; } - self.routes + self.router_internal + .routes .drain(overlay_range.start..overlay_range.end - 1); } pub fn is_replacing(&self) -> bool { - self.replacing + self.router_internal.is_replacing() } fn set_overlaying(&mut self) { let mut overlaying_active = None; let mut binding = self.overlay_ranges.last_mut(); if let Some(range) = &mut binding { - if range.end == self.routes.len() - 1 { + if range.end == self.router_internal.len() - 1 { overlaying_active = Some(range); } }; if let Some(range) = overlaying_active { - range.end = self.routes.len(); + range.end = self.router_internal.len(); } else { - let new_range = self.routes.len() - 1..self.routes.len(); + let new_range = self.router_internal.len() - 1..self.router_internal.len(); self.overlay_ranges.push(new_range); } } fn new_overlay(&mut self) { - let new_range = self.routes.len() - 1..self.routes.len(); + let new_range = self.router_internal.len() - 1..self.router_internal.len(); self.overlay_ranges.push(new_range); } - pub fn top(&self) -> &R { - self.routes.last().expect("routes can't be empty") + pub fn routes(&self) -> &Vec<R> { + self.router_internal.routes() } - pub fn prev(&self) -> Option<&R> { - self.routes.get(self.routes.len() - 2) + pub fn navigating(&self) -> bool { + self.router_internal.navigating } - pub fn routes(&self) -> &Vec<R> { - &self.routes + pub fn navigating_mut(&mut self, new: bool) { + self.router_internal.navigating = new; + } + + pub fn returning(&self) -> bool { + self.router_internal.returning + } + + pub fn returning_mut(&mut self, new: bool) { + self.router_internal.returning = new; + } + + pub fn top(&self) -> &R { + self.router_internal.top() + } + + pub fn prev(&self) -> Option<&R> { + self.router_internal.prev() } } diff --git a/crates/notedeck_columns/src/timeline/route.rs b/crates/notedeck_columns/src/timeline/route.rs @@ -1,12 +1,12 @@ use crate::{ - nav::{BodyResponse, RenderNavAction}, + nav::RenderNavAction, profile::ProfileAction, timeline::{thread::Threads, ThreadSelection, TimelineCache, TimelineKind}, ui::{self, ProfileView}, }; use enostr::Pubkey; -use notedeck::NoteContext; +use notedeck::{DragResponse, NoteContext}; use notedeck_ui::NoteOptions; #[allow(clippy::too_many_arguments)] @@ -19,7 +19,7 @@ pub fn render_timeline_route( ui: &mut egui::Ui, note_context: &mut NoteContext, scroll_to_top: bool, -) -> BodyResponse<RenderNavAction> { +) -> DragResponse<RenderNavAction> { match kind { TimelineKind::List(_) | TimelineKind::Search(_) @@ -58,7 +58,7 @@ pub fn render_thread_route( mut note_options: NoteOptions, ui: &mut egui::Ui, note_context: &mut NoteContext, -) -> BodyResponse<RenderNavAction> { +) -> DragResponse<RenderNavAction> { // don't truncate thread notes for now, since they are // default truncated everywher eelse note_options.set(NoteOptions::Truncate, false); @@ -85,7 +85,7 @@ pub fn render_profile_route( ui: &mut egui::Ui, note_options: NoteOptions, note_context: &mut NoteContext, -) -> BodyResponse<RenderNavAction> { +) -> DragResponse<RenderNavAction> { let profile_view = ProfileView::new(pubkey, col, timeline_cache, note_options, note_context).ui(ui); diff --git a/crates/notedeck_columns/src/ui/accounts.rs b/crates/notedeck_columns/src/ui/accounts.rs @@ -3,14 +3,12 @@ use egui::{ }; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; -use notedeck::{tr, Accounts, Images, Localization, MediaJobSender}; +use notedeck::{tr, Accounts, DragResponse, Images, Localization, MediaJobSender}; use notedeck_ui::colors::PINK; use notedeck_ui::profile::preview::SimpleProfilePreview; use notedeck_ui::app_images; -use crate::nav::BodyResponse; - pub struct AccountsView<'a> { ndb: &'a Ndb, accounts: &'a Accounts, @@ -49,8 +47,8 @@ impl<'a> AccountsView<'a> { } } - pub fn ui(&mut self, ui: &mut Ui) -> BodyResponse<AccountsViewResponse> { - let mut out = BodyResponse::none(); + pub fn ui(&mut self, ui: &mut Ui) -> DragResponse<AccountsViewResponse> { + let mut out = DragResponse::none(); Frame::new().outer_margin(12.0).show(ui, |ui| { if let Some(resp) = Self::top_section_buttons_widget(ui, self.i18n).inner { out.set_output(resp); diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -9,12 +9,14 @@ use crate::{ ui::{self}, }; -use egui::{Margin, Response, RichText, Sense, Stroke, UiBuilder}; +use egui::UiBuilder; +use egui::{Margin, Response, RichText, Sense, Stroke}; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; use notedeck::tr; use notedeck::{Images, Localization, MediaJobSender, NotedeckTextStyle}; use notedeck_ui::app_images; +use notedeck_ui::header::chevron; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, ProfilePic, @@ -656,28 +658,6 @@ fn prev<R>(xs: &[R]) -> Option<&R> { xs.get(xs.len().checked_sub(2)?) } -fn chevron( - ui: &mut egui::Ui, - pad: f32, - size: egui::Vec2, - stroke: impl Into<Stroke>, -) -> egui::Response { - let (r, painter) = ui.allocate_painter(size, egui::Sense::click()); - - let min = r.rect.min; - let max = r.rect.max; - - let apex = egui::Pos2::new(min.x + pad, min.y + size.y / 2.0); - let top = egui::Pos2::new(max.x - pad, min.y + pad); - let bottom = egui::Pos2::new(max.x - pad, max.y - pad); - - let stroke = stroke.into(); - painter.line_segment([apex, top], stroke); - painter.line_segment([apex, bottom], stroke); - - r -} - fn grab_button() -> impl egui::Widget { |ui: &mut egui::Ui| -> egui::Response { let max_size = egui::vec2(20.0, 20.0); diff --git a/crates/notedeck_columns/src/ui/mentions_picker.rs b/crates/notedeck_columns/src/ui/mentions_picker.rs @@ -1,8 +1,8 @@ use egui::{vec2, FontId, Layout, Pos2, Rect, ScrollArea, UiBuilder, Vec2b}; use nostrdb::{Ndb, ProfileRecord, Transaction}; use notedeck::{ - fonts::get_font_size, name::get_display_name, profile::get_profile_url, Images, MediaJobSender, - NotedeckTextStyle, + fonts::get_font_size, name::get_display_name, profile::get_profile_url, DragResponse, Images, + MediaJobSender, NotedeckTextStyle, }; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, @@ -11,8 +11,6 @@ use notedeck_ui::{ }; use tracing::error; -use crate::nav::BodyResponse; - /// Displays user profiles for the user to pick from. /// Useful for manually typing a username and selecting the profile desired pub struct MentionPickerView<'a> { @@ -73,7 +71,7 @@ impl<'a> MentionPickerView<'a> { &mut self, rect: egui::Rect, ui: &mut egui::Ui, - ) -> BodyResponse<MentionPickerResponse> { + ) -> DragResponse<MentionPickerResponse> { let widget_id = ui.id().with("mention_results"); let area_resp = egui::Area::new(widget_id) .order(egui::Order::Foreground) @@ -114,7 +112,7 @@ impl<'a> MentionPickerView<'a> { .show(ui, |ui| Some(self.show(ui, width))); ui.advance_cursor_after_rect(rect); - BodyResponse::scroll(scroll_resp).map_output(|o| { + DragResponse::scroll(scroll_resp).map_output(|o| { if close_button_resp { MentionPickerResponse::DeleteMention } else { diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -1,6 +1,5 @@ use crate::draft::{Draft, Drafts, MentionHint}; use crate::media_upload::nostrbuild_nip96_upload; -use crate::nav::BodyResponse; use crate::post::{downcast_post_buffer, MentionType, NewPost}; use crate::ui::mentions_picker::MentionPickerView; use crate::ui::{self, Preview, PreviewConfig}; @@ -18,10 +17,10 @@ use notedeck::media::AnimationMode; #[cfg(target_os = "android")] use notedeck::platform::android::try_open_file_picker; use notedeck::platform::get_next_selected_file; -use notedeck::PixelDimensions; use notedeck::{ name::get_display_name, supported_mime_hosted_at_url, tr, Localization, NoteAction, NoteContext, }; +use notedeck::{DragResponse, PixelDimensions}; use notedeck_ui::{ app_images, context_menu::{input_context, PasteBehavior}, @@ -364,7 +363,7 @@ impl<'a, 'd> PostView<'a, 'd> { 12 } - pub fn ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { + pub fn ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> DragResponse<PostResponse> { let scroll_out = ScrollArea::vertical() .id_salt(PostView::scroll_id()) .show(ui, |ui| Some(self.ui_no_scroll(txn, ui))); @@ -373,7 +372,7 @@ impl<'a, 'd> PostView<'a, 'd> { if let Some(inner) = scroll_out.inner { inner // should override the PostView scroll for the mention scroll } else { - BodyResponse::none() + DragResponse::none() } .scroll_raw(scroll_id) } @@ -382,7 +381,7 @@ impl<'a, 'd> PostView<'a, 'd> { &mut self, txn: &Transaction, ui: &mut egui::Ui, - ) -> BodyResponse<PostResponse> { + ) -> DragResponse<PostResponse> { while let Some(selected_file) = get_next_selected_file() { match selected_file { Ok(selected_media) => { @@ -427,7 +426,7 @@ impl<'a, 'd> PostView<'a, 'd> { .inner } - fn input_ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { + fn input_ui(&mut self, txn: &Transaction, ui: &mut egui::Ui) -> DragResponse<PostResponse> { let edit_response = ui.horizontal(|ui| self.editbox(txn, ui)).inner; let note_response = if let PostType::Quote(id) = self.post_type { @@ -478,7 +477,7 @@ impl<'a, 'd> PostView<'a, 'd> { .and_then(|nr| nr.action.map(PostAction::QuotedNoteAction)) .or(post_action.map(PostAction::NewPostAction)); - let mut resp = BodyResponse::output(action); + let mut resp = DragResponse::output(action); if let Some(drag_id) = edit_response.mention_hints_drag_id { resp.set_drag_id_raw(drag_id); } diff --git a/crates/notedeck_columns/src/ui/note/quote_repost.rs b/crates/notedeck_columns/src/ui/note/quote_repost.rs @@ -1,13 +1,12 @@ use super::{PostResponse, PostType}; use crate::{ draft::Draft, - nav::BodyResponse, ui::{self}, }; use egui::ScrollArea; use enostr::{FilledKeypair, NoteId}; -use notedeck::NoteContext; +use notedeck::{DragResponse, NoteContext}; use notedeck_ui::NoteOptions; pub struct QuoteRepostView<'a, 'd> { @@ -50,7 +49,7 @@ impl<'a, 'd> QuoteRepostView<'a, 'd> { QuoteRepostView::id(col, note_id).with("scroll") } - pub fn show(&mut self, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { + pub fn show(&mut self, ui: &mut egui::Ui) -> DragResponse<PostResponse> { let scroll_out = ScrollArea::vertical() .id_salt(self.scroll_id) .show(ui, |ui| Some(self.show_internal(ui))); @@ -60,12 +59,12 @@ impl<'a, 'd> QuoteRepostView<'a, 'd> { if let Some(inner) = scroll_out.inner { inner } else { - BodyResponse::none() + DragResponse::none() } .scroll_raw(scroll_id) } - fn show_internal(&mut self, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { + fn show_internal(&mut self, ui: &mut egui::Ui) -> DragResponse<PostResponse> { let quoting_note_id = self.quoting_note.id(); let post_resp = ui::PostView::new( diff --git a/crates/notedeck_columns/src/ui/note/reply.rs b/crates/notedeck_columns/src/ui/note/reply.rs @@ -1,5 +1,4 @@ use crate::draft::Draft; -use crate::nav::BodyResponse; use crate::ui::{ self, note::{PostAction, PostResponse, PostType}, @@ -7,7 +6,7 @@ use crate::ui::{ use egui::{Rect, Response, ScrollArea, Ui}; use enostr::{FilledKeypair, NoteId}; -use notedeck::NoteContext; +use notedeck::{DragResponse, NoteContext}; use notedeck_ui::{NoteOptions, NoteView, ProfilePic}; pub struct PostReplyView<'a, 'd> { @@ -50,7 +49,7 @@ impl<'a, 'd> PostReplyView<'a, 'd> { PostReplyView::id(col, note_id).with("scroll") } - pub fn show(&mut self, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { + pub fn show(&mut self, ui: &mut egui::Ui) -> DragResponse<PostResponse> { let scroll_out = ScrollArea::vertical() .id_salt(self.scroll_id) .stick_to_bottom(true) @@ -60,13 +59,13 @@ impl<'a, 'd> PostReplyView<'a, 'd> { if let Some(inner) = scroll_out.inner { inner } else { - BodyResponse::none() + DragResponse::none() } .scroll_raw(scroll_id) } // no scroll - fn show_internal(&mut self, ui: &mut egui::Ui) -> BodyResponse<PostResponse> { + fn show_internal(&mut self, ui: &mut egui::Ui) -> DragResponse<PostResponse> { ui.vertical(|ui| { let avail_rect = ui.available_rect_before_wrap(); diff --git a/crates/notedeck_columns/src/ui/onboarding.rs b/crates/notedeck_columns/src/ui/onboarding.rs @@ -2,13 +2,13 @@ use std::mem; use egui::{Layout, ScrollArea}; use nostrdb::Ndb; -use notedeck::{tr, Images, Localization, MediaJobSender}; +use notedeck::{tr, DragResponse, Images, Localization, MediaJobSender}; use notedeck_ui::{ colors, nip51_set::{Nip51SetUiCache, Nip51SetWidget, Nip51SetWidgetAction, Nip51SetWidgetFlags}, }; -use crate::{nav::BodyResponse, onboarding::Onboarding, ui::widgets::styled_button}; +use crate::{onboarding::Onboarding, ui::widgets::styled_button}; /// Display Follow Packs for the user to choose from authors trusted by the Damus team pub struct FollowPackOnboardingView<'a> { @@ -53,9 +53,9 @@ impl<'a> FollowPackOnboardingView<'a> { egui::Id::new("follow_pack_onboarding") } - pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<OnboardingResponse> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<OnboardingResponse> { let Some(follow_pack_state) = self.onboarding.get_follow_packs() else { - return BodyResponse::output(Some(OnboardingResponse::FollowPacks( + return DragResponse::output(Some(OnboardingResponse::FollowPacks( FollowPacksResponse::NoFollowPacks, ))); }; @@ -110,6 +110,6 @@ impl<'a> FollowPackOnboardingView<'a> { } }); - BodyResponse::output(action).scroll_raw(scroll_out.id) + DragResponse::output(action).scroll_raw(scroll_out.id) } } diff --git a/crates/notedeck_columns/src/ui/profile/contacts_list.rs b/crates/notedeck_columns/src/ui/profile/contacts_list.rs @@ -1,98 +0,0 @@ -use egui::{RichText, Sense}; -use enostr::Pubkey; -use nostrdb::Transaction; -use notedeck::{name::get_display_name, profile::get_profile_url, NoteContext}; -use notedeck_ui::ProfilePic; - -use crate::nav::BodyResponse; - -pub struct ContactsListView<'a, 'd, 'txn> { - contacts: Vec<Pubkey>, - note_context: &'a mut NoteContext<'d>, - txn: &'txn Transaction, -} - -#[derive(Clone)] -pub enum ContactsListAction { - OpenProfile(Pubkey), -} - -impl<'a, 'd, 'txn> ContactsListView<'a, 'd, 'txn> { - pub fn new( - _pubkey: &'a Pubkey, - contacts: Vec<Pubkey>, - note_context: &'a mut NoteContext<'d>, - txn: &'txn Transaction, - ) -> Self { - ContactsListView { - contacts, - note_context, - txn, - } - } - - pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<ContactsListAction> { - let mut action = None; - - egui::ScrollArea::vertical().show(ui, |ui| { - let clip_rect = ui.clip_rect(); - - for contact_pubkey in &self.contacts { - let (rect, resp) = - ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), Sense::click()); - - if !clip_rect.intersects(rect) { - continue; - } - - let profile = self - .note_context - .ndb - .get_profile_by_pubkey(self.txn, contact_pubkey.bytes()) - .ok(); - - let display_name = get_display_name(profile.as_ref()); - let name_str = display_name.display_name.unwrap_or("Anonymous"); - let profile_url = get_profile_url(profile.as_ref()); - - let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); - - if resp.hovered() { - ui.painter() - .rect_filled(rect, 0.0, ui.visuals().widgets.hovered.weak_bg_fill); - } - - let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect)); - child_ui.horizontal(|ui| { - ui.add_space(16.0); - - ui.add( - &mut ProfilePic::new( - self.note_context.img_cache, - self.note_context.jobs, - profile_url, - ) - .size(48.0), - ); - - ui.add_space(12.0); - - ui.add( - egui::Label::new( - RichText::new(name_str) - .size(16.0) - .color(ui.visuals().text_color()), - ) - .selectable(false), - ); - }); - - if resp.clicked() { - action = Some(ContactsListAction::OpenProfile(*contact_pubkey)); - } - } - }); - - BodyResponse::output(action) - } -} diff --git a/crates/notedeck_columns/src/ui/profile/edit.rs b/crates/notedeck_columns/src/ui/profile/edit.rs @@ -3,14 +3,13 @@ use core::f32; use egui::{vec2, Button, CornerRadius, Layout, Margin, RichText, ScrollArea, TextEdit}; use egui_winit::clipboard::Clipboard; use enostr::ProfileState; +use notedeck::DragResponse; use notedeck::{ profile::unwrap_profile_url, tr, Images, Localization, MediaJobSender, NotedeckTextStyle, }; use notedeck_ui::context_menu::{input_context, PasteBehavior}; use notedeck_ui::{profile::banner, ProfilePic}; -use crate::nav::BodyResponse; - pub struct EditProfileView<'a> { state: &'a mut ProfileState, clipboard: &'a mut Clipboard, @@ -41,7 +40,7 @@ impl<'a> EditProfileView<'a> { } // return true to save - pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<bool> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<bool> { let scroll_out = ScrollArea::vertical() .id_salt(EditProfileView::scroll_id()) .stick_to_bottom(true) @@ -80,7 +79,7 @@ impl<'a> EditProfileView<'a> { Some(save) }); - BodyResponse::scroll(scroll_out) + DragResponse::scroll(scroll_out) } fn inner(&mut self, ui: &mut egui::Ui, padding: f32) { diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -1,18 +1,15 @@ -pub mod contacts_list; pub mod edit; -pub use contacts_list::{ContactsListAction, ContactsListView}; pub use edit::EditProfileView; use egui::{vec2, Color32, CornerRadius, Layout, Rect, RichText, ScrollArea, Sense, Stroke}; use enostr::Pubkey; use nostrdb::{ProfileRecord, Transaction}; -use notedeck::{tr, Localization, ProfileContext}; +use notedeck::{tr, DragResponse, Localization, ProfileContext}; use notedeck_ui::profile::{context::ProfileContextWidget, follow_button}; use robius_open::Uri; use tracing::error; use crate::{ - nav::BodyResponse, timeline::{TimelineCache, TimelineKind}, ui::timeline::{tabs_ui, TimelineTabView}, }; @@ -71,7 +68,7 @@ impl<'a, 'd> ProfileView<'a, 'd> { egui::Id::new(("profile_scroll", col_id, profile_pubkey)) } - pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<ProfileViewAction> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<ProfileViewAction> { let scroll_id = ProfileView::scroll_id(self.col_id, self.pubkey); let scroll_area = ScrollArea::vertical().id_salt(scroll_id).animated(false); @@ -79,7 +76,7 @@ impl<'a, 'd> ProfileView<'a, 'd> { .timeline_cache .get_mut(&TimelineKind::Profile(*self.pubkey)) else { - return BodyResponse::none(); + return DragResponse::none(); }; let output = scroll_area.show(ui, |ui| { @@ -137,7 +134,7 @@ impl<'a, 'd> ProfileView<'a, 'd> { // only allow front insert when the profile body is fully obstructed profile_timeline.enable_front_insert = output.inner.body_end_pos < ui.clip_rect().top(); - BodyResponse::output(output.inner.action).scroll_raw(output.id) + DragResponse::output(output.inner.action).scroll_raw(output.id) } } diff --git a/crates/notedeck_columns/src/ui/relay.rs b/crates/notedeck_columns/src/ui/relay.rs @@ -1,10 +1,9 @@ use std::collections::HashMap; -use crate::nav::BodyResponse; use crate::ui::{Preview, PreviewConfig}; use egui::{Align, Button, CornerRadius, Frame, Id, Layout, Margin, Rgba, RichText, Ui, Vec2}; use enostr::{RelayPool, RelayStatus}; -use notedeck::{tr, Localization, NotedeckTextStyle, RelayAction}; +use notedeck::{tr, DragResponse, Localization, NotedeckTextStyle, RelayAction}; use notedeck_ui::app_images; use notedeck_ui::{colors::PINK, padding}; use tracing::debug; @@ -18,7 +17,7 @@ pub struct RelayView<'a> { } impl RelayView<'_> { - pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<RelayAction> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<RelayAction> { let scroll_out = Frame::new() .inner_margin(Margin::symmetric(10, 0)) .show(ui, |ui| { @@ -53,7 +52,7 @@ impl RelayView<'_> { }) .inner; - BodyResponse::scroll(scroll_out) + DragResponse::scroll(scroll_out) } pub fn scroll_id() -> egui::Id { diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -3,15 +3,15 @@ use enostr::{NoteId, Pubkey}; use state::TypingType; use crate::{ - nav::BodyResponse, timeline::{TimelineTab, TimelineUnits}, ui::timeline::TimelineTabView, }; use egui_winit::clipboard::Clipboard; use nostrdb::{Filter, Ndb, ProfileRecord, Transaction}; use notedeck::{ - fonts::get_font_size, name::get_display_name, profile::get_profile_url, tr, tr_plural, Images, - Localization, MediaJobSender, NoteAction, NoteContext, NoteRef, NotedeckTextStyle, + fonts::get_font_size, name::get_display_name, profile::get_profile_url, tr, tr_plural, + DragResponse, Images, Localization, MediaJobSender, NoteAction, NoteContext, NoteRef, + NotedeckTextStyle, }; use notedeck_ui::{ @@ -50,7 +50,7 @@ impl<'a, 'd> SearchView<'a, 'd> { } } - pub fn show(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> { + pub fn show(&mut self, ui: &mut egui::Ui) -> DragResponse<NoteAction> { padding(8.0, ui, |ui| self.show_impl(ui)) .inner .map_output(|action| match action { @@ -59,7 +59,7 @@ impl<'a, 'd> SearchView<'a, 'd> { }) } - fn show_impl(&mut self, ui: &mut egui::Ui) -> BodyResponse<SearchViewAction> { + fn show_impl(&mut self, ui: &mut egui::Ui) -> DragResponse<SearchViewAction> { ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0); let search_resp = search_box( @@ -79,7 +79,7 @@ impl<'a, 'd> SearchView<'a, 'd> { ); let mut search_action = None; - let mut body_resp = BodyResponse::none(); + let mut body_resp = DragResponse::none(); match &self.query.state { SearchState::New | SearchState::Navigating @@ -345,7 +345,7 @@ impl<'a, 'd> SearchView<'a, 'd> { None } - fn show_search_results(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> { + fn show_search_results(&mut self, ui: &mut egui::Ui) -> DragResponse<NoteAction> { let scroll_out = egui::ScrollArea::vertical() .id_salt(SearchView::scroll_id()) .show(ui, |ui| { @@ -358,7 +358,7 @@ impl<'a, 'd> SearchView<'a, 'd> { .show(ui) }); - BodyResponse::scroll(scroll_out) + DragResponse::scroll(scroll_out) } pub fn scroll_id() -> egui::Id { diff --git a/crates/notedeck_columns/src/ui/settings.rs b/crates/notedeck_columns/src/ui/settings.rs @@ -6,7 +6,7 @@ use egui_extras::{Size, StripBuilder}; use enostr::NoteId; use nostrdb::Transaction; use notedeck::{ - tr, ui::richtext_small, Images, LanguageIdentifier, Localization, NoteContext, + tr, ui::richtext_small, DragResponse, Images, LanguageIdentifier, Localization, NoteContext, NotedeckTextStyle, Settings, SettingsHandler, DEFAULT_MAX_HASHTAGS_PER_NOTE, DEFAULT_NOTE_BODY_FONT_SIZE, }; @@ -15,11 +15,7 @@ use notedeck_ui::{ AnimationHelper, NoteOptions, NoteView, }; -use crate::{ - nav::{BodyResponse, RouterAction}, - ui::account_login_view::eye_button, - Damus, Route, -}; +use crate::{nav::RouterAction, ui::account_login_view::eye_button, Damus, Route}; const PREVIEW_NOTE_ID: &str = "note1edjc8ggj07hwv77g2405uh6j2jkk5aud22gktxrvc2wnre4vdwgqzlv2gw"; @@ -713,7 +709,7 @@ impl<'a> SettingsView<'a> { action } - pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<SettingsAction> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<SettingsAction> { let scroll_out = Frame::default() .inner_margin(Margin::symmetric(10, 10)) .show(ui, |ui| { @@ -749,7 +745,7 @@ impl<'a> SettingsView<'a> { }) .inner; - BodyResponse::scroll(scroll_out) + DragResponse::scroll(scroll_out) } } diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs @@ -6,8 +6,8 @@ use notedeck::{NoteAction, NoteContext}; use notedeck_ui::note::NoteResponse; use notedeck_ui::{NoteOptions, NoteView}; -use crate::nav::BodyResponse; use crate::timeline::thread::{NoteSeenFlags, ParentState, Threads}; +use notedeck::DragResponse; pub struct ThreadView<'a, 'd> { threads: &'a mut Threads, @@ -39,7 +39,7 @@ impl<'a, 'd> ThreadView<'a, 'd> { egui::Id::new(("threadscroll", selected_note_id, col)) } - pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<NoteAction> { let txn = Transaction::new(self.note_context.ndb).expect("txn"); let scroll_id = ThreadView::scroll_id(self.selected_note_id, self.col); @@ -69,7 +69,7 @@ impl<'a, 'd> ThreadView<'a, 'd> { *scroll_offset = output.state.offset.y; } - BodyResponse::output(resp).scroll_raw(out_id) + DragResponse::output(resp).scroll_raw(out_id) } fn notes(&mut self, ui: &mut egui::Ui, txn: &Transaction) -> Option<NoteAction> { diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -12,11 +12,11 @@ use notedeck_ui::{ProfilePic, ProfilePreview}; use std::f32::consts::PI; use tracing::{error, warn}; -use crate::nav::BodyResponse; use crate::timeline::{ CompositeType, CompositeUnit, NoteUnit, ReactionUnit, RepostUnit, TimelineCache, TimelineKind, TimelineTab, }; +use notedeck::DragResponse; use notedeck::{ note::root_note_id_from_selected_id, tr, Localization, NoteAction, NoteContext, ScrollInfo, }; @@ -54,7 +54,7 @@ impl<'a, 'd> TimelineView<'a, 'd> { } } - pub fn ui(&mut self, ui: &mut egui::Ui) -> BodyResponse<NoteAction> { + pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<NoteAction> { timeline_ui( ui, self.timeline_id, @@ -91,7 +91,7 @@ fn timeline_ui( note_context: &mut NoteContext, col: usize, scroll_to_top: bool, -) -> BodyResponse<NoteAction> { +) -> DragResponse<NoteAction> { //padding(4.0, ui, |ui| ui.heading("Notifications")); /* let font_id = egui::TextStyle::Body.resolve(ui.style()); @@ -100,7 +100,7 @@ fn timeline_ui( */ let Some(scroll_id) = TimelineView::scroll_id(timeline_cache, timeline_id, col) else { - return BodyResponse::none(); + return DragResponse::none(); }; { @@ -110,7 +110,7 @@ fn timeline_ui( error!("tried to render timeline in column, but timeline was missing"); // TODO (jb55): render error when timeline is missing? // this shouldn't happen... - return BodyResponse::none(); + return DragResponse::none(); }; timeline.selected_view = tabs_ui( @@ -211,7 +211,7 @@ fn timeline_ui( } }); - BodyResponse::output(action).scroll_raw(scroll_id) + DragResponse::output(action).scroll_raw(scroll_id) } fn goto_top_button(center: Pos2) -> impl egui::Widget { diff --git a/crates/notedeck_messages/Cargo.toml b/crates/notedeck_messages/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "notedeck_messages" +version = { workspace = true } +edition = "2021" +description = "The direct messages app that ships with Notedeck" +license = "GPL-3.0-or-later" + +[dependencies] +egui = { workspace = true } +egui_extras = { workspace = true } +egui_virtual_list = { workspace = true } +enostr = { workspace = true } +hashbrown = { workspace = true } +notedeck = { workspace = true } +nostrdb = { workspace = true } +profiling = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +tempfile = { workspace = true } +notedeck_ui = { workspace = true } +chrono = { workspace = true } +nostr = { workspace = true } +egui_nav = { workspace = true } diff --git a/crates/notedeck_messages/src/cache/conversation.rs b/crates/notedeck_messages/src/cache/conversation.rs @@ -0,0 +1,352 @@ +use std::cmp::Ordering; + +use crate::{ + cache::{ + message_store::NotePkg, + registry::{ + ConversationId, ConversationIdentifierUnowned, ConversationRegistry, + ParticipantSetUnowned, + }, + }, + convo_renderable::ConversationRenderable, + nip17::{chatroom_filter, conversation_filter, get_participants}, +}; + +use super::message_store::MessageStore; +use enostr::Pubkey; +use hashbrown::HashMap; +use nostrdb::{Ndb, Note, NoteKey, QueryResult, Subscription, Transaction}; +use notedeck::{note::event_tag, NoteCache, NoteRef, UnknownIds}; + +pub struct ConversationCache { + pub registry: ConversationRegistry, + conversations: HashMap<ConversationId, Conversation>, + order: Vec<ConversationOrder>, + pub state: ConversationListState, + pub active: Option<ConversationId>, +} + +impl ConversationCache { + pub fn new() -> Self { + Self::default() + } + + pub fn len(&self) -> usize { + self.conversations.len() + } + + pub fn is_empty(&self) -> bool { + self.conversations.is_empty() + } + + pub fn get(&self, id: ConversationId) -> Option<&Conversation> { + self.conversations.get(&id) + } + + pub fn get_id_by_index(&self, i: usize) -> Option<&ConversationId> { + Some(&self.order.get(i)?.id) + } + + pub fn get_active(&self) -> Option<&Conversation> { + self.conversations.get(&self.active?) + } + + /// A conversation is "opened" when the user navigates to the conversation + #[profiling::function] + pub fn open_conversation( + &mut self, + ndb: &Ndb, + txn: &Transaction, + id: ConversationId, + note_cache: &mut NoteCache, + unknown_ids: &mut UnknownIds, + selected: &Pubkey, + ) { + let Some(conversation) = self.conversations.get_mut(&id) else { + return; + }; + + let pubkeys = conversation.metadata.participants.clone(); + let participants: Vec<&[u8; 32]> = pubkeys.iter().map(|p| p.bytes()).collect(); + + // We should try and get more messages... this isn't ideal + let chatroom_filter = chatroom_filter(participants, selected); + let results = match ndb.query(txn, &chatroom_filter, 500) { + Ok(r) => r, + Err(e) => { + tracing::error!("problem with chatroom filter ndb::query: {e:?}"); + return; + } + }; + + let mut updated = false; + for res in results { + let participants = get_participants(&res.note); + let parts = ParticipantSetUnowned::new(participants); + let cur_id = self + .registry + .get_or_insert(ConversationIdentifierUnowned::Nip17(parts)); + + if cur_id != id { + // this note isn't relevant to the current conversation, unfortunately... + continue; + } + + UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, &res.note); + updated |= conversation.ingest_kind_14(res.note, res.note_key); + } + + if updated { + let latest = conversation.last_activity(); + refresh_order(&mut self.order, id, LatestMessage::Latest(latest)); + } + + self.active = Some(id); + tracing::info!("Set active to {id}"); + } + + pub fn init_conversations( + &mut self, + ndb: &Ndb, + txn: &Transaction, + cur_acc: &Pubkey, + note_cache: &mut NoteCache, + unknown_ids: &mut UnknownIds, + ) { + let Some(results) = get_conversations(ndb, txn, cur_acc) else { + tracing::warn!("Got no conversations from ndb"); + return; + }; + + tracing::trace!("Received {} conversations from ndb", results.len()); + + for res in results { + self.ingest_chatroom_msg(res.note, res.note_key, ndb, txn, note_cache, unknown_ids); + } + } + + pub fn ingest_chatroom_msg( + &mut self, + note: Note, + key: NoteKey, + ndb: &Ndb, + txn: &Transaction, + note_cache: &mut NoteCache, + unknown_ids: &mut UnknownIds, + ) { + let participants = get_participants(&note); + + let id = self + .registry + .get_or_insert(ConversationIdentifierUnowned::Nip17( + ParticipantSetUnowned::new(participants.clone()), + )); + + let conversation = self.conversations.entry(id).or_insert_with(|| { + let participants: Vec<Pubkey> = + participants.into_iter().map(|p| Pubkey::new(*p)).collect(); + + Conversation::new(id, participants) + }); + + tracing::trace!("ingesting into conversation id {id}: {:?}", note.json()); + UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, &note); + if conversation.ingest_kind_14(note, key) { + let latest = conversation.last_activity(); + refresh_order(&mut self.order, id, LatestMessage::Latest(latest)); + } + } + + pub fn initialize_conversation(&mut self, id: ConversationId, participants: Vec<Pubkey>) { + if self.conversations.contains_key(&id) { + return; + } + + self.conversations + .insert(id, Conversation::new(id, participants)); + + refresh_order(&mut self.order, id, LatestMessage::NoMessages); + } + + pub fn first_convo_id(&self) -> Option<ConversationId> { + Some(self.order.first()?.id) + } +} + +fn refresh_order(order: &mut Vec<ConversationOrder>, id: ConversationId, latest: LatestMessage) { + if let Some(pos) = order.iter().position(|entry| entry.id == id) { + order.remove(pos); + } + + let entry = ConversationOrder { id, latest }; + let idx = match order.binary_search(&entry) { + Ok(idx) | Err(idx) => idx, + }; + order.insert(idx, entry); +} + +#[derive(Clone, Copy, Debug)] +struct ConversationOrder { + id: ConversationId, + latest: LatestMessage, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LatestMessage { + NoMessages, + Latest(u64), +} + +impl PartialOrd for LatestMessage { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +impl Ord for LatestMessage { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (LatestMessage::Latest(a), LatestMessage::Latest(b)) => a.cmp(b), + (LatestMessage::NoMessages, LatestMessage::NoMessages) => Ordering::Equal, + (LatestMessage::NoMessages, _) => Ordering::Greater, + (_, LatestMessage::NoMessages) => Ordering::Less, + } + } +} + +impl PartialEq for ConversationOrder { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for ConversationOrder {} + +impl PartialOrd for ConversationOrder { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +impl Ord for ConversationOrder { + fn cmp(&self, other: &Self) -> Ordering { + // newer first + match other.latest.cmp(&self.latest) { + Ordering::Equal => self.id.cmp(&other.id), + non_eq => non_eq, + } + } +} + +pub struct Conversation { + pub id: ConversationId, + pub messages: MessageStore, + pub metadata: ConversationMetadata, + pub renderable: ConversationRenderable, +} + +impl Conversation { + pub fn new(id: ConversationId, participants: Vec<Pubkey>) -> Self { + Self { + id, + messages: MessageStore::default(), + metadata: ConversationMetadata::new(participants), + renderable: ConversationRenderable::new(&[]), + } + } + + fn last_activity(&self) -> u64 { + self.messages.newest_timestamp().unwrap_or(0) + } + + pub fn ingest_kind_14(&mut self, note: Note, key: NoteKey) -> bool { + if note.kind() != 14 { + tracing::error!("tried to ingest a non-kind 14 note..."); + return false; + } + + if let Some(title) = event_tag(&note, "subject") { + let created = note.created_at(); + + if self + .metadata + .title + .as_ref() + .is_none_or(|cur| created > cur.last_modified) + { + self.metadata.title = Some(TitleMetadata { + title: title.to_string(), + last_modified: created, + }); + } + } + + let inserted = self.messages.insert(NotePkg { + note_ref: NoteRef { + key, + created_at: note.created_at(), + }, + author: Pubkey::new(*note.pubkey()), + }); + + if inserted { + self.renderable = ConversationRenderable::new(&self.messages.messages_ordered); + } + + inserted + } +} + +impl Default for ConversationCache { + fn default() -> Self { + Self { + registry: ConversationRegistry::default(), + conversations: HashMap::new(), + order: Vec::new(), + state: Default::default(), + active: None, + } + } +} + +fn get_conversations<'a>( + ndb: &Ndb, + txn: &'a Transaction, + cur_acc: &Pubkey, +) -> Option<Vec<QueryResult<'a>>> { + match ndb.query(txn, &conversation_filter(cur_acc), 500) { + Ok(r) => Some(r), + Err(e) => { + tracing::error!("error fetching kind 14 messages: {e}"); + None + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct ConversationMetadata { + pub title: Option<TitleMetadata>, + pub participants: Vec<Pubkey>, +} + +#[derive(Clone, Debug)] +pub struct TitleMetadata { + pub title: String, + pub last_modified: u64, +} + +impl ConversationMetadata { + pub fn new(participants: Vec<Pubkey>) -> Self { + Self { + title: None, + participants, + } + } +} + +#[derive(Default)] +pub enum ConversationListState { + #[default] + Initializing, + Initialized(Option<Subscription>), // conversation list filter +} diff --git a/crates/notedeck_messages/src/cache/message_store.rs b/crates/notedeck_messages/src/cache/message_store.rs @@ -0,0 +1,86 @@ +use enostr::Pubkey; +use hashbrown::HashSet; +use nostrdb::NoteKey; +use notedeck::NoteRef; +use std::cmp::Ordering; + +/// Maintains a strictly ordered list of message references for a single +/// conversation. It mirrors the lightweight ordering guarantees that +/// `TimelineCache` and `Threads` rely on so UI code can assume the +/// backing data is already sorted from newest to oldest. +#[derive(Default)] +pub struct MessageStore { + pub messages_ordered: Vec<NotePkg>, + seen: HashSet<NoteKey>, +} + +impl MessageStore { + pub fn new() -> Self { + Self::default() + } + + /// Inserts a new `NoteRef` while keeping the store sorted. Returns + /// `true` when the reference was new to the conversation. + pub fn insert(&mut self, note: NotePkg) -> bool { + if !self.seen.insert(note.note_ref.key) { + return false; + } + + match self.messages_ordered.binary_search(&note) { + Ok(_) => { + debug_assert!( + false, + "MessageStore::insert was asked to insert a duplicate NoteRef" + ); + false + } + Err(idx) => { + self.messages_ordered.insert(idx, note); + true + } + } + } + + pub fn is_empty(&self) -> bool { + self.messages_ordered.is_empty() + } + + pub fn len(&self) -> usize { + self.messages_ordered.len() + } + + pub fn latest(&self) -> Option<&NoteRef> { + self.messages_ordered.first().map(|p| &p.note_ref) + } + + pub fn newest_timestamp(&self) -> Option<u64> { + self.latest().map(|n| n.created_at) + } +} + +pub struct NotePkg { + pub note_ref: NoteRef, + pub author: Pubkey, +} + +impl Ord for NotePkg { + fn cmp(&self, other: &Self) -> Ordering { + self.note_ref + .cmp(&other.note_ref) + .then_with(|| self.author.cmp(&other.author)) + } +} + +impl PartialOrd for NotePkg { + fn partial_cmp(&self, other: &Self) -> Option<Ordering> { + Some(self.cmp(other)) + } +} + +impl PartialEq for NotePkg { + fn eq(&self, other: &Self) -> bool { + self.note_ref == other.note_ref && self.author == other.author + } +} + +impl Eq for NotePkg {} diff --git a/crates/notedeck_messages/src/cache/mod.rs b/crates/notedeck_messages/src/cache/mod.rs @@ -0,0 +1,13 @@ +mod conversation; +mod message_store; +mod registry; +mod state; + +pub use conversation::{ + Conversation, ConversationCache, ConversationListState, ConversationMetadata, +}; +pub use message_store::{MessageStore, NotePkg}; +pub use registry::{ + ConversationId, ConversationIdentifier, ConversationIdentifierUnowned, ParticipantSetUnowned, +}; +pub use state::{ConversationState, ConversationStates}; diff --git a/crates/notedeck_messages/src/cache/registry.rs b/crates/notedeck_messages/src/cache/registry.rs @@ -0,0 +1,146 @@ +use enostr::Pubkey; +use hashbrown::{hash_map::RawEntryMut, HashMap}; +use std::{ + fmt::Debug, + hash::{BuildHasher, Hash}, +}; + +pub type ConversationId = u32; + +#[derive(Default)] +pub struct ConversationRegistry { + next_id: ConversationId, + conversation_ids: HashMap<ConversationIdentifier, ConversationId>, +} + +impl ConversationRegistry { + pub fn get(&self, id: ConversationIdentifierUnowned) -> Option<ConversationId> { + let hash = id.hash(self.conversation_ids.hasher()); + self.conversation_ids + .raw_entry() + .from_hash(hash, |existing| id.matches(existing)) + .map(|(_, v)| *v) + } + + pub fn get_or_insert(&mut self, id: ConversationIdentifierUnowned) -> ConversationId { + let hash = id.hash(self.conversation_ids.hasher()); + let id_c = id.clone(); + + let uid = match self + .conversation_ids + .raw_entry_mut() + .from_hash(hash, |existing| id.matches(existing)) + { + RawEntryMut::Occupied(entry) => *entry.get(), + RawEntryMut::Vacant(entry) => { + let owned = id.into_owned(); + let uid = self.next_id; + entry.insert(owned, uid); + self.next_id = self.next_id.wrapping_add(1); + uid + } + }; + tracing::info!("normalized conversation id: {id_c:?} | uid: {uid}"); + uid + } + + pub fn insert(&mut self, id: ConversationIdentifier) -> ConversationId { + let uid = self.next_id; + self.conversation_ids.insert(id, uid); + self.next_id = self.next_id.wrapping_add(1); + + uid + } +} + +#[derive(Hash, Eq, PartialEq, Debug, Clone)] +pub enum ConversationIdentifier { + Nip17(ParticipantSet), +} + +#[derive(Debug, Clone)] +pub enum ConversationIdentifierUnowned<'a> { + Nip17(ParticipantSetUnowned<'a>), +} + +// Set of Pubkeys, sorted and deduplicated +#[derive(Hash, Eq, PartialEq, Debug, Clone)] +pub struct ParticipantSet(Vec<[u8; 32]>); + +impl ParticipantSet { + pub fn new(mut items: Vec<[u8; 32]>) -> Self { + items.sort(); + items.dedup(); + Self(items) + } +} + +#[derive(Clone)] +pub struct ParticipantSetUnowned<'a>(Vec<&'a [u8; 32]>); + +impl<'a> ParticipantSetUnowned<'a> { + pub fn new(mut items: Vec<&'a [u8; 32]>) -> Self { + items.sort(); + items.dedup(); + Self(items) + } + + pub fn normalize(&mut self) { + self.0.sort_unstable(); + self.0.dedup(); + } + + fn hash_with<S: BuildHasher>(&self, build_hasher: &S) -> u64 { + build_hasher.hash_one(&self.0) + } + + fn matches(&self, owned: &ParticipantSet) -> bool { + if self.0.len() != owned.0.len() { + return false; + } + + self.0 + .iter() + .zip(&owned.0) + .all(|(left, right)| *left == right) + } + + fn into_owned(self) -> ParticipantSet { + let owned = self.0.into_iter().copied().collect(); + ParticipantSet::new(owned) + } +} + +impl<'a> Debug for ParticipantSetUnowned<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let hexes: Vec<String> = self + .0 + .iter() + .map(|bytes| Pubkey::new(**bytes).hex()) + .collect(); + + f.debug_tuple("ConversationParticipantsUnowned") + .field(&hexes) + .finish() + } +} + +impl<'a> ConversationIdentifierUnowned<'a> { + fn hash<S: BuildHasher>(&self, build_hasher: &S) -> u64 { + match self { + Self::Nip17(participants) => participants.hash_with(build_hasher), + } + } + + fn matches(&self, owned: &ConversationIdentifier) -> bool { + match (self, owned) { + (Self::Nip17(left), ConversationIdentifier::Nip17(right)) => left.matches(right), + } + } + + fn into_owned(self) -> ConversationIdentifier { + match self { + Self::Nip17(participants) => ConversationIdentifier::Nip17(participants.into_owned()), + } + } +} diff --git a/crates/notedeck_messages/src/cache/state.rs b/crates/notedeck_messages/src/cache/state.rs @@ -0,0 +1,33 @@ +use std::collections::HashMap; + +use crate::cache::ConversationId; +use egui_virtual_list::VirtualList; +use notedeck::NoteRef; + +/// Keep track of the UI state for conversations. Meant to be mutably accessed by UI +#[derive(Default)] +pub struct ConversationStates { + pub cache: HashMap<ConversationId, ConversationState>, + pub convos_list: VirtualList, +} + +impl ConversationStates { + pub fn new() -> Self { + let mut convos_list = VirtualList::new(); + convos_list.hide_on_resize(None); + Self { + cache: Default::default(), + convos_list, + } + } + pub fn get_or_insert(&mut self, id: ConversationId) -> &mut ConversationState { + self.cache.entry(id).or_default() + } +} + +#[derive(Default)] +pub struct ConversationState { + pub list: VirtualList, + pub last_read: Option<NoteRef>, + pub composer: String, +} diff --git a/crates/notedeck_messages/src/convo_renderable.rs b/crates/notedeck_messages/src/convo_renderable.rs @@ -0,0 +1,194 @@ +use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, Utc}; +use nostrdb::NoteKey; + +use crate::cache::NotePkg; + +pub struct ConversationRenderable { + items: Vec<ConversationItem>, +} + +#[derive(Debug)] +pub enum ConversationItem { + Date(NaiveDate), + Message { msg_type: MessageType, key: NoteKey }, +} + +#[derive(PartialEq, Copy, Clone, Debug)] +pub enum MessageType { + Standalone, + FirstInSeries, + MiddleInSeries, + LastInSeries, +} + +impl ConversationRenderable { + pub fn new(ordered_msgs: &[NotePkg]) -> Self { + Self { + items: generate_conversation_renderable(ordered_msgs), + } + } + + pub fn get(&self, index: usize) -> Option<&ConversationItem> { + self.items.get(index) + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +// ordered_msgs ordered from newest to oldest. Need it ordered oldest to newest +fn generate_conversation_renderable(ordered_msgs: &[NotePkg]) -> Vec<ConversationItem> { + let mut items = Vec::with_capacity(ordered_msgs.len()); + let midnight_anchor = local_midnight_anchor(); + + let mut iter = ordered_msgs.iter().rev(); + + let Some(mut prev) = iter.next() else { + return vec![]; + }; + + let mut prev_anchor_dist = days_since_anchor(&midnight_anchor, prev.note_ref.created_at); + + items.push(ConversationItem::Date(prev_anchor_dist.dt)); + + let Some(mut cur) = iter.next() else { + items.push(ConversationItem::Message { + msg_type: cur_message_type( + None, + AnchoredPkg { + pkg: prev, + distance: &prev_anchor_dist, + }, + None, + ), + key: prev.note_ref.key, + }); + return items; + }; + + let mut cur_anchor_dist = days_since_anchor(&midnight_anchor, cur.note_ref.created_at); + items.push(ConversationItem::Message { + msg_type: cur_message_type( + None, + AnchoredPkg { + pkg: prev, + distance: &prev_anchor_dist, + }, + Some(AnchoredPkg { + pkg: cur, + distance: &cur_anchor_dist, + }), + ), + key: prev.note_ref.key, + }); + + for next in iter { + if prev_anchor_dist.days_from_anchor != cur_anchor_dist.days_from_anchor { + items.push(ConversationItem::Date(cur_anchor_dist.dt)); + } + + let next_anchor_dist = days_since_anchor(&midnight_anchor, next.note_ref.created_at); + items.push(ConversationItem::Message { + msg_type: cur_message_type( + Some(AnchoredPkg { + pkg: prev, + distance: &prev_anchor_dist, + }), + AnchoredPkg { + pkg: cur, + distance: &cur_anchor_dist, + }, + Some(AnchoredPkg { + pkg: next, + distance: &next_anchor_dist, + }), + ), + key: cur.note_ref.key, + }); + + prev = cur; + prev_anchor_dist = cur_anchor_dist; + cur = next; + cur_anchor_dist = next_anchor_dist; + } + + if prev_anchor_dist.days_from_anchor != cur_anchor_dist.days_from_anchor { + items.push(ConversationItem::Date(cur_anchor_dist.dt)); + } + + items.push(ConversationItem::Message { + msg_type: cur_message_type( + Some(AnchoredPkg { + pkg: prev, + distance: &prev_anchor_dist, + }), + AnchoredPkg { + pkg: cur, + distance: &cur_anchor_dist, + }, + None, + ), + key: cur.note_ref.key, + }); + + items +} + +struct AnchoredPkg<'a> { + pkg: &'a NotePkg, + distance: &'a AnchorDistance, +} + +static GROUPING_SECS: u64 = 60; +fn cur_message_type( + prev: Option<AnchoredPkg>, + cur: AnchoredPkg, + next: Option<AnchoredPkg>, +) -> MessageType { + let prev_link = prev.as_ref().is_some_and(|p| series_between(&cur, p)); + let next_link = next.as_ref().is_some_and(|n| series_between(&cur, n)); + + match (prev_link, next_link) { + (false, false) => MessageType::Standalone, + (false, true) => MessageType::FirstInSeries, + (true, false) => MessageType::LastInSeries, + (true, true) => MessageType::MiddleInSeries, + } +} + +fn series_between(a: &AnchoredPkg, b: &AnchoredPkg) -> bool { + a.distance.days_from_anchor == b.distance.days_from_anchor + && a.pkg.author == b.pkg.author + && a.distance.unix_ts.abs_diff(b.distance.unix_ts) < GROUPING_SECS +} + +fn local_midnight_anchor() -> NaiveDateTime { + let epoch_utc = DateTime::<Utc>::UNIX_EPOCH; + let epoch_local = epoch_utc.with_timezone(&Local); + + epoch_local.date_naive().and_hms_opt(0, 0, 0).unwrap() +} + +fn days_since_anchor(anchor: &NaiveDateTime, timestamp: u64) -> AnchorDistance { + let dt = DateTime::from_timestamp(timestamp as i64, 0) + .unwrap() + .with_timezone(&Local) + .naive_local(); + + AnchorDistance { + days_from_anchor: anchor.signed_duration_since(dt).num_days() as u64, + dt: dt.date(), + unix_ts: timestamp, + } +} + +struct AnchorDistance { + days_from_anchor: u64, // distance in anchor, in days + unix_ts: u64, + dt: NaiveDate, +} diff --git a/crates/notedeck_messages/src/lib.rs b/crates/notedeck_messages/src/lib.rs @@ -0,0 +1,170 @@ +pub mod cache; +pub mod convo_renderable; +pub mod nav; +pub mod nip17; +pub mod ui; + +use enostr::Pubkey; +use hashbrown::HashMap; +use nav::{process_messages_ui_response, Route}; +use nostrdb::{Subscription, Transaction}; +use notedeck::{ + try_process_events_core, ui::is_narrow, Accounts, App, AppContext, AppResponse, Router, +}; + +use crate::{ + cache::{ConversationCache, ConversationListState, ConversationStates}, + nip17::conversation_filter, + ui::{login_nsec_prompt, messages::messages_ui}, +}; + +pub struct MessagesApp { + messages: ConversationsCtx, + states: ConversationStates, + router: Router<Route>, +} + +impl MessagesApp { + pub fn new() -> Self { + Self { + messages: ConversationsCtx::default(), + states: ConversationStates::default(), + router: Router::new(vec![Route::ConvoList]), + } + } +} + +impl Default for MessagesApp { + fn default() -> Self { + Self::new() + } +} + +impl App for MessagesApp { + fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { + try_process_events_core(ctx, ui.ctx(), |_, _| {}); + + let Some(cache) = self.messages.get_current_mut(ctx.accounts) else { + login_nsec_prompt(ui, ctx.i18n); + return AppResponse::none(); + }; + + 's: { + let Some(secret) = &ctx.accounts.get_selected_account().key.secret_key else { + break 's; + }; + + ctx.ndb.add_key(&secret.secret_bytes()); + let txn = Transaction::new(ctx.ndb).expect("txn"); + ctx.ndb.process_giftwraps(&txn); + } + + match cache.state { + ConversationListState::Initializing => initialize(ctx, cache, is_narrow(ui.ctx())), + ConversationListState::Initialized(subscription) => 's: { + let Some(sub) = subscription else { + break 's; + }; + update_initialized(ctx, cache, sub); + } + } + + let selected_pubkey = ctx.accounts.selected_account_pubkey(); + + let contacts_state = ctx + .accounts + .get_selected_account() + .data + .contacts + .get_state(); + let resp = messages_ui( + cache, + &mut self.states, + ctx.media_jobs.sender(), + ctx.ndb, + selected_pubkey, + ui, + ctx.img_cache, + &self.router, + ctx.settings.get_settings_mut(), + contacts_state, + ctx.i18n, + ); + let action = + process_messages_ui_response(resp, ctx, cache, &mut self.router, is_narrow(ui.ctx())); + + AppResponse::action(action) + } +} + +fn initialize(ctx: &mut AppContext, cache: &mut ConversationCache, is_narrow: bool) { + let txn = Transaction::new(ctx.ndb).expect("txn"); + cache.init_conversations( + ctx.ndb, + &txn, + ctx.accounts.selected_account_pubkey(), + &mut *ctx.note_cache, + &mut *ctx.unknown_ids, + ); + if !is_narrow { + if let Some(first) = cache.first_convo_id() { + cache.open_conversation( + ctx.ndb, + &txn, + first, + ctx.note_cache, + ctx.unknown_ids, + ctx.accounts.selected_account_pubkey(), + ); + } + } + let sub = match ctx + .ndb + .subscribe(&conversation_filter(ctx.accounts.selected_account_pubkey())) + { + Ok(sub) => Some(sub), + Err(e) => { + tracing::error!("couldn't sub ndb: {e}"); + None + } + }; + + cache.state = ConversationListState::Initialized(sub); +} + +fn update_initialized(ctx: &mut AppContext, cache: &mut ConversationCache, sub: Subscription) { + let notes = ctx.ndb.poll_for_notes(sub, 10); + let txn = Transaction::new(ctx.ndb).expect("txn"); + for key in notes { + let note = match ctx.ndb.get_note_by_key(&txn, key) { + Ok(n) => n, + Err(e) => { + tracing::error!("could not find note key: {e}"); + continue; + } + }; + cache.ingest_chatroom_msg(note, key, ctx.ndb, &txn, ctx.note_cache, ctx.unknown_ids); + } +} + +/// Storage for conversations per account. Account management is performed by `Accounts` +#[derive(Default)] +struct ConversationsCtx { + convos_per_acc: HashMap<Pubkey, ConversationCache>, +} + +impl ConversationsCtx { + /// Get the conversation cache for the selected account. Return None if we don't have a full kp + pub fn get_current_mut(&mut self, accounts: &Accounts) -> Option<&mut ConversationCache> { + accounts.get_selected_account().keypair().secret_key?; + + let current = accounts.selected_account_pubkey(); + Some( + self.convos_per_acc + .raw_entry_mut() + .from_key(current) + .or_insert_with(|| (*current, ConversationCache::new())) + .1, + ) + } +} diff --git a/crates/notedeck_messages/src/nav.rs b/crates/notedeck_messages/src/nav.rs @@ -0,0 +1,172 @@ +use egui_nav::{NavAction, NavResponse}; +use enostr::Pubkey; +use nostrdb::Transaction; +use notedeck::{AppAction, AppContext, ReplacementType, Router}; + +use crate::{ + cache::{ + ConversationCache, ConversationId, ConversationIdentifierUnowned, ParticipantSetUnowned, + }, + nip17::send_conversation_message, +}; + +#[derive(Clone, Debug)] +pub enum Route { + ConvoList, + CreateConvo, + Conversation, +} + +#[derive(Debug)] +pub enum MessagesAction { + SendMessage { + conversation_id: ConversationId, + content: String, + }, + Open(ConversationId), + Creating, + Back, + Create { + recipient: Pubkey, + }, + ToggleChrome, +} + +pub struct MessagesUiResponse { + pub nav_response: Option<NavResponse<Option<MessagesAction>>>, + pub conversation_panel_response: Option<MessagesAction>, +} + +pub fn process_messages_ui_response( + resp: MessagesUiResponse, + ctx: &mut AppContext, + cache: &mut ConversationCache, + router: &mut Router<Route>, + is_narrow: bool, +) -> Option<AppAction> { + let mut action = None; + if let Some(convo_resp) = resp.conversation_panel_response { + action = handle_messages_action(convo_resp, ctx, cache, router, is_narrow); + } + + let Some(nav) = resp.nav_response else { + return action; + }; + + action.or(process_nav_resp(nav, ctx, cache, router, is_narrow)) +} + +fn process_nav_resp( + nav: NavResponse<Option<MessagesAction>>, + ctx: &mut AppContext, + cache: &mut ConversationCache, + router: &mut Router<Route>, + is_narrow: bool, +) -> Option<AppAction> { + let mut app_action = None; + if let Some(action) = nav.response.or(nav.title_response) { + app_action = handle_messages_action(action, ctx, cache, router, is_narrow); + } + + let Some(action) = nav.action else { + return app_action; + }; + + match action { + NavAction::Returning(_) => {} + NavAction::Resetting => {} + NavAction::Dragging => {} + NavAction::Returned(_) => { + router.pop(); + if is_narrow { + cache.active = None; + } + } + NavAction::Navigating => {} + NavAction::Navigated => { + router.navigating = false; + if router.is_replacing() { + router.complete_replacement(); + } + } + } + + app_action +} + +fn handle_messages_action( + action: MessagesAction, + ctx: &mut AppContext<'_>, + cache: &mut ConversationCache, + router: &mut Router<Route>, + is_narrow: bool, +) -> Option<AppAction> { + let mut app_action = None; + match action { + MessagesAction::SendMessage { + conversation_id, + content, + } => send_conversation_message(conversation_id, content, cache, ctx), + MessagesAction::Open(conversation_id) => { + open_coversation_action(conversation_id, ctx, cache, router, is_narrow); + } + MessagesAction::Create { recipient } => { + let selected = ctx.accounts.selected_account_pubkey(); + let participants = vec![recipient.bytes(), selected.bytes()]; + let id = cache + .registry + .get_or_insert(ConversationIdentifierUnowned::Nip17( + ParticipantSetUnowned::new(participants), + )); + + cache.initialize_conversation(id, vec![recipient, *selected]); + + let txn = Transaction::new(ctx.ndb).expect("txn"); + cache.open_conversation( + ctx.ndb, + &txn, + id, + ctx.note_cache, + ctx.unknown_ids, + ctx.accounts.selected_account_pubkey(), + ); + + if is_narrow { + router.route_to_replaced(Route::Conversation, ReplacementType::Single); + } else { + router.go_back(); + } + } + MessagesAction::Creating => { + router.route_to(Route::CreateConvo); + } + MessagesAction::Back => { + router.go_back(); + } + MessagesAction::ToggleChrome => app_action = Some(AppAction::ToggleChrome), + } + + app_action +} + +fn open_coversation_action( + id: ConversationId, + ctx: &mut AppContext<'_>, + cache: &mut ConversationCache, + router: &mut Router<Route>, + is_narrow: bool, +) { + let txn = Transaction::new(ctx.ndb).expect("txn"); + cache.open_conversation( + ctx.ndb, + &txn, + id, + ctx.note_cache, + ctx.unknown_ids, + ctx.accounts.selected_account_pubkey(), + ); + + if is_narrow { + router.route_to(Route::Conversation); + } +} diff --git a/crates/notedeck_messages/src/nip17/message.rs b/crates/notedeck_messages/src/nip17/message.rs @@ -0,0 +1,57 @@ +use enostr::ClientMessage; +use notedeck::AppContext; + +use crate::cache::{ConversationCache, ConversationId}; +use crate::nip17::{build_rumor_json, giftwrap_message, OsRng}; + +pub fn send_conversation_message( + conversation_id: ConversationId, + content: String, + cache: &ConversationCache, + ctx: &mut AppContext<'_>, +) { + if content.trim().is_empty() { + return; + } + + let Some(conversation) = cache.get(conversation_id) else { + tracing::warn!("missing conversation {conversation_id} for send action"); + return; + }; + + let Some(selected_kp) = ctx.accounts.selected_filled() else { + tracing::warn!("cannot send message without a full keypair"); + return; + }; + + let Some(rumor_json) = build_rumor_json( + &content, + &conversation.metadata.participants, + selected_kp.pubkey, + ) else { + tracing::error!("failed to build rumor for conversation {conversation_id}"); + return; + }; + + let Some(sender_secret) = ctx.accounts.selected_filled().map(|f| f.secret_key) else { + return; + }; + + let mut rng = OsRng; + for participant in &conversation.metadata.participants { + let Some(giftwrap_json) = + giftwrap_message(&mut rng, sender_secret, participant, &rumor_json) + else { + continue; + }; + if participant == selected_kp.pubkey { + if let Err(e) = ctx.ndb.process_client_event(&giftwrap_json) { + tracing::error!("Could not ingest event: {e:?}"); + } + } + match ClientMessage::event_json(giftwrap_json.clone()) { + Ok(msg) => ctx.pool.send(&msg), + Err(err) => tracing::error!("failed to build client message: {err}"), + }; + } +} diff --git a/crates/notedeck_messages/src/nip17/mod.rs b/crates/notedeck_messages/src/nip17/mod.rs @@ -0,0 +1,221 @@ +pub mod message; + +use enostr::{FullKeypair, Pubkey, SecretKey}; +pub use message::send_conversation_message; +pub use nostr::secp256k1::rand::rngs::OsRng; +use nostr::secp256k1::rand::Rng; +use nostr::{ + event::{EventBuilder, Kind, Tag}, + key::PublicKey, + nips::nip44, + util::JsonUtil, +}; +use nostrdb::{Filter, FilterBuilder, Note, NoteBuilder}; +use notedeck::get_p_tags; + +fn build_rumor_json( + message: &str, + participants: &[Pubkey], + sender_pubkey: &Pubkey, +) -> Option<String> { + let sender = nostrcrate_pk(sender_pubkey)?; + let mut tags = Vec::new(); + for participant in participants { + if let Some(pk) = nostrcrate_pk(participant) { + tags.push(Tag::public_key(pk)); + } else { + tracing::warn!("invalid participant {}", participant); + } + } + + let builder = EventBuilder::new(Kind::PrivateDirectMessage, message).tags(tags); + Some(builder.build(sender).as_json()) +} + +pub fn giftwrap_message( + rng: &mut OsRng, + sender_secret: &SecretKey, + recipient: &Pubkey, + rumor_json: &str, +) -> Option<String> { + let Some(recipient_pk) = nostrcrate_pk(recipient) else { + tracing::warn!("failed to convert recipient pubkey {}", recipient); + return None; + }; + + let encrypted_rumor = match nip44::encrypt_with_rng( + rng, + sender_secret, + &recipient_pk, + rumor_json, + nip44::Version::V2, + ) { + Ok(payload) => payload, + Err(err) => { + tracing::error!("failed to encrypt rumor for {recipient}: {err}"); + return None; + } + }; + + let seal_created = randomized_timestamp(rng); + let Some(seal_json) = build_seal_json(&encrypted_rumor, sender_secret, seal_created) else { + tracing::error!("failed to build seal for recipient {}", recipient); + return None; + }; + + let wrap_keys = FullKeypair::generate(); + let encrypted_seal = match nip44::encrypt_with_rng( + rng, + &wrap_keys.secret_key, + &recipient_pk, + &seal_json, + nip44::Version::V2, + ) { + Ok(payload) => payload, + Err(err) => { + tracing::error!("failed to encrypt seal for wrap: {err}"); + return None; + } + }; + + let wrap_created = randomized_timestamp(rng); + build_giftwrap_json(&encrypted_seal, &wrap_keys, recipient, wrap_created) +} + +fn build_seal_json( + content_ciphertext: &str, + sender_secret: &SecretKey, + created_at: u64, +) -> Option<String> { + let builder = NoteBuilder::new() + .kind(13) + .content(content_ciphertext) + .created_at(created_at); + + builder + .sign(&sender_secret.secret_bytes()) + .build()? + .json() + .ok() +} + +fn build_giftwrap_json( + content: &str, + wrap_keys: &FullKeypair, + recipient: &Pubkey, + created_at: u64, +) -> Option<String> { + let builder = NoteBuilder::new() + .kind(1059) + .content(content) + .created_at(created_at) + .start_tag() + .tag_str("p") + .tag_str(&recipient.hex()); + + builder + .sign(&wrap_keys.secret_key.secret_bytes()) + .build()? + .json() + .ok() +} + +fn nostrcrate_pk(pk: &Pubkey) -> Option<PublicKey> { + PublicKey::from_slice(pk.bytes()).ok() +} + +fn current_timestamp() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn randomized_timestamp(rng: &mut OsRng) -> u64 { + const MAX_SKEW_SECS: u64 = 2 * 24 * 60 * 60; + let now = current_timestamp(); + let tweak = rng.gen_range(0..=MAX_SKEW_SECS); + now.saturating_sub(tweak) +} + +pub fn get_participants<'a>(note: &Note<'a>) -> Vec<&'a [u8; 32]> { + let mut participants = get_p_tags(note); + let chat_message_sender = note.pubkey(); + if !participants.contains(&chat_message_sender) { + // the chat message sender must be in the participants set + participants.push(chat_message_sender); + } + participants +} + +pub fn conversation_filter(cur_acc: &Pubkey) -> Vec<Filter> { + vec![ + FilterBuilder::new() + .kinds([14]) + .pubkey([cur_acc.bytes()]) + .build(), + FilterBuilder::new() + .kinds([14]) + .authors([cur_acc.bytes()]) + .build(), + ] +} + +/// Unfortunately this gives an OR across participants +pub fn chatroom_filter(participants: Vec<&[u8; 32]>, me: &[u8; 32]) -> Vec<Filter> { + vec![FilterBuilder::new() + .kinds([14]) + .authors(participants.clone()) + .pubkey([me]) + .build()] +} + +// easily retrievable from Note<'a> +pub struct Nip17ChatMessage<'a> { + pub sender: &'a [u8; 32], + pub p_tags: Vec<&'a [u8; 32]>, + pub subject: Option<&'a str>, + pub reply_to: Option<&'a [u8; 32]>, // NoteId + pub message: &'a str, + pub created_at: u64, +} + +pub fn parse_chat_message<'a>(note: &Note<'a>) -> Option<Nip17ChatMessage<'a>> { + if note.kind() != 14 { + return None; + } + + let mut p_tags = Vec::new(); + let mut subject = None; + let mut reply_to = None; + + for tag in note.tags() { + if tag.count() < 2 { + continue; + } + let Some(first) = tag.get_str(0) else { + continue; + }; + + if first == "p" { + if let Some(id) = tag.get_id(1) { + p_tags.push(id); + } + } else if first == "subject" { + subject = tag.get_str(1); + } else if first == "e" { + reply_to = tag.get_id(1); + } + } + + Some(Nip17ChatMessage { + sender: note.pubkey(), + p_tags, + subject, + reply_to, + message: note.content(), + created_at: note.created_at(), + }) +} diff --git a/crates/notedeck_messages/src/ui/convo.rs b/crates/notedeck_messages/src/ui/convo.rs @@ -0,0 +1,612 @@ +use chrono::{DateTime, Duration, Local, NaiveDate}; +use egui::{ + vec2, Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, ScrollArea, TextEdit, +}; +use egui_extras::{Size, StripBuilder}; +use enostr::Pubkey; +use nostrdb::{Ndb, NoteKey, Transaction}; +use notedeck::{ + name::get_display_name, tr, ui::is_narrow, Images, Localization, MediaJobSender, NostrName, +}; +use notedeck_ui::{include_input, ProfilePic}; + +use crate::{ + cache::{ + Conversation, ConversationCache, ConversationId, ConversationState, ConversationStates, + }, + convo_renderable::{ConversationItem, MessageType}, + nav::MessagesAction, + nip17::{parse_chat_message, Nip17ChatMessage}, + ui::{local_datetime_from_nostr, title_label}, +}; + +pub struct ConversationUi<'a> { + conversation: &'a Conversation, + state: &'a mut ConversationState, + ndb: &'a Ndb, + jobs: &'a MediaJobSender, + img_cache: &'a mut Images, + i18n: &'a mut Localization, +} + +impl<'a> ConversationUi<'a> { + pub fn new( + conversation: &'a Conversation, + state: &'a mut ConversationState, + ndb: &'a Ndb, + jobs: &'a MediaJobSender, + img_cache: &'a mut Images, + i18n: &'a mut Localization, + ) -> Self { + Self { + conversation, + state, + ndb, + jobs, + img_cache, + i18n, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui, selected_pubkey: &Pubkey) -> Option<MessagesAction> { + let txn = Transaction::new(self.ndb).expect("txn"); + + let mut action = None; + Frame::new().fill(ui.visuals().panel_fill).show(ui, |ui| { + ui.with_layout(Layout::bottom_up(Align::Min), |ui| { + let focusing_composer = ui + .allocate_ui(vec2(ui.available_width(), 64.0), |ui| { + let comp_resp = + conversation_composer(ui, self.state, self.conversation.id, self.i18n); + if action.is_none() { + action = comp_resp.action; + } + comp_resp.composer_has_focus + }) + .inner; + ui.with_layout(Layout::top_down(Align::Min), |ui| { + ScrollArea::vertical() + .stick_to_bottom(focusing_composer) + .id_salt(ui.id().with(self.conversation.id)) + .show(ui, |ui| { + conversation_history( + ui, + self.conversation, + self.state, + self.jobs, + self.ndb, + &txn, + self.img_cache, + selected_pubkey, + self.i18n, + ); + }); + }); + }) + }); + + action + } +} + +#[allow(clippy::too_many_arguments)] +fn conversation_history( + ui: &mut egui::Ui, + conversation: &Conversation, + state: &mut ConversationState, + jobs: &MediaJobSender, + ndb: &Ndb, + txn: &Transaction, + img_cache: &mut Images, + selected_pk: &Pubkey, + i18n: &mut Localization, +) { + let renderable = &conversation.renderable; + + state.last_read = conversation + .messages + .messages_ordered + .first() + .map(|n| &n.note_ref) + .copied(); + Frame::new() + .inner_margin(Margin::symmetric(16, 0)) + .show(ui, |ui| { + let today = Local::now().date_naive(); + let total = renderable.len(); + state.list.ui_custom_layout(ui, total, |ui, index| { + let Some(renderable) = renderable.get(index) else { + return 1; + }; + + match renderable { + ConversationItem::Date(date) => render_date_line(ui, *date, &today, i18n), + ConversationItem::Message { msg_type, key } => { + render_chat_msg( + ui, + img_cache, + jobs, + ndb, + txn, + *key, + *msg_type, + selected_pk, + ); + } + }; + + 1 + }); + }); +} + +fn render_date_line( + ui: &mut egui::Ui, + date: NaiveDate, + today: &NaiveDate, + i18n: &mut Localization, +) { + let label = format_day_heading(date, today, i18n); + ui.add_space(8.0); + ui.vertical_centered(|ui| { + ui.add( + egui::Label::new( + RichText::new(label) + .strong() + .color(ui.visuals().weak_text_color()), + ) + .wrap(), + ); + }); + ui.add_space(4.0); +} + +#[allow(clippy::too_many_arguments)] +fn render_chat_msg( + ui: &mut egui::Ui, + img_cache: &mut Images, + jobs: &MediaJobSender, + ndb: &Ndb, + txn: &Transaction, + key: NoteKey, + msg_type: MessageType, + selected_pk: &Pubkey, +) { + let Ok(note) = ndb.get_note_by_key(txn, key) else { + tracing::error!("Could not get key {:?}", key); + return; + }; + + let Some(chat_msg) = parse_chat_message(&note) else { + tracing::error!("Could not parse chat message for note {key:?}"); + return; + }; + + match msg_type { + MessageType::Standalone => { + ui.add_space(2.0); + render_msg_with_pfp( + ui, + img_cache, + jobs, + ndb, + txn, + selected_pk, + msg_type, + chat_msg, + ); + ui.add_space(2.0); + } + MessageType::FirstInSeries => { + ui.add_space(2.0); + render_msg_no_pfp(ui, ndb, txn, selected_pk, msg_type, chat_msg); + } + MessageType::MiddleInSeries => { + render_msg_no_pfp(ui, ndb, txn, selected_pk, msg_type, chat_msg); + } + MessageType::LastInSeries => { + render_msg_with_pfp( + ui, + img_cache, + jobs, + ndb, + txn, + selected_pk, + msg_type, + chat_msg, + ); + ui.add_space(2.0); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn render_msg_with_pfp( + ui: &mut egui::Ui, + img_cache: &mut Images, + jobs: &MediaJobSender, + ndb: &Ndb, + txn: &Transaction, + selected_pk: &Pubkey, + msg_type: MessageType, + chat_msg: Nip17ChatMessage, +) { + if selected_pk.bytes() == chat_msg.sender { + self_chat_bubble(ui, chat_msg.message, msg_type, chat_msg.created_at); + return; + } + + let avatar_size = ProfilePic::medium_size() as f32; + let profile = ndb.get_profile_by_pubkey(txn, chat_msg.sender).ok(); + let mut pic = + ProfilePic::from_profile_or_default(img_cache, jobs, profile.as_ref()).size(avatar_size); + ui.horizontal(|ui| { + ui.add(&mut pic); + ui.add_space(8.0); + + other_chat_bubble(ui, chat_msg, get_display_name(profile.as_ref()), msg_type); + }); +} + +fn render_msg_no_pfp( + ui: &mut egui::Ui, + ndb: &Ndb, + txn: &Transaction, + selected_pk: &Pubkey, + msg_type: MessageType, + chat_msg: Nip17ChatMessage, +) { + if selected_pk.bytes() == chat_msg.sender { + self_chat_bubble(ui, chat_msg.message, msg_type, chat_msg.created_at); + return; + } + + ui.horizontal(|ui| { + ui.add_space(ProfilePic::medium_size() as f32 + ui.spacing().item_spacing.x + 8.0); + let profile = ndb.get_profile_by_pubkey(txn, chat_msg.sender).ok(); + other_chat_bubble(ui, chat_msg, get_display_name(profile.as_ref()), msg_type); + }); +} + +fn conversation_composer( + ui: &mut egui::Ui, + state: &mut ConversationState, + conversation_id: ConversationId, + i18n: &mut Localization, +) -> ComposerResponse { + { + let rect = ui.available_rect_before_wrap(); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, CornerRadius::ZERO, ui.visuals().panel_fill); + } + let margin = Margin::symmetric(16, 4); + let mut action = None; + let mut composer_has_focus = false; + Frame::new().inner_margin(margin).show(ui, |ui| { + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + // TODO(kernelkind): ideally this will be multiline, but the default multiline impl doesn't work the way + // signal's multiline works... TBC + + let old = mut_visuals_corner_radius(ui, CornerRadius::same(16)); + + let hint_text = RichText::new(tr!( + i18n, + "Type a message", + "Placeholder text for the message composer in chats" + )) + .color(ui.visuals().noninteractive().fg_stroke.color); + let mut send = false; + let is_narrow = is_narrow(ui.ctx()); + let send_button_section = if is_narrow { 32.0 } else { 0.0 }; + + StripBuilder::new(ui) + .size(Size::remainder()) + .size(Size::exact(send_button_section)) + .horizontal(|mut strip| { + strip.cell(|ui| { + let spacing = ui.spacing().item_spacing.x; + let text_height = ui.spacing().item_spacing.y * 1.4; + let text_width = (ui.available_width() - spacing).max(0.0); + let size = vec2(text_width, text_height); + + let text_edit = TextEdit::singleline(&mut state.composer) + .margin(Margin::symmetric(16, 8)) + .vertical_align(Align::Center) + .desired_width(text_width) + .hint_text(hint_text) + .min_size(size); + let text_resp = ui.add(text_edit); + restore_widgets_corner_rad(ui, old); + send = text_resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)); + include_input(ui, &text_resp); + composer_has_focus = text_resp.has_focus(); + }); + + if is_narrow { + strip.cell(|ui| { + ui.add_space(6.0); + if ui + .add_enabled( + !state.composer.is_empty(), + egui::Button::new("Send").frame(false), + ) + .clicked() + { + send = true; + } + }); + } else { + strip.empty(); + } + }); + if send { + action = prepare_send_action(conversation_id, state); + } + }); + }); + + ComposerResponse { + action, + composer_has_focus, + } +} + +struct ComposerResponse { + action: Option<MessagesAction>, + composer_has_focus: bool, +} + +fn prepare_send_action( + conversation_id: ConversationId, + state: &mut ConversationState, +) -> Option<MessagesAction> { + if state.composer.trim().is_empty() { + return None; + } + + let message = std::mem::take(&mut state.composer); + Some(MessagesAction::SendMessage { + conversation_id, + content: message, + }) +} + +fn chat_bubble<R>( + ui: &mut egui::Ui, + msg_type: MessageType, + is_self: bool, + bubble_fill: Color32, + contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + let d = 18; + let i = 4; + + let (inner_top, inner_bottom) = match msg_type { + MessageType::Standalone => (d, d), + MessageType::FirstInSeries => (d, i), + MessageType::MiddleInSeries => (i, i), + MessageType::LastInSeries => (i, d), + }; + + let corner_radius = if is_self { + CornerRadius { + nw: d, + ne: inner_top, + sw: d, + se: inner_bottom, + } + } else { + CornerRadius { + nw: inner_top, + ne: d, + sw: inner_bottom, + se: d, + } + }; + + Frame::new() + .fill(bubble_fill) + .corner_radius(corner_radius) + .inner_margin(Margin::symmetric(14, 10)) + .show(ui, |ui| { + ui.set_max_width(ui.available_width() * 0.9); + contents(ui) + }) + .inner +} + +fn self_chat_bubble( + ui: &mut egui::Ui, + message: &str, + msg_type: MessageType, + timestamp: u64, +) -> egui::Response { + let bubble_fill = ui.visuals().selection.bg_fill; + ui.with_layout(Layout::right_to_left(Align::Min), |ui| { + chat_bubble(ui, msg_type, true, bubble_fill, |ui| { + ui.with_layout(Layout::top_down(Align::Max), |ui| { + ui.label(RichText::new(message).color(ui.visuals().text_color())); + + if msg_type == MessageType::Standalone || msg_type == MessageType::LastInSeries { + let timestamp_label = + format_timestamp_label(&local_datetime_from_nostr(timestamp)); + ui.label( + RichText::new(timestamp_label) + .small() + .color(ui.visuals().window_fill), + ); + } + }) + }) + .inner + }) + .response +} + +fn other_chat_bubble( + ui: &mut egui::Ui, + chat_msg: Nip17ChatMessage, + sender_name: NostrName, + msg_type: MessageType, +) -> egui::Response { + let message = chat_msg.message; + let bubble_fill = ui.visuals().extreme_bg_color; + let text_color = ui.visuals().text_color(); + let secondary_color = ui.visuals().weak_text_color(); + + chat_bubble(ui, msg_type, false, bubble_fill, |ui| { + ui.vertical(|ui| { + if msg_type == MessageType::FirstInSeries || msg_type == MessageType::Standalone { + ui.label( + RichText::new(sender_name.name()) + .strong() + .color(secondary_color), + ); + ui.add_space(2.0); + } + + ui.with_layout( + Layout::left_to_right(Align::Max).with_main_wrap(true), + |ui| { + ui.label(RichText::new(message).color(text_color)); + if msg_type == MessageType::Standalone || msg_type == MessageType::LastInSeries + { + ui.add_space(6.0); + let timestamp_label = + format_timestamp_label(&local_datetime_from_nostr(chat_msg.created_at)); + ui.add( + egui::Label::new( + RichText::new(timestamp_label) + .small() + .color(secondary_color), + ) + .wrap_mode(egui::TextWrapMode::Extend), + ); + } + }, + ); + }) + .response + }) +} + +/// An unfortunate hack to change the corner radius of a TextEdit... +/// returns old `CornerRadius` +fn mut_visuals_corner_radius(ui: &mut egui::Ui, rad: CornerRadius) -> WidgetsCornerRadius { + let widgets = &ui.visuals().widgets; + let old = WidgetsCornerRadius { + active: widgets.active.corner_radius, + hovered: widgets.hovered.corner_radius, + inactive: widgets.inactive.corner_radius, + noninteractive: widgets.noninteractive.corner_radius, + open: widgets.open.corner_radius, + }; + + let widgets = &mut ui.visuals_mut().widgets; + widgets.active.corner_radius = rad; + widgets.hovered.corner_radius = rad; + widgets.inactive.corner_radius = rad; + widgets.noninteractive.corner_radius = rad; + widgets.open.corner_radius = rad; + + old +} + +fn restore_widgets_corner_rad(ui: &mut egui::Ui, old: WidgetsCornerRadius) { + let widgets = &mut ui.visuals_mut().widgets; + + widgets.active.corner_radius = old.active; + widgets.hovered.corner_radius = old.hovered; + widgets.inactive.corner_radius = old.inactive; + widgets.noninteractive.corner_radius = old.noninteractive; + widgets.open.corner_radius = old.open; +} + +struct WidgetsCornerRadius { + active: CornerRadius, + hovered: CornerRadius, + inactive: CornerRadius, + noninteractive: CornerRadius, + open: CornerRadius, +} + +fn format_day_heading(date: NaiveDate, today: &NaiveDate, i18n: &mut Localization) -> String { + if date == *today { + tr!( + i18n, + "Today", + "Label shown between chat messages for the current day" + ) + } else if date == *today - Duration::days(1) { + tr!( + i18n, + "Yesterday", + "Label shown between chat messages for the previous day" + ) + } else { + date.format("%A, %B %-d, %Y").to_string() + } +} + +pub fn format_time_short( + today: NaiveDate, + time: &DateTime<Local>, + i18n: &mut Localization, +) -> String { + let d = time.date_naive(); + + if d == today { + return format_timestamp_label(time); + } else if d == today - Duration::days(1) { + return tr!( + i18n, + "Yest", + "Abbreviated version of yesterday used in conversation summaries" + ); + } + + let days_ago = today.signed_duration_since(d).num_days(); + + if days_ago < 7 { + return d.format("%a").to_string(); + } + + d.format("%b %-d").to_string() +} + +fn format_timestamp_label(dt: &DateTime<Local>) -> String { + dt.format("%-I:%M %p").to_string() +} + +#[allow(clippy::too_many_arguments)] +pub fn conversation_ui( + cache: &ConversationCache, + states: &mut ConversationStates, + jobs: &MediaJobSender, + ndb: &Ndb, + ui: &mut egui::Ui, + img_cache: &mut Images, + i18n: &mut Localization, + selected_pubkey: &Pubkey, +) -> Option<MessagesAction> { + let Some(id) = cache.active else { + title_label( + ui, + &tr!( + i18n, + "No conversations yet", + "label describing that there are no conversations yet", + ), + ); + return None; + }; + + let Some(conversation) = cache.get(id) else { + tracing::error!("could not find active convo id {id}"); + return None; + }; + + let state = states.get_or_insert(id); + + ConversationUi::new(conversation, state, ndb, jobs, img_cache, i18n).ui(ui, selected_pubkey) +} diff --git a/crates/notedeck_messages/src/ui/convo_list.rs b/crates/notedeck_messages/src/ui/convo_list.rs @@ -0,0 +1,311 @@ +use chrono::Local; +use egui::{ + Align, Color32, CornerRadius, Frame, Label, Layout, Margin, RichText, ScrollArea, Sense, +}; +use egui_extras::{Size, Strip, StripBuilder}; +use enostr::Pubkey; +use nostrdb::{Ndb, Note, ProfileRecord, Transaction}; +use notedeck::{ + fonts::get_font_size, tr, ui::is_narrow, Images, Localization, MediaJobSender, + NotedeckTextStyle, +}; +use notedeck_ui::ProfilePic; + +use crate::{ + cache::{ + Conversation, ConversationCache, ConversationId, ConversationState, ConversationStates, + }, + nav::MessagesAction, + ui::{ + conversation_title, convo::format_time_short, direct_chat_partner, local_datetime, + ConversationSummary, + }, +}; + +pub struct ConversationListUi<'a> { + cache: &'a ConversationCache, + states: &'a mut ConversationStates, + jobs: &'a MediaJobSender, + ndb: &'a Ndb, + img_cache: &'a mut Images, + i18n: &'a mut Localization, +} + +impl<'a> ConversationListUi<'a> { + pub fn new( + cache: &'a ConversationCache, + states: &'a mut ConversationStates, + jobs: &'a MediaJobSender, + ndb: &'a Ndb, + img_cache: &'a mut Images, + i18n: &'a mut Localization, + ) -> Self { + Self { + cache, + states, + ndb, + jobs, + img_cache, + i18n, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui, selected_pubkey: &Pubkey) -> Option<MessagesAction> { + let mut action = None; + if self.cache.is_empty() { + ui.centered_and_justified(|ui| { + ui.label(tr!( + self.i18n, + "No conversations yet", + "Empty state text when the user has no conversations" + )); + }); + return None; + } + + ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + let num_convos = self.cache.len(); + + self.states + .convos_list + .ui_custom_layout(ui, num_convos, |ui, index| { + let Some(id) = self.cache.get_id_by_index(index).copied() else { + return 1; + }; + + let Some(convo) = self.cache.get(id) else { + return 1; + }; + + let state = self.states.cache.get(&id); + + if let Some(a) = render_list_item( + ui, + self.ndb, + self.cache.active, + id, + convo, + state, + self.jobs, + self.img_cache, + selected_pubkey, + self.i18n, + ) { + action = Some(a); + } + + 1 + }); + }); + action + } +} + +#[allow(clippy::too_many_arguments)] +fn render_list_item( + ui: &mut egui::Ui, + ndb: &Ndb, + active: Option<ConversationId>, + id: ConversationId, + convo: &Conversation, + state: Option<&ConversationState>, + jobs: &MediaJobSender, + img_cache: &mut Images, + selected_pubkey: &Pubkey, + i18n: &mut Localization, +) -> Option<MessagesAction> { + let txn = Transaction::new(ndb).expect("txn"); + let summary = ConversationSummary::new(convo, state.and_then(|s| s.last_read)); + + let title = conversation_title(summary.metadata, &txn, ndb, selected_pubkey, i18n); + + let partner = direct_chat_partner(summary.metadata.participants.as_slice(), selected_pubkey); + let partner_profile = partner.and_then(|pk| ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok()); + + let last_msg = summary + .last_message + .and_then(|r| ndb.get_note_by_key(&txn, r.key).ok()); + + let response = render_summary( + ui, + summary, + active == Some(id), + title.as_ref(), + partner.is_some(), + last_msg.as_ref(), + partner_profile.as_ref(), + jobs, + img_cache, + i18n, + ); + + response.clicked().then_some(MessagesAction::Open(id)) +} + +#[allow(clippy::too_many_arguments)] +pub fn render_summary( + ui: &mut egui::Ui, + summary: ConversationSummary, + selected: bool, + title: &str, + show_partner_avatar: bool, + last_message: Option<&Note>, + partner_profile: Option<&ProfileRecord<'_>>, + jobs: &MediaJobSender, + img_cache: &mut Images, + i18n: &mut Localization, +) -> egui::Response { + let visuals = ui.visuals(); + let fill = if is_narrow(ui.ctx()) { + Color32::TRANSPARENT + } else if selected { + visuals.selection.bg_fill + } else if summary.unread { + visuals.faint_bg_color + } else { + Color32::TRANSPARENT + }; + + Frame::new() + .fill(fill) + .corner_radius(CornerRadius::same(12)) + .inner_margin(Margin::symmetric(12, 8)) + .show(ui, |ui| { + render_summary_inner( + ui, + title, + show_partner_avatar, + last_message, + partner_profile, + jobs, + img_cache, + i18n, + ); + }) + .response + .interact(Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand) +} + +#[allow(clippy::too_many_arguments)] +fn render_summary_inner( + ui: &mut egui::Ui, + title: &str, + show_partner_avatar: bool, + last_message: Option<&Note>, + partner_profile: Option<&ProfileRecord<'_>>, + jobs: &MediaJobSender, + img_cache: &mut Images, + i18n: &mut Localization, +) { + let summary_height = 40.0; + StripBuilder::new(ui) + .size(Size::exact(summary_height)) + .vertical(|mut strip| { + strip.strip(|builder| { + builder + .size(Size::exact(summary_height + 8.0)) + .size(Size::remainder()) + .horizontal(|strip| { + render_summary_horizontal( + title, + show_partner_avatar, + last_message, + partner_profile, + jobs, + img_cache, + summary_height, + i18n, + strip, + ); + }); + }); + }); +} + +#[allow(clippy::too_many_arguments)] +fn render_summary_horizontal( + title: &str, + show_partner_avatar: bool, + last_message: Option<&Note>, + partner_profile: Option<&ProfileRecord<'_>>, + jobs: &MediaJobSender, + img_cache: &mut Images, + summary_height: f32, + i18n: &mut Localization, + mut strip: Strip, +) { + if show_partner_avatar { + strip.cell(|ui| { + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + let size = ProfilePic::default_size() as f32; + let mut pic = ProfilePic::from_profile_or_default(img_cache, jobs, partner_profile) + .size(size); + ui.add(&mut pic); + }); + }); + } else { + strip.empty(); + } + + let title_height = 8.0; + strip.cell(|ui| { + StripBuilder::new(ui) + .size(Size::exact(title_height)) + .size(Size::exact(summary_height - title_height)) + .vertical(|strip| { + render_summary_body(title, last_message, i18n, strip); + }); + }); +} + +fn render_summary_body( + title: &str, + last_message: Option<&Note>, + i18n: &mut Localization, + mut strip: Strip, +) { + strip.cell(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if let Some(last_msg) = last_message { + let today = Local::now().date_naive(); + let last_msg_ts = i64::try_from(last_msg.created_at()).unwrap_or(i64::MAX); + let time_str = format_time_short(today, &local_datetime(last_msg_ts), i18n); + + ui.add_enabled( + false, + Label::new( + RichText::new(time_str) + .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Heading4)), + ), + ); + } + + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add( + egui::Label::new(RichText::new(title).strong()) + .truncate() + .selectable(false), + ); + }); + }); + }); + + let Some(last_msg) = last_message else { + strip.empty(); + return; + }; + + strip.cell(|ui| { + ui.add_enabled( + false, // disables hover & makes text grayed out + Label::new( + RichText::new(last_msg.content()) + .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Body)), + ) + .truncate(), + ); + }); +} diff --git a/crates/notedeck_messages/src/ui/create_convo.rs b/crates/notedeck_messages/src/ui/create_convo.rs @@ -0,0 +1,67 @@ +use egui::{Label, RichText}; +use enostr::Pubkey; +use nostrdb::{Ndb, Transaction}; +use notedeck::{tr, ContactState, Images, Localization, MediaJobSender, NotedeckTextStyle}; +use notedeck_ui::{contacts_list::ContactsCollection, ContactsListView}; + +pub struct CreateConvoUi<'a> { + ndb: &'a Ndb, + jobs: &'a MediaJobSender, + img_cache: &'a mut Images, + contacts: &'a ContactState, + i18n: &'a mut Localization, +} + +pub struct CreateConvoResponse { + pub recipient: Pubkey, +} + +impl<'a> CreateConvoUi<'a> { + pub fn new( + ndb: &'a Ndb, + jobs: &'a MediaJobSender, + img_cache: &'a mut Images, + contacts: &'a ContactState, + i18n: &'a mut Localization, + ) -> Self { + Self { + ndb, + jobs, + img_cache, + contacts, + i18n, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<CreateConvoResponse> { + let ContactState::Received { contacts, .. } = self.contacts else { + // TODO render something about not having contacts + return None; + }; + + let txn = Transaction::new(self.ndb).expect("txn"); + + ui.add(Label::new( + RichText::new(tr!( + self.i18n, + "Contacts", + "Heading shown when choosing a contact to start a new chat" + )) + .text_style(NotedeckTextStyle::Heading.text_style()), + )); + let resp = ContactsListView::new( + ContactsCollection::Set(contacts), + self.jobs, + self.ndb, + self.img_cache, + &txn, + ) + .ui(ui); + + resp.output.map(|a| match a { + notedeck_ui::ContactsListAction::Select(pubkey) => { + CreateConvoResponse { recipient: pubkey } + } + }) + } +} diff --git a/crates/notedeck_messages/src/ui/messages.rs b/crates/notedeck_messages/src/ui/messages.rs @@ -0,0 +1,174 @@ +use egui::{Frame, Layout, Margin}; +use egui_extras::{Size, StripBuilder}; +use enostr::Pubkey; +use nostrdb::Ndb; +use notedeck::{ + ui::is_narrow, ContactState, Images, Localization, MediaJobSender, Router, Settings, +}; + +use crate::{ + cache::{ConversationCache, ConversationStates}, + nav::{MessagesUiResponse, Route}, + ui::{conversation_header_impl, convo::conversation_ui, nav::render_nav}, +}; + +#[allow(clippy::too_many_arguments)] +pub fn desktop_messages_ui( + cache: &ConversationCache, + states: &mut ConversationStates, + jobs: &MediaJobSender, + ndb: &Ndb, + selected_pubkey: &Pubkey, + ui: &mut egui::Ui, + img_cache: &mut Images, + router: &Router<Route>, + settings: &Settings, + contacts: &ContactState, + i18n: &mut Localization, +) -> MessagesUiResponse { + let mut nav_resp = None; + let mut convo_resp = None; + + StripBuilder::new(ui) + .size(Size::exact(300.0)) + .size(Size::remainder()) + .horizontal(|mut strip| { + strip.cell(|ui| { + nav_resp = Some(render_nav( + ui, + router, + settings, + cache, + states, + jobs, + ndb, + selected_pubkey, + img_cache, + contacts, + i18n, + )); + }); + + strip.strip(|strip| { + strip + .size(Size::exact(64.0)) + .size(Size::remainder()) + .vertical(|mut strip| { + strip.cell(|ui| { + ui.with_layout(Layout::left_to_right(egui::Align::Center), |ui| { + Frame::new().inner_margin(Margin::symmetric(16, 4)).show( + ui, + |ui| { + conversation_header_impl( + ui, + i18n, + cache, + selected_pubkey, + ndb, + jobs, + img_cache, + ); + }, + ); + }); + }); + strip.cell(|ui| { + convo_resp = conversation_ui( + cache, + states, + jobs, + ndb, + ui, + img_cache, + i18n, + selected_pubkey, + ); + }); + }); + }); + }); + + MessagesUiResponse { + nav_response: nav_resp, + conversation_panel_response: convo_resp, + } +} + +#[allow(clippy::too_many_arguments)] +pub fn narrow_messages_ui( + cache: &ConversationCache, + states: &mut ConversationStates, + jobs: &MediaJobSender, + ndb: &Ndb, + selected_pubkey: &Pubkey, + ui: &mut egui::Ui, + img_cache: &mut Images, + router: &Router<Route>, + settings: &Settings, + contacts: &ContactState, + i18n: &mut Localization, +) -> MessagesUiResponse { + let nav = render_nav( + ui, + router, + settings, + cache, + states, + jobs, + ndb, + selected_pubkey, + img_cache, + contacts, + i18n, + ); + + MessagesUiResponse { + nav_response: Some(nav), + conversation_panel_response: None, + } +} + +#[allow(clippy::too_many_arguments)] +pub fn messages_ui( + cache: &ConversationCache, + states: &mut ConversationStates, + jobs: &MediaJobSender, + ndb: &Ndb, + selected_pubkey: &Pubkey, + ui: &mut egui::Ui, + img_cache: &mut Images, + router: &Router<Route>, + settings: &Settings, + contacts: &ContactState, + i18n: &mut Localization, +) -> MessagesUiResponse { + if is_narrow(ui.ctx()) { + narrow_messages_ui( + cache, + states, + jobs, + ndb, + selected_pubkey, + ui, + img_cache, + router, + settings, + contacts, + i18n, + ) + } else { + desktop_messages_ui( + cache, + states, + jobs, + ndb, + selected_pubkey, + ui, + img_cache, + router, + settings, + contacts, + i18n, + ) + } +} diff --git a/crates/notedeck_messages/src/ui/mod.rs b/crates/notedeck_messages/src/ui/mod.rs @@ -0,0 +1,253 @@ +use std::borrow::Cow; + +use chrono::{DateTime, Local, Utc}; +use egui::{Layout, RichText}; +use enostr::Pubkey; +use nostrdb::{Ndb, ProfileRecord, Transaction}; +use notedeck::{ + name::get_display_name, tr, tr_plural, Images, Localization, MediaJobSender, NoteRef, + NotedeckTextStyle, +}; +use notedeck_ui::ProfilePic; + +use crate::cache::{Conversation, ConversationCache, ConversationMetadata}; + +pub mod convo; +pub mod convo_list; +pub mod create_convo; +pub mod messages; +pub mod nav; + +#[derive(Clone, Debug)] +pub struct ConversationSummary<'a> { + pub metadata: &'a ConversationMetadata, + pub last_message: Option<&'a NoteRef>, + pub unread: bool, + pub total_messages: usize, +} + +impl<'a> ConversationSummary<'a> { + pub fn new(convo: &'a Conversation, last_read: Option<NoteRef>) -> Self { + Self { + metadata: &convo.metadata, + last_message: convo.messages.latest(), + unread: last_read.is_some_and(|r| { + let Some(latest) = convo.messages.latest() else { + return false; + }; + + r < *latest + }), + total_messages: convo.messages.len(), + } + } +} + +fn fallback_convo_title( + participants: &[Pubkey], + txn: &Transaction, + ndb: &Ndb, + current: &Pubkey, + i18n: &mut Localization, +) -> String { + let fallback = tr!( + i18n, + "Conversation", + "Fallback title when no direct chat partner is available" + ); + if participants.is_empty() { + return fallback; + } + + let others: Vec<&Pubkey> = participants.iter().filter(|pk| *pk != current).collect(); + + if let Some(partner) = direct_chat_partner(participants, current) { + return participant_label(ndb, txn, partner); + } + + if others.is_empty() { + return tr!( + i18n, + "Note to Self", + "Conversation title used when a chat only has the current user" + ); + } + + let names: Vec<String> = others + .iter() + .map(|pk| participant_label(ndb, txn, pk)) + .collect(); + + if names.is_empty() { + return fallback; + } + + names.join(", ") +} + +pub fn conversation_title<'a>( + metadata: &'a ConversationMetadata, + txn: &Transaction, + ndb: &Ndb, + current: &Pubkey, + i18n: &mut Localization, +) -> Cow<'a, str> { + if let Some(title) = metadata.title.as_ref() { + Cow::Borrowed(title.title.as_str()) + } else { + Cow::Owned(fallback_convo_title( + &metadata.participants, + txn, + ndb, + current, + i18n, + )) + } +} + +pub fn conversation_meta_line( + summary: &ConversationSummary<'_>, + i18n: &mut Localization, +) -> String { + let mut parts = Vec::new(); + if summary.total_messages > 0 { + parts.push(tr_plural!( + i18n, + "{count} message", + "{count} messages", + "Count of messages shown in a chat summary line", + summary.total_messages, + )); + } else { + parts.push(tr!( + i18n, + "No messages yet", + "Chat summary text when the conversation has no messages" + )); + } + + parts.join(" • ") +} + +pub fn direct_chat_partner<'a>(participants: &'a [Pubkey], current: &Pubkey) -> Option<&'a Pubkey> { + if participants.len() != 2 { + return None; + } + + participants.iter().find(|pk| *pk != current) +} + +pub fn participant_label(ndb: &Ndb, txn: &Transaction, pk: &Pubkey) -> String { + let record = ndb.get_profile_by_pubkey(txn, pk.bytes()).ok(); + let name = get_display_name(record.as_ref()); + + if name.display_name.is_some() || name.username.is_some() { + name.name().to_owned() + } else { + short_pubkey(pk) + } +} + +fn short_pubkey(pk: &Pubkey) -> String { + let hex = pk.hex(); + const START: usize = 8; + const END: usize = 4; + if hex.len() <= START + END { + hex.to_owned() + } else { + format!("{}…{}", &hex[..START], &hex[hex.len() - END..]) + } +} + +pub fn local_datetime(day: i64) -> DateTime<Local> { + DateTime::<Utc>::from_timestamp(day, 0) + .unwrap_or_else(|| DateTime::<Utc>::from_timestamp(0, 0).unwrap()) + .with_timezone(&Local) +} + +pub fn local_datetime_from_nostr(timestamp: u64) -> DateTime<Local> { + local_datetime(timestamp as i64) +} + +pub fn login_nsec_prompt(ui: &mut egui::Ui, i18n: &mut Localization) { + ui.centered_and_justified(|ui| { + ui.vertical(|ui| { + ui.heading(tr!( + i18n, + "Add your private key", + "Heading shown when prompting the user to add a private key to use messages" + )); + ui.label(tr!( + i18n, + "Messages are end-to-end encrypted. Add your nsec in Accounts to read and send chats.", + "Description shown under the private key prompt in the Messages view" + )); + }); + }); +} + +pub fn conversation_header_impl( + ui: &mut egui::Ui, + i18n: &mut Localization, + cache: &ConversationCache, + selected_pubkey: &Pubkey, + ndb: &Ndb, + jobs: &MediaJobSender, + img_cache: &mut Images, +) { + let Some(conversation) = cache.get_active() else { + title_label( + ui, + &tr!( + i18n, + "Conversation", + "Title used when viewing an unknown conversation" + ), + ); + return; + }; + + let txn = Transaction::new(ndb).expect("txn"); + + let title = conversation_title(&conversation.metadata, &txn, ndb, selected_pubkey, i18n); + let summary = ConversationSummary { + metadata: &conversation.metadata, + last_message: conversation.messages.latest(), + unread: false, + total_messages: conversation.messages.len(), + }; + let partner = direct_chat_partner(summary.metadata.participants.as_slice(), selected_pubkey); + let partner_profile = partner.and_then(|pk| ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok()); + + conversation_header(ui, &title, jobs, img_cache, true, partner_profile.as_ref()); +} + +fn title_label(ui: &mut egui::Ui, text: &str) -> egui::Response { + ui.add( + egui::Label::new(RichText::new(text).text_style(NotedeckTextStyle::Heading.text_style())) + .selectable(false), + ) +} + +pub fn conversation_header( + ui: &mut egui::Ui, + title: &str, + jobs: &MediaJobSender, + img_cache: &mut Images, + show_partner_avatar: bool, + partner_profile: Option<&ProfileRecord<'_>>, +) { + ui.with_layout( + Layout::left_to_right(egui::Align::Center).with_main_wrap(true), + |ui| { + if show_partner_avatar { + let mut pic = ProfilePic::from_profile_or_default(img_cache, jobs, partner_profile) + .size(ProfilePic::medium_size() as f32); + ui.add(&mut pic); + ui.add_space(8.0); + } + + ui.heading(title); + }, + ); +} diff --git a/crates/notedeck_messages/src/ui/nav.rs b/crates/notedeck_messages/src/ui/nav.rs @@ -0,0 +1,281 @@ +use egui::{CornerRadius, CursorIcon, Frame, Margin, Sense, Stroke}; +use egui_nav::{NavResponse, RouteResponse}; +use enostr::Pubkey; +use nostrdb::Ndb; +use notedeck::{ + tr, ui::is_narrow, ContactState, Images, Localization, MediaJobSender, Router, Settings, +}; +use notedeck_ui::{ + app_images, + header::{chevron, HorizontalHeader}, +}; + +use crate::{ + cache::{ConversationCache, ConversationStates}, + nav::{MessagesAction, Route}, + ui::{ + conversation_header_impl, convo::conversation_ui, convo_list::ConversationListUi, + create_convo::CreateConvoUi, title_label, + }, +}; + +#[allow(clippy::too_many_arguments)] +pub fn render_nav( + ui: &mut egui::Ui, + router: &Router<Route>, + settings: &Settings, + cache: &ConversationCache, + states: &mut ConversationStates, + jobs: &MediaJobSender, + ndb: &Ndb, + selected_pubkey: &Pubkey, + img_cache: &mut Images, + contacts: &ContactState, + i18n: &mut Localization, +) -> NavResponse<Option<MessagesAction>> { + ui.painter().rect( + ui.available_rect_before_wrap(), + CornerRadius::ZERO, + ui.visuals().faint_bg_color, + Stroke::NONE, + egui::StrokeKind::Inside, + ); + + if cfg!(target_os = "macos") { + ui.add_space(16.0); + } + + egui_nav::Nav::new(router.routes()) + .navigating(router.navigating) + .returning(router.returning) + .animate_transitions(settings.animate_nav_transitions) + .show_mut(ui, |ui, render_type, nav| match render_type { + egui_nav::NavUiType::Title => { + let mut nav_title = NavTitle::new( + nav.routes(), + cache, + jobs, + ndb, + selected_pubkey, + img_cache, + i18n, + ); + let response = nav_title.show(ui); + + RouteResponse { + response, + can_take_drag_from: Vec::new(), + } + } + egui_nav::NavUiType::Body => { + let Some(top) = nav.routes().last() else { + return RouteResponse { + response: None, + can_take_drag_from: Vec::new(), + }; + }; + + render_nav_body( + top, + cache, + states, + jobs, + ndb, + selected_pubkey, + ui, + img_cache, + contacts, + i18n, + ) + } + }) +} + +#[allow(clippy::too_many_arguments)] +fn render_nav_body( + top: &Route, + cache: &ConversationCache, + states: &mut ConversationStates, + jobs: &MediaJobSender, + ndb: &Ndb, + selected_pubkey: &Pubkey, + ui: &mut egui::Ui, + img_cache: &mut Images, + contacts: &ContactState, + i18n: &mut Localization, +) -> RouteResponse<Option<MessagesAction>> { + let response = match top { + Route::ConvoList => { + let mut frame = Frame::new(); + if !is_narrow(ui.ctx()) { + frame = frame.inner_margin(Margin { + left: 12, + right: 12, + top: 0, + bottom: 10, + }); + } + frame + .show(ui, |ui| { + ConversationListUi::new(cache, states, jobs, ndb, img_cache, i18n) + .ui(ui, selected_pubkey) + }) + .inner + } + Route::CreateConvo => 's: { + let Some(r) = CreateConvoUi::new(ndb, jobs, img_cache, contacts, i18n).ui(ui) else { + break 's None; + }; + + Some(MessagesAction::Create { + recipient: r.recipient, + }) + } + Route::Conversation => conversation_ui( + cache, + states, + jobs, + ndb, + ui, + img_cache, + i18n, + selected_pubkey, + ), + }; + + RouteResponse { + response, + can_take_drag_from: vec![], + } +} + +pub struct NavTitle<'a> { + routes: &'a [Route], + cache: &'a ConversationCache, + jobs: &'a MediaJobSender, + ndb: &'a Ndb, + selected_pubkey: &'a Pubkey, + img_cache: &'a mut Images, + i18n: &'a mut Localization, +} + +impl<'a> NavTitle<'a> { + pub fn new( + routes: &'a [Route], + cache: &'a ConversationCache, + jobs: &'a MediaJobSender, + ndb: &'a Ndb, + selected_pubkey: &'a Pubkey, + img_cache: &'a mut Images, + i18n: &'a mut Localization, + ) -> Self { + Self { + routes, + cache, + jobs, + ndb, + selected_pubkey, + img_cache, + i18n, + } + } + + pub fn show(&mut self, ui: &mut egui::Ui) -> Option<MessagesAction> { + self.title_bar(ui) + } + + fn title_bar(&mut self, ui: &mut egui::Ui) -> Option<MessagesAction> { + let top = self.routes.last()?; + + let mut right_action = None; + let mut left_action = None; + + HorizontalHeader::new(48.0) + .with_margin(Margin::symmetric(12, 8)) + .ui( + ui, + 0, + 1, + 2, + |ui: &mut egui::Ui| { + let chev_width = 12.0; + left_action = if prev(self.routes).is_some() { + back_button(ui, egui::vec2(chev_width, 20.0)) + .on_hover_cursor(CursorIcon::PointingHand) + .clicked() + .then_some(MessagesAction::Back) + } else { + ui.add(app_images::damus_image().max_width(32.0)) + .interact(Sense::click()) + .on_hover_cursor(CursorIcon::PointingHand) + .clicked() + .then_some(MessagesAction::ToggleChrome) + } + }, + |ui| { + self.title(ui, top); + }, + |ui: &mut egui::Ui| match top { + Route::ConvoList => { + let new_msg_icon = app_images::new_message_image().max_height(24.0); + if ui + .add(new_msg_icon) + .on_hover_cursor(CursorIcon::PointingHand) + .interact(egui::Sense::click()) + .clicked() + { + tracing::info!("CLICKED NEW MSG"); + right_action = Some(MessagesAction::Creating); + } + } + Route::CreateConvo => {} + Route::Conversation => {} + }, + ); + + right_action.or(left_action) + } + + fn title(&mut self, ui: &mut egui::Ui, route: &Route) { + match route { + Route::ConvoList => { + let label = tr!( + self.i18n, + "Chats", + "Title for the list of chat conversations" + ); + title_label(ui, &label); + } + Route::CreateConvo => { + let label = tr!( + self.i18n, + "New Chat", + "Title shown when composing a new conversation" + ); + title_label(ui, &label); + } + Route::Conversation => self.conversation_title_section(ui), + } + } + + fn conversation_title_section(&mut self, ui: &mut egui::Ui) { + conversation_header_impl( + ui, + self.i18n, + self.cache, + self.selected_pubkey, + self.ndb, + self.jobs, + self.img_cache, + ); + } +} + +fn back_button(ui: &mut egui::Ui, chev_size: egui::Vec2) -> egui::Response { + let color = ui.style().visuals.noninteractive().fg_stroke.color; + chevron(ui, 2.0, chev_size, egui::Stroke::new(2.0, color)) +} + +fn prev<R>(xs: &[R]) -> Option<&R> { + xs.get(xs.len().checked_sub(2)?) +} diff --git a/crates/notedeck_ui/src/app_images.rs b/crates/notedeck_ui/src/app_images.rs @@ -152,7 +152,7 @@ pub fn link_light_image() -> Image<'static> { } pub fn new_message_image() -> Image<'static> { - Image::new(include_image!("../../../assets/icons/newmessage_64.png")) + Image::new(include_image!("../../../assets/icons/new-message.svg")) } pub fn new_deck_image() -> Image<'static> { diff --git a/crates/notedeck_ui/src/contacts_list.rs b/crates/notedeck_ui/src/contacts_list.rs @@ -0,0 +1,127 @@ +use std::collections::HashSet; + +use crate::ProfilePic; +use egui::{RichText, Sense}; +use enostr::Pubkey; +use nostrdb::{Ndb, Transaction}; +use notedeck::{ + name::get_display_name, profile::get_profile_url, DragResponse, Images, MediaJobSender, +}; + +pub struct ContactsListView<'a, 'txn> { + contacts: ContactsCollection<'a>, + jobs: &'a MediaJobSender, + ndb: &'a Ndb, + img_cache: &'a mut Images, + txn: &'txn Transaction, +} + +#[derive(Clone)] +pub enum ContactsListAction { + Select(Pubkey), +} + +pub enum ContactsCollection<'a> { + Vec(&'a Vec<Pubkey>), + Set(&'a HashSet<Pubkey>), +} + +pub enum ContactsIter<'a> { + Vec(std::slice::Iter<'a, Pubkey>), + Set(std::collections::hash_set::Iter<'a, Pubkey>), +} + +impl<'a> ContactsCollection<'a> { + pub fn iter(&'a self) -> ContactsIter<'a> { + match self { + ContactsCollection::Vec(v) => ContactsIter::Vec(v.iter()), + ContactsCollection::Set(s) => ContactsIter::Set(s.iter()), + } + } +} + +impl<'a> Iterator for ContactsIter<'a> { + type Item = &'a Pubkey; + + fn next(&mut self) -> Option<Self::Item> { + match self { + ContactsIter::Vec(iter) => iter.next().as_ref().copied(), + ContactsIter::Set(iter) => iter.next().as_ref().copied(), + } + } +} + +impl<'a, 'txn> ContactsListView<'a, 'txn> { + pub fn new( + contacts: ContactsCollection<'a>, + jobs: &'a MediaJobSender, + ndb: &'a Ndb, + img_cache: &'a mut Images, + txn: &'txn Transaction, + ) -> Self { + ContactsListView { + contacts, + ndb, + img_cache, + txn, + jobs, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<ContactsListAction> { + let mut action = None; + + egui::ScrollArea::vertical().show(ui, |ui| { + let clip_rect = ui.clip_rect(); + + for contact_pubkey in self.contacts.iter() { + let (rect, resp) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 56.0), Sense::click()); + + if !clip_rect.intersects(rect) { + continue; + } + + let profile = self + .ndb + .get_profile_by_pubkey(self.txn, contact_pubkey.bytes()) + .ok(); + + let display_name = get_display_name(profile.as_ref()); + let name_str = display_name.display_name.unwrap_or("Anonymous"); + let profile_url = get_profile_url(profile.as_ref()); + + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); + + if resp.hovered() { + ui.painter() + .rect_filled(rect, 0.0, ui.visuals().widgets.hovered.weak_bg_fill); + } + + let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + child_ui.horizontal(|ui| { + ui.add_space(16.0); + + ui.add(&mut ProfilePic::new(self.img_cache, self.jobs, profile_url).size(48.0)); + + ui.add_space(12.0); + + ui.add( + egui::Label::new( + RichText::new(name_str) + .size(16.0) + .color(ui.visuals().text_color()), + ) + .selectable(false), + ); + }); + + if resp.clicked() { + action = Some(ContactsListAction::Select(*contact_pubkey)); + } + } + }); + + DragResponse::output(action) + } +} diff --git a/crates/notedeck_ui/src/header.rs b/crates/notedeck_ui/src/header.rs @@ -0,0 +1,172 @@ +use egui::{Frame, Layout, Margin, Stroke, UiBuilder}; +use egui_extras::{Size, StripBuilder}; + +pub fn chevron( + ui: &mut egui::Ui, + pad: f32, + size: egui::Vec2, + stroke: impl Into<Stroke>, +) -> egui::Response { + let (r, painter) = ui.allocate_painter(size, egui::Sense::click()); + + let min = r.rect.min; + let max = r.rect.max; + + let apex = egui::Pos2::new(min.x + pad, min.y + size.y / 2.0); + let top = egui::Pos2::new(max.x - pad, min.y + pad); + let bottom = egui::Pos2::new(max.x - pad, max.y - pad); + + let stroke = stroke.into(); + painter.line_segment([apex, top], stroke); + painter.line_segment([apex, bottom], stroke); + + r +} + +/// Generic UI Widget to render widgets horizontally where each is aligned vertically +pub struct HorizontalHeader { + height: f32, + margin: Margin, + layout: Layout, +} + +impl HorizontalHeader { + pub fn new(height: f32) -> Self { + Self { + height, + margin: Margin::same(8), + layout: Layout::left_to_right(egui::Align::Center), + } + } + + pub fn with_margin(mut self, margin: Margin) -> Self { + self.margin = margin; + self + } + + #[allow(clippy::too_many_arguments)] + pub fn ui( + self, + ui: &mut egui::Ui, + left_priority: i8, // lower the value, higher the priority + center_priority: i8, + right_priority: i8, + left_aligned: impl FnMut(&mut egui::Ui), + centered: impl FnMut(&mut egui::Ui), + right_aligned: impl FnMut(&mut egui::Ui), + ) { + let prev_spacing = ui.spacing().item_spacing.y; + ui.spacing_mut().item_spacing.y = 0.0; + Frame::new().inner_margin(self.margin).show(ui, |ui| { + let mut rect = ui.available_rect_before_wrap(); + rect.set_height(self.height); + + let mut child_ui = ui.new_child(UiBuilder::new().max_rect(rect)); + + horizontal_header_inner( + &mut child_ui, + self.layout, + left_priority, + center_priority, + right_priority, + left_aligned, + centered, + right_aligned, + ); + ui.advance_cursor_after_rect(rect); + }); + ui.spacing_mut().item_spacing.y = prev_spacing; + } +} + +#[allow(clippy::too_many_arguments)] +fn horizontal_header_inner( + ui: &mut egui::Ui, + layout: Layout, + left_priority: i8, // lower the value, higher the priority + center_priority: i8, + right_priority: i8, + left_aligned: impl FnMut(&mut egui::Ui), + centered: impl FnMut(&mut egui::Ui), + right_aligned: impl FnMut(&mut egui::Ui), +) { + let item_spacing = 6.0 * ui.spacing().item_spacing.x; + let max_width = ui.available_width() - item_spacing; + + let (left_width, left_aligned) = measure_width(ui, left_aligned); + let (center_width, centered) = measure_width(ui, centered); + let (right_width, right_aligned) = measure_width(ui, right_aligned); + + let half_max = max_width / 2.0; + let half_center = center_width / 2.0; + let left_spacing = half_max - left_width - half_center; + let right_spacing = half_max - right_width - half_center; + + let mut left_center = half_center; + let left_cell = if left_spacing > 0.0 || left_priority < center_priority { + Size::exact(left_width) + } else { + Size::remainder() + }; + let mut left_gap = Size::exact(left_spacing.max(0.0)); + + if left_spacing <= 0.0 { + left_gap = Size::exact(0.0); + if left_priority < center_priority { + left_center = (half_center + left_spacing).max(0.0); + } + } + + let mut center_cell = Size::exact((left_center + half_center).max(0.0)); + let mut right_gap = Size::exact(right_spacing.max(0.0)); + let mut right_cell = Size::exact(right_width); + + if right_spacing <= 0.0 { + right_gap = Size::exact(0.0); + if center_priority < right_priority { + right_cell = Size::remainder(); + } else { + center_cell = Size::remainder(); + } + } + + let sizes = [left_cell, left_gap, center_cell, right_gap, right_cell]; + + let mut builder = StripBuilder::new(ui); + for size in sizes { + builder = builder.size(size); + } + + builder.cell_layout(layout).horizontal(|mut strip| { + strip.cell(left_aligned); + strip.empty(); + strip.cell(centered); + strip.empty(); + strip.cell(right_aligned); + }); +} + +/// Inspired by VirtualList::ui_custom_layout +fn measure_width( + ui: &mut egui::Ui, + mut render: impl FnMut(&mut egui::Ui), +) -> (f32, impl FnMut(&mut egui::Ui)) { + let mut measure_ui = ui.new_child( + UiBuilder::new() + .max_rect(ui.max_rect()) + .layout(Layout::left_to_right(egui::Align::Min)), + ); + measure_ui.set_invisible(); + + let start_width = measure_ui.next_widget_position(); + let res = measure_ui.scope_builder(UiBuilder::new().id_salt(ui.id().with("measure")), |ui| { + render(ui); + render + }); + let end_width = measure_ui.next_widget_position(); + + ( + (end_width.x - start_width.x + ui.spacing().item_spacing.x).max(0.0), + res.inner, + ) +} diff --git a/crates/notedeck_ui/src/lib.rs b/crates/notedeck_ui/src/lib.rs @@ -2,8 +2,10 @@ pub mod anim; pub mod app_images; pub mod colors; pub mod constants; +pub mod contacts_list; pub mod context_menu; pub mod debug; +pub mod header; pub mod icons; pub mod images; pub mod media; @@ -15,6 +17,7 @@ mod username; pub mod widgets; pub use anim::{rolling_number, AnimationHelper, PulseAlpha}; +pub use contacts_list::{ContactsListAction, ContactsListView}; pub use debug::debug_slider; pub use icons::{expanding_button, ICON_EXPANSION_MULTIPLE, ICON_WIDTH}; pub use mention::Mention;