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:
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(¬e);
+
+ 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, ¬e);
+ 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(¬e, "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(¬e) {
+ 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(¬e) 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;