use std::collections::{HashMap, HashSet}; use std::fmt; use anyhow::Result; use community::{ ChannelId, ChatMessage, Community, CommunityEvent, Epoch, Intent, LOAD_OLDER_PAGES, TIMELINE_PAGE, Timeline, }; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, }; use nostr_sdk::prelude::EventId; use smallvec::{SmallVec, smallvec}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::input::{InputEvent, Textarea, TextareaState}; use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; mod message; /// How near the top row a scroll has to come before the panel splices older history in. const LOAD_OLDER_THRESHOLD: usize = 20; /// Why a room is not showing the messages it knows about. /// /// "No messages yet" is a claim, and a room we cannot read is not an empty one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Notice { Stranded, Removed(Epoch), ChannelRemoved(Epoch), MissingKey(Epoch), Unreachable, Unreadable(usize), } impl fmt::Display for Notice { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Notice::Stranded => formatter.write_str( "This invite is stale, the community has rotated past the epoch it names", ), Notice::Removed(epoch) => write!( formatter, "You were removed from this community at epoch {}. Its history stays readable", epoch.0 ), Notice::ChannelRemoved(epoch) => write!( formatter, "A rotation removed you from this channel at epoch {}", epoch.0 ), Notice::MissingKey(epoch) => write!( formatter, "Messages here can't be read yet — this channel's key for epoch {} is missing", epoch.0 ), Notice::Unreachable => formatter.write_str("Couldn't reach the community's relays"), Notice::Unreadable(1) => { formatter.write_str("1 message here can't be read yet — no key we hold opens it") } Notice::Unreadable(count) => write!( formatter, "{count} messages here can't be read yet — no key we hold opens them" ), } } } impl Notice { /// Whether the room still accepts writes, rather than only being readable. fn writable(self) -> bool { matches!(self, Notice::Unreachable | Notice::Unreadable(_)) } } pub fn init( community: Entity, window: &mut Window, cx: &mut App, ) -> Entity { cx.new(|cx| CommunityPanel::new(community, window, cx)) } /// Community Panel pub struct CommunityPanel { id: SharedString, focus_handle: FocusHandle, /// Community community: WeakEntity, /// The selected channel channel: Option, /// The selected channel's timeline (oldest first) rows: Vec, /// Whether the store holds rows older than `rows` has_more: bool, /// A round or a page read is in flight loading: bool, /// Message list state list_state: ListState, /// Message input state input: Entity, /// Spawned reads and publishes, cancelled when the panel closes tasks: SmallVec<[Task>; 4]>, /// Event subscriptions _subscriptions: SmallVec<[Subscription; 2]>, } impl CommunityPanel { pub fn new(community: Entity, window: &mut Window, cx: &mut Context) -> Self { let (id, name, channel) = { let community = community.read(cx); ( SharedString::from(format!("community-{}", community.id().to_hex())), community.name(), community.active_channel(), ) }; let input = cx.new(|cx| { TextareaState::new(window, cx) .placeholder(format!("Message {name}")) .auto_grow(1, 20) .clean_on_escape() }); let mut subscriptions = smallvec![]; subscriptions.push( cx.subscribe_in(&input, window, |this, _input, event, window, cx| { if let InputEvent::PressEnter { .. } = event { this.send(window, cx); } }), ); subscriptions.push(cx.subscribe_in( &community, window, |_this, _community, event, window, cx| { match event { CommunityEvent::Updated(_) | CommunityEvent::Unreadable(_) | CommunityEvent::Failed(_) => { cx.defer_in(window, |this, window, cx| this.reload(window, cx)); } CommunityEvent::Channel(..) => { cx.defer_in(window, |this, window, cx| this.load(window, cx)); } CommunityEvent::Error(error) => { window.push_notification( Notification::error(error.clone()).autohide(false), cx, ); } CommunityEvent::Open(_) | CommunityEvent::Close(_) => {} }; }, )); let panel = Self { id, focus_handle: cx.focus_handle(), community: community.downgrade(), channel, rows: Vec::new(), has_more: false, loading: false, list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)), input, tasks: smallvec![], _subscriptions: subscriptions, }; panel.list_state.set_follow_mode(FollowMode::Tail); panel.list_state.set_scroll_handler(cx.listener( |this, event: &ListScrollEvent, window, cx| { if event.visible_range.start <= LOAD_OLDER_THRESHOLD { this.load_older(window, cx); } }, )); cx.defer_in(window, |this, window, cx| { this.load(window, cx); }); panel } /// The channel to show, following the community's selection. fn resolve_channel(&mut self, cx: &App) -> Option { let channel = self .community .read_with(cx, |community, _cx| community.active_channel()) .ok() .flatten(); if channel != self.channel { self.channel = channel; self.rows.clear(); self.has_more = false; self.loading = false; self.list_state.reset(1); } channel } /// The list's item count: the load-older row, then every message row. fn item_count(&self) -> usize { self.rows.len() + 1 } /// A timeline read, `before_ms` exclusive, or `None` for the newest rows. fn read( &self, channel: ChannelId, before_ms: Option, cx: &App, ) -> Option>> { self.community .read_with(cx, |community, cx| { community.timeline(&channel, before_ms, TIMELINE_PAGE, cx) }) .ok() } /// A channel round, or `None` once the community is gone. fn sync( &self, channel: ChannelId, intent: Intent, cx: &mut App, ) -> Option>> { self.community .update(cx, |community, cx| { community.sync_channel(&channel, intent, cx) }) .ok() } /// Paint the selected channel's cache, then catch it up from the relays. fn load(&mut self, window: &mut Window, cx: &mut Context) { let Some(channel) = self.resolve_channel(cx) else { return; }; self.reload(window, cx); // Opening a channel twice in a breath asks the relays once. if self.due(channel, cx) { self.round(channel, Intent::CatchUp, window, cx); } } /// Whether the community would actually round `channel`, or just serve it. fn due(&self, channel: ChannelId, cx: &App) -> bool { self.community .read_with(cx, |community, _cx| community.due(&channel)) .unwrap_or(true) } /// Why the room is not showing messages, when it is not simply empty. fn notice(&self, cx: &App) -> Option { let channel = self.channel?; self.community .read_with(cx, |community, _cx| { if community.stranded() { return Some(Notice::Stranded); } if let Some(epoch) = community.removed_at() { return Some(Notice::Removed(epoch)); } if let Some(epoch) = community.channel_removed_at(&channel) { return Some(Notice::ChannelRemoved(epoch)); } if let Some(epoch) = community.missing_key(&channel) { return Some(Notice::MissingKey(epoch)); } if community .progress(&channel) .is_some_and(|progress| progress.failed && progress.errors > 0) { return Some(Notice::Unreachable); } let unreadable = community.unreadable(&channel); (unreadable > 0).then_some(Notice::Unreadable(unreadable)) }) .ok() .flatten() } /// Run a round for `channel`, its completion re-reads the timeline. fn round( &mut self, channel: ChannelId, intent: Intent, window: &mut Window, cx: &mut Context, ) { let Some(round) = self.sync(channel, intent, cx) else { return; }; self.loading = true; cx.notify(); let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { let result = round.await; this.update_in(cx, |this, window, cx| { this.loading = false; if let Err(error) = result { window.push_notification( Notification::error(error.to_string()).autohide(false), cx, ); } })?; Ok(()) }); self.tasks.push(task); } /// Run the newest round again after a relay failure. fn retry(&mut self, window: &mut Window, cx: &mut Context) { let Some(channel) = self.channel else { return; }; self.round(channel, Intent::CatchUp, window, cx); } /// Read the newest page and fold it into what is on screen. fn reload(&mut self, window: &mut Window, cx: &mut Context) { let Some(channel) = self.resolve_channel(cx) else { return; }; let Some(timeline) = self.read(channel, None, cx) else { return; }; let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { match timeline.await { Ok(timeline) => this.update(cx, |this, cx| this.apply(channel, timeline, cx))?, Err(error) => { this.update_in(cx, |_this, window, cx| { window.push_notification( Notification::error(error.to_string()).autohide(false), cx, ); })?; } } Ok(()) }); self.tasks.push(task); } /// Splice the page of history above the oldest row on screen. fn load_older(&mut self, window: &mut Window, cx: &mut Context) { if self.loading || !self.has_more { return; } let Some(channel) = self.channel else { return; }; let Some(before_ms) = self .rows .first() .map(|message| message.at_ms.saturating_sub(1)) else { return; }; let Some(page) = self.read(channel, Some(before_ms), cx) else { return; }; self.loading = true; let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { let timeline = match page.await { Ok(timeline) => timeline, Err(error) => { this.update_in(cx, |this, window, cx| { this.loading = false; window.push_notification( Notification::error(error.to_string()).autohide(false), cx, ); })?; return Ok(()); } }; let swept = this.update(cx, |this, cx| { this.prepend(channel, timeline, cx); !this.has_more })?; if !swept { this.update(cx, |this, _cx| this.loading = false)?; return Ok(()); } let round = this.update(cx, |this, cx| { this.sync( channel, Intent::Older { pages: LOAD_OLDER_PAGES, }, cx, ) })?; let Some(round) = round else { this.update(cx, |this, _cx| this.loading = false)?; return Ok(()); }; match round.await { Ok(_) => { let page = this.update(cx, |this, cx| this.read(channel, Some(before_ms), cx))?; if let Some(page) = page { match page.await { Ok(timeline) => { this.update(cx, |this, cx| this.prepend(channel, timeline, cx))? } Err(error) => this.update_in(cx, |_this, window, cx| { window.push_notification( Notification::error(error.to_string()).autohide(false), cx, ); })?, } } this.update(cx, |this, _cx| this.loading = false)?; } Err(error) => { this.update_in(cx, |this, window, cx| { this.loading = false; window.push_notification( Notification::error(error.to_string()).autohide(false), cx, ); })?; } } Ok(()) }); self.tasks.push(task); } /// Fold a freshly read window into the rows on screen. fn apply(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context) { if self.channel != Some(channel) { return; } let Timeline { messages, has_more } = timeline; let connected = self .rows .last() .is_some_and(|last| messages.iter().any(|message| message.id == last.id)); if !connected { self.rows = messages; self.has_more = has_more; self.list_state.reset(self.item_count()); cx.notify(); return; } let mut index: HashMap = self .rows .iter() .enumerate() .map(|(ix, message)| (message.id, ix)) .collect(); let mut added = Vec::new(); for message in messages { match index.get(&message.id).copied() { Some(ix) => self.rows[ix] = message, None => { index.insert(message.id, self.rows.len() + added.len()); added.push(message); } } } if !added.is_empty() { let at = self.item_count(); let count = added.len(); self.rows.extend(added); self.list_state.splice(at..at, count); } cx.notify(); } /// Splice older rows in above what is on screen. fn prepend(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context) { if self.channel != Some(channel) { return; } let known: HashSet = self.rows.iter().map(|message| message.id).collect(); let added: Vec = timeline .messages .into_iter() .filter(|message| !known.contains(&message.id)) .collect(); self.has_more = timeline.has_more; if !added.is_empty() { let count = added.len(); self.rows.splice(0..0, added); self.list_state.splice(1..1, count); } cx.notify(); } fn send(&mut self, window: &mut Window, cx: &mut Context) { let content = self.input.read(cx).value().trim().to_owned(); if content.is_empty() { window.push_notification("Cannot send an empty message", cx); return; } let Some(channel) = self.resolve_channel(cx) else { return; }; // A rotation that excluded us moved the write plane out of reach, and the // community refuses to seal a message nobody could read. The notice above // the list is what says why. if let Some(notice) = self.notice(cx).filter(|notice| !notice.writable()) { window.push_notification(Notification::error(notice.to_string()).autohide(false), cx); return; } let Ok(send) = self.community.read_with(cx, |community, cx| { community.send(&channel, &content, None, cx) }) else { return; }; let Some(send) = send else { window.push_notification(Notification::error("Failed to send the message"), cx); return; }; self.input.update(cx, |input, cx| { input.set_value("", window, cx); }); let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { match send.await { Ok(_) => { this.update_in(cx, |this, window, cx| this.reload(window, cx))?; } Err(error) => { this.update_in(cx, |_this, window, cx| { window.push_notification( Notification::error(error.to_string()).autohide(false), cx, ); })?; } } Ok(()) }); self.tasks.push(task); } /// The honest reason this room has nothing to show, with a way out of it. fn render_notice(&self, notice: Notice, cx: &mut Context) -> AnyElement { h_flex() .w_full() .justify_center() .items_center() .gap_2() .px_3() .py_2() .text_sm() .text_color(cx.theme().text_placeholder) .child(notice.to_string()) .when(notice == Notice::Unreachable, |this| { this.child( Button::new("retry-round") .label("Retry") .ghost() .small() .loading(self.loading) .on_click(cx.listener(|this, _event, window, cx| this.retry(window, cx))), ) }) .into_any_element() } /// The row at index 0: the affordance that pages older history in. fn render_older(&self, cx: &mut Context) -> AnyElement { if !self.has_more { return div().into_any_element(); } h_flex() .w_full() .justify_center() .py_2() .child( Button::new("load-older") .label(if self.loading { "Loading earlier messages…" } else { "Load earlier messages" }) .ghost() .small() .loading(self.loading) .on_click(cx.listener(|this, _event, window, cx| this.load_older(window, cx))), ) .into_any_element() } fn render_message( &mut self, ix: usize, _window: &mut Window, cx: &mut Context, ) -> AnyElement { if ix == 0 { return self.render_older(cx); } let Some(message) = self.rows.get(ix - 1) else { return div().into_any_element(); }; message::render(ix, message, cx) } fn render_composer(&self, cx: &mut Context) -> impl IntoElement { let writable = self.notice(cx).is_none_or(|notice| notice.writable()); h_flex() .flex_shrink_0() .w_full() .p_2() .gap_1() .items_end() .child(Textarea::new(&self.input).appearance(false).flex_1()) .child( Button::new("send") .icon(IconName::PaperPlaneFill) .tooltip("Send") .ghost() .large() .disabled(!writable) .on_click(cx.listener(|this, _event, window, cx| { this.send(window, cx); })), ) } } impl Panel for CommunityPanel { fn panel_id(&self) -> SharedString { self.id.clone() } fn title(&self, cx: &App) -> AnyElement { self.community .read_with(cx, |community, _cx| { let seed = community.id().to_hex(); let avatar = match community.icon() { Some(path) => Avatar::from_source(path).seed(seed).xsmall(), None => Avatar::new(None).seed(seed).xsmall(), }; h_flex() .gap_1p5() .child(avatar) .child(SharedString::from(community.name())) .into_any_element() }) .unwrap_or_else(|_| div().child("Unknown").into_any_element()) } fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec