diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index 144bc42d..cca2489a 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -13,21 +13,51 @@ use state::NostrRegistry; use crate::LOCAL_KEYS; use crate::protocol::{ - GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupParticipants, GroupPins, - GroupRoles, LivekitSession, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, - STATE_KINDS, TimelineWindow, + ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, + GroupParticipants, GroupPins, GroupRoles, LivekitSession, MEMBERSHIP_KINDS, Membership, + PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, TimelineWindow, }; const REPLAY_LIMIT: usize = 100; const PAGE_LIMIT: usize = 50; const PAGE_TIMEOUT: Duration = Duration::from_secs(10); const LIVEKIT_TIMEOUT: Duration = Duration::from_secs(10); +const LEAVE_WINDOW_SECS: u64 = 5 * 60; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Absorbed { Ignored, - Rendered { fresh: bool }, - Changed, + Shown { fresh: bool, updated: bool }, + Updated, +} + +#[derive(Debug, Clone)] +pub enum Row { + Message(Box), + Activity(Activity), +} + +impl Row { + pub fn id(&self) -> EventId { + match self { + Self::Message(message) => message.id, + Self::Activity(activity) => activity.id, + } + } + + pub fn author(&self) -> PublicKey { + match self { + Self::Message(message) => message.author, + Self::Activity(activity) => activity.author, + } + } + + pub fn created_at(&self) -> Timestamp { + match self { + Self::Message(message) => message.created_at, + Self::Activity(activity) => activity.created_at, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -51,8 +81,9 @@ pub struct Group { membership_events: Vec, elsewhere: Option, state_ids: HashMap, - messages: Vec, + rows: Vec, index: HashSet, + leave_requests: HashMap, window: TimelineWindow, oldest: Option, loading: bool, @@ -77,8 +108,9 @@ impl Group { membership_events: Vec::new(), elsewhere: None, state_ids: HashMap::new(), - messages: Vec::new(), + rows: Vec::new(), index: HashSet::new(), + leave_requests: HashMap::new(), window: TimelineWindow::new(), oldest: None, loading: true, @@ -141,8 +173,15 @@ impl Group { self.elsewhere.as_ref() } - pub fn messages(&self) -> &[Message] { - &self.messages + pub fn rows(&self) -> &[Row] { + &self.rows + } + + pub fn message(&self, id: EventId) -> Option<&Message> { + self.rows.iter().find_map(|row| match row { + Row::Message(message) if message.id == id => Some(message.as_ref()), + _ => None, + }) } pub fn loading(&self) -> bool { @@ -311,11 +350,13 @@ impl Group { log::warn!("nip29: connecting {relay} failed: {error}"); } - let filters = vec![ + let filters = [ key.id().timeline_filter(REPLAY_LIMIT), - key.id().state_filter(), key.id().membership_filter(me), - ]; + ] + .into_iter() + .chain(key.id().state_filters()) + .collect::>(); if let Err(error) = client .subscribe(ReqTarget::single(relay, filters)) @@ -485,9 +526,19 @@ impl Group { for event in &events { match self.absorb(event, me) { - Absorbed::Rendered { fresh: true } => rendered.push(event.clone()), - Absorbed::Changed => changed = true, - _ => {} + Absorbed::Shown { + fresh: true, + updated, + } => { + rendered.push(event.clone()); + changed |= updated; + } + Absorbed::Shown { + fresh: false, + updated, + } => changed |= updated, + Absorbed::Updated => changed = true, + Absorbed::Ignored => {} } } @@ -509,7 +560,7 @@ impl Group { return; } - self.messages.retain(|message| message.id != id); + self.rows.retain(|row| row.id() != id); cx.emit(GroupEvent::Updated); cx.notify(); } @@ -530,29 +581,7 @@ impl Group { fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed { if STATE_KINDS.contains(&event.kind) { return if self.absorb_state(event) { - Absorbed::Changed - } else { - Absorbed::Ignored - }; - } - - if MEMBERSHIP_KINDS.contains(&event.kind) { - self.track(event); - - if !event.tags.public_keys().any(|key| key == me) { - return Absorbed::Ignored; - } - - if !self - .membership_events - .iter() - .any(|existing| existing.id == event.id) - { - self.membership_events.push(event.clone()); - } - - return if self.refresh_membership(me) { - Absorbed::Changed + Absorbed::Updated } else { Absorbed::Ignored }; @@ -560,15 +589,49 @@ impl Group { self.track(event); + let updated = self.absorb_membership(event, me); + + if ACTIVITY_KINDS.contains(&event.kind) { + return Absorbed::Shown { + fresh: self.push_activity(event), + updated, + }; + } + if RENDER_KINDS.contains(&event.kind) { - Absorbed::Rendered { - fresh: self.push_message(event), - } + return Absorbed::Shown { + fresh: self.push_row(event), + updated, + }; + } + + if updated { + Absorbed::Updated } else { Absorbed::Ignored } } + fn absorb_membership(&mut self, event: &Event, me: PublicKey) -> bool { + if !MEMBERSHIP_KINDS.contains(&event.kind) { + return false; + } + + if !event.tags.public_keys().any(|key| key == me) { + return false; + } + + if !self + .membership_events + .iter() + .any(|existing| existing.id == event.id) + { + self.membership_events.push(event.clone()); + } + + self.refresh_membership(me) + } + fn track(&mut self, event: &Event) { self.window.push(event.clone()); @@ -609,17 +672,61 @@ impl Group { self.state_ids.insert(event.kind, event.id) != Some(event.id) } - fn push_message(&mut self, event: &Event) -> bool { + fn push_row(&mut self, event: &Event) -> bool { if !self.index.insert(event.id) { return false; } let message = Message::from(event); let position = self - .messages - .partition_point(|existing| existing.created_at <= message.created_at); + .rows + .partition_point(|row| row.created_at() <= message.created_at); - self.messages.insert(position, message); + self.rows.insert(position, Row::Message(Box::new(message))); + + true + } + + fn push_activity(&mut self, event: &Event) -> bool { + let activity = match Activity::parse(event) { + Ok(activity) => activity, + Err(error) => { + log::warn!("nip29: ignoring a malformed group activity: {error}"); + return false; + } + }; + + if activity.kind == Kind::GroupPutUser && activity.roles.is_empty() { + return false; + } + + if activity.kind == Kind::GroupRemoveUser + && self + .leave_requests + .get(&activity.subject()) + .is_some_and(|left| { + activity.created_at >= *left + && activity.created_at.as_secs().saturating_sub(left.as_secs()) + <= LEAVE_WINDOW_SECS + }) + { + return false; + } + + if !self.index.insert(activity.id) { + return false; + } + + if activity.kind == Kind::GroupLeaveRequest { + self.leave_requests + .insert(activity.subject(), activity.created_at); + } + + let position = self + .rows + .partition_point(|row| row.created_at() <= activity.created_at); + + self.rows.insert(position, Row::Activity(activity)); true } diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index 0e7fc91e..8c38bb19 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -33,6 +33,13 @@ pub const STATE_KINDS: [Kind; 6] = [ pub const MEMBERSHIP_KINDS: [Kind; 2] = [Kind::GroupPutUser, Kind::GroupRemoveUser]; +pub const ACTIVITY_KINDS: [Kind; 4] = [ + Kind::GroupPutUser, + Kind::GroupRemoveUser, + Kind::GroupJoinRequest, + Kind::GroupLeaveRequest, +]; + fn has_tag(tags: &Tags, name: &str) -> bool { tags.iter().any(|tag| tag.kind() == name) } @@ -76,8 +83,11 @@ impl GroupId { self.timeline_filter(limit).until(until) } - pub fn state_filter(&self) -> Filter { - Filter::new().identifier(self.0.clone()).kinds(STATE_KINDS) + pub fn state_filters(&self) -> Vec { + STATE_KINDS + .iter() + .map(|kind| Filter::new().identifier(self.0.clone()).kind(*kind)) + .collect() } pub fn membership_filter(&self, me: PublicKey) -> Filter { @@ -815,6 +825,57 @@ impl Membership { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Activity { + pub id: EventId, + pub kind: Kind, + pub author: PublicKey, + pub target: Option, + pub roles: Vec, + pub created_at: Timestamp, +} + +impl Activity { + pub fn parse(event: &Event) -> Result { + if !ACTIVITY_KINDS.contains(&event.kind) { + bail!("not a group activity event: kind {}", event.kind); + } + + let mut fields = event + .tags + .iter() + .find(|tag| tag.kind() == "p") + .map(|tag| tag.as_slice().iter().skip(1)) + .into_iter() + .flatten(); + + let target = match fields.next() { + Some(value) => Some(PublicKey::from_hex(value)?), + None => None, + }; + + if target.is_none() && MEMBERSHIP_KINDS.contains(&event.kind) { + bail!("a membership event has no p tag"); + } + + Ok(Self { + id: event.id, + kind: event.kind, + author: event.pubkey, + target, + roles: fields.cloned().collect(), + created_at: event.created_at, + }) + } + + pub fn subject(&self) -> PublicKey { + match self.kind { + Kind::GroupPutUser | Kind::GroupRemoveUser => self.target.unwrap_or(self.author), + _ => self.author, + } + } +} + #[derive(Debug, Clone, Default)] pub struct TimelineWindow(Vec); diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index e57d6c42..e5c036be 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -1,7 +1,6 @@ use std::collections::BTreeMap; use anyhow::Result; -use chat::Message; use chat_ui::text::rendered_text; use gpui::prelude::FluentBuilder; use gpui::{ @@ -9,7 +8,7 @@ use gpui::{ Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, }; -use nip29::{Group, GroupEvent, GroupMetadata, GroupsRegistry, Membership, Pin}; +use nip29::{Group, GroupEvent, GroupMetadata, GroupsRegistry, Membership, Pin, Row}; use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; @@ -88,20 +87,23 @@ fn display_name(group: &Group) -> SharedString { ) } -fn opens_run(messages: &[Message], index: usize) -> bool { - let Some(current) = messages.get(index) else { +fn opens_run(rows: &[Row], index: usize) -> bool { + let (Some(current), Some(previous)) = ( + rows.get(index), + index.checked_sub(1).and_then(|index| rows.get(index)), + ) else { return true; }; - let Some(previous) = index.checked_sub(1).and_then(|index| messages.get(index)) else { + if !matches!((current, previous), (Row::Message(_), Row::Message(_))) { return true; - }; + } - current.author != previous.author + current.author() != previous.author() || current - .created_at + .created_at() .as_secs() - .saturating_sub(previous.created_at.as_secs()) + .saturating_sub(previous.created_at().as_secs()) > RUN_WINDOW_SECS } @@ -123,9 +125,7 @@ pub(crate) fn pin_label(group: &Group, pin: &Pin) -> SharedString { Pin::Address(coordinate) => SharedString::from(format!("{coordinate}")), Pin::Event(id) => { let snippet = group - .messages() - .iter() - .find(|message| message.id == *id) + .message(*id) .and_then(|message| message.content.lines().next()) .map(str::trim) .filter(|line| !line.is_empty()); @@ -154,13 +154,13 @@ impl GroupPanel { pub fn new(group: Entity, window: &mut Window, cx: &mut Context) -> Self { let (id, name, count, head) = { let group = group.read(cx); - let messages = group.messages(); + let rows = group.rows(); ( SharedString::from(format!("group-{}", group.key().cache_tag())), display_name(group), - messages.len(), - messages.first().map(|message| message.id), + rows.len(), + rows.first().map(Row::id), ) }; @@ -220,7 +220,7 @@ impl GroupPanel { fn item_count(&self, cx: &App) -> usize { self.group .upgrade() - .map_or(2, |group| group.read(cx).messages().len() + 2) + .map_or(2, |group| group.read(cx).rows().len() + 2) } fn sync(&mut self, cx: &App) { @@ -229,9 +229,9 @@ impl GroupPanel { }; let group = group.read(cx); - let messages = group.messages(); - let head = messages.first().map(|message| message.id); - let total = messages.len() + 2; + let rows = group.rows(); + let head = rows.first().map(Row::id); + let total = rows.len() + 2; let old_total = self.list_state.item_count(); if total == old_total { @@ -242,8 +242,8 @@ impl GroupPanel { let old_len = old_total.saturating_sub(2); let prepended = self .head - .and_then(|old_head| messages.iter().position(|message| message.id == old_head)) - .filter(|prepended| prepended + old_len <= messages.len()); + .and_then(|old_head| rows.iter().position(|row| row.id() == old_head)) + .filter(|prepended| prepended + old_len <= rows.len()); match prepended { Some(prepended) => { @@ -251,7 +251,7 @@ impl GroupPanel { self.list_state.splice(2..2, prepended); } - let appended = messages.len() - old_len - prepended; + let appended = rows.len() - old_len - prepended; if appended > 0 { let at = old_total + prepended; @@ -837,12 +837,7 @@ impl GroupPanel { } } - fn render_message( - &mut self, - ix: usize, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { + fn render_row(&mut self, ix: usize, window: &mut Window, cx: &mut Context) -> AnyElement { if ix == 0 { return self.render_welcome(cx); } @@ -856,12 +851,17 @@ impl GroupPanel { }; let group = group.read(cx); - let messages = group.messages(); + let rows = group.rows(); - let Some(message) = messages.get(ix - 2) else { + let Some(row) = rows.get(ix - 2) else { return div().into_any_element(); }; + let message = match row { + Row::Message(message) => message, + Row::Activity(activity) => return message::render_activity(ix, activity, cx), + }; + let nostr = NostrRegistry::global(cx); let persons = PersonRegistry::global(cx); @@ -878,7 +878,7 @@ impl GroupPanel { .current_user() .is_some_and(|me| group.admins().contains(&me)); - let show_author = opens_run(messages, ix - 2); + let show_author = opens_run(rows, ix - 2); let pinned = group.pins().pins().contains(&Pin::Event(message.id)); message::render( @@ -1005,7 +1005,7 @@ impl Render for GroupPanel { list( self.list_state.clone(), cx.processor(move |this, ix, window, cx| { - this.render_message(ix, window, cx) + this.render_row(ix, window, cx) }), ) .size_full(), diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index 7fcd4d64..142a8eca 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -2,11 +2,11 @@ use chat::Message; use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedUri, Styled, - StyledImage, WeakEntity, div, img, px, + AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedString, + SharedUri, Styled, StyledImage, WeakEntity, div, img, px, }; -use nip29::Group; -use nostr_sdk::prelude::EventId; +use nip29::{Activity, Group}; +use nostr_sdk::prelude::{EventId, Kind}; use person::PersonRegistry; use settings::AppSettings; use theme::ActiveTheme; @@ -129,6 +129,38 @@ fn react_menu( }) } +pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyElement { + let persons = PersonRegistry::global(cx); + let subject = persons.read(cx).get(&activity.subject(), cx); + let hide_avatar = AppSettings::get_hide_avatar(cx); + + MessageRow::new(ix) + .show_author(true) + .hide_avatar(hide_avatar) + .avatar( + Avatar::new(subject.avatar()) + .seed(subject.avatar_seed()) + .flex_shrink_0(), + ) + .author(SharedString::from(format!( + "{} {}", + subject.name(), + action(activity) + ))) + .timestamp(activity.created_at.to_human_time()) + .into_any_element() +} + +fn action(activity: &Activity) -> String { + match activity.kind { + Kind::GroupJoinRequest => "joined the group".to_owned(), + Kind::GroupLeaveRequest => "left the group".to_owned(), + Kind::GroupRemoveUser => "was removed from the group".to_owned(), + Kind::GroupPutUser => format!("is now {}", activity.roles.join(", ")), + _ => String::new(), + } +} + fn media(media: &[SharedUri], cx: &App) -> AnyElement { if let [only] = media { return div() diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index fdce0b25..670e5e82 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -52,6 +52,31 @@ pub enum SidebarRow { Contact { public_key: PublicKey }, } +/// A collapsible section of the group view that replaces the tab list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GroupSection { + Admins, + Members, +} + +impl GroupSection { + fn label(self) -> &'static str { + match self { + Self::Admins => "Admins", + Self::Members => "Members", + } + } +} + +/// A row in the group view. +enum GroupRow { + Section(GroupSection), + Member { + public_key: PublicKey, + roles: Vec, + }, +} + pub struct Sidebar { focus_handle: FocusHandle, scroll_handles: [UniformListScrollHandle; 2], @@ -60,6 +85,15 @@ pub struct Sidebar { banner: SharedString, active_tab: SidebarTab, + /// The group whose information the sidebar is browsing, if any. + group: Option>, + /// Scroll state of the group's member list. + group_scroll: UniformListScrollHandle, + /// Whether the group view's admins section is expanded. + admins_open: bool, + /// Whether the group view's members section is expanded. + members_open: bool, + /// Whether the inbox list is showing contacts to start a chat with. contacts_open: bool, /// The signed-in user's contacts, loaded when the contact picker opens. @@ -130,6 +164,10 @@ impl Sidebar { dock, banner: pick_banner(), active_tab: SidebarTab::Inbox, + group: None, + group_scroll: UniformListScrollHandle::new(), + admins_open: true, + members_open: true, contacts_open: false, contacts: None, selected_contacts: HashSet::new(), @@ -142,6 +180,20 @@ impl Sidebar { } } + /// Show the group view, replacing the tab list. + fn show_group(&mut self, group: &Entity, cx: &mut Context) { + self.group = Some(group.downgrade()); + cx.notify(); + } + + /// Leave the group view, returning the sidebar to its tab list. + fn reset_group(&mut self, cx: &mut Context) { + if self.group.take().is_none() { + return; + } + cx.notify(); + } + fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context) { if self.active_tab == tab { return; @@ -378,6 +430,18 @@ impl Sidebar { .when_some(AutoUpdater::try_global(cx), |this, updater| { this.child(self.render_updater(updater, cx)) }) + .when(self.group.is_some(), |this| { + this.child( + Button::new("sidebar-back") + .icon(IconName::ArrowLeft) + .tooltip("Back") + .ghost() + .small() + .on_click(cx.listener(|this, _event, _window, cx| { + this.reset_group(cx); + })), + ) + }) } fn render_updater(&self, updater: Entity, cx: &mut App) -> AnyElement { @@ -768,6 +832,7 @@ impl Sidebar { } SidebarRow::Group { group } => { let dock = self.dock.clone(); + let sidebar = cx.entity().downgrade(); let group = group.clone(); let name = group.read(cx).display_name(); @@ -812,6 +877,12 @@ impl Sidebar { window, cx, ); + + if let Err(error) = sidebar.update(cx, |this, cx| { + this.show_group(&group, cx); + }) { + log::error!("Failed to show the group in the sidebar: {error}"); + } }) .into_any_element() } @@ -819,6 +890,191 @@ impl Sidebar { }) .collect() } + + fn render_group(&mut self, group: Entity, cx: &mut Context) -> AnyElement { + let (banner, name, seed, picture, rows) = { + let group = group.read(cx); + let admins = group.admins(); + let mut admin_rows = Vec::new(); + let mut member_rows = Vec::new(); + + for (public_key, roles) in admins.entries() { + admin_rows.push(GroupRow::Member { + public_key: *public_key, + roles: roles.clone(), + }); + } + + for public_key in group.members().iter() { + if admins.contains(public_key) { + continue; + } + + member_rows.push(GroupRow::Member { + public_key: *public_key, + roles: Vec::new(), + }); + } + + let mut rows = Vec::new(); + + if !admin_rows.is_empty() { + rows.push(GroupRow::Section(GroupSection::Admins)); + if self.admins_open { + rows.append(&mut admin_rows); + } + } + + if !member_rows.is_empty() { + rows.push(GroupRow::Section(GroupSection::Members)); + if self.members_open { + rows.append(&mut member_rows); + } + } + + let metadata = group.metadata(); + + ( + metadata.and_then(|metadata| metadata.banner.clone()), + group.display_name(), + group.key().cache_tag(), + group.display_image(), + rows, + ) + }; + + let rows = Rc::new(rows); + + v_flex() + .flex_1() + .min_h_0() + .w_full() + .gap_2() + .child( + h_flex() + .px_2() + .gap_2() + .flex_shrink_0() + .child(Avatar::from_source(picture).seed(seed).small()) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .font_medium() + .truncate() + .child(name), + ), + ) + .when_some(banner, |this, banner| { + this.child( + div().px_2().flex_shrink_0().child( + img(banner) + .w_full() + .h_20() + .rounded(cx.theme().radius_lg) + .object_fit(ObjectFit::Cover), + ), + ) + }) + .child( + div() + .min_h_0() + .flex_1() + .child( + uniform_list( + "group-rows", + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_group_rows(range, rows.as_slice(), cx) + }), + ) + .track_scroll(&self.group_scroll) + .h_full() + .px_2(), + ) + .child(Scrollbar::vertical(&self.group_scroll)), + ) + .into_any_element() + } + + fn render_group_rows( + &self, + range: Range, + rows: &[GroupRow], + cx: &mut Context, + ) -> Vec { + rows.get(range) + .into_iter() + .flatten() + .map(|row| match row { + GroupRow::Section(section) => self.section_row(*section, cx), + GroupRow::Member { public_key, roles } => self.member_row(public_key, roles, cx), + }) + .collect() + } + + fn section_row(&self, section: GroupSection, cx: &mut Context) -> AnyElement { + let open = match section { + GroupSection::Admins => self.admins_open, + GroupSection::Members => self.members_open, + }; + let icon = if open { + IconName::CaretDown + } else { + IconName::CaretRight + }; + let label = section.label(); + + Nav::new(label) + .label(label) + .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted)) + .text_xs() + .font_semibold() + .text_color(cx.theme().text_placeholder) + .on_click(cx.listener(move |this, _event, _window, cx| { + match section { + GroupSection::Admins => this.admins_open = !this.admins_open, + GroupSection::Members => this.members_open = !this.members_open, + } + cx.notify(); + })) + .into_any_element() + } + + fn member_row( + &self, + public_key: &PublicKey, + roles: &[String], + cx: &mut Context, + ) -> AnyElement { + let hide_avatar = AppSettings::get_hide_avatar(cx); + let persons = PersonRegistry::global(cx); + let person = persons.read(cx).get(public_key, cx); + + Nav::new(public_key.to_hex()) + .label(person.name()) + .text_sm() + .font_medium() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::new(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .when(!roles.is_empty(), |this| { + this.suffix( + div() + .truncate() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(SharedString::from(roles.join(", "))), + ) + }) + .into_any_element() + } } impl Panel for Sidebar { @@ -856,6 +1112,8 @@ impl Render for Sidebar { let chat = ChatRegistry::global(cx); let loading = chat.read(cx).loading(); + let group = self.group.clone().and_then(|group| group.upgrade()); + v_flex() .image_cache(retain_all("sidebar")) .size_full() @@ -916,7 +1174,10 @@ impl Render for Sidebar { ), ) }) - .child(self.render_tabs(cx)) + .map(|this| match group { + Some(group) => this.child(self.render_group(group, cx)), + None => this.child(self.render_tabs(cx)), + }) .when(loading && logged_in, |this| { this.child( div() diff --git a/docs/nip29-usage.md b/docs/nip29-usage.md index 20b332a8..e1142c69 100644 --- a/docs/nip29-usage.md +++ b/docs/nip29-usage.md @@ -178,7 +178,7 @@ use nip29::{Group, GroupEvent}; let Some(group) = registry.read(cx).group(&key, cx) else { return; }; -let messages = group.read(cx).messages(); // &[chat::Message] +let rows = group.read(cx).rows(); // &[nip29::Row] ``` ```rust @@ -194,17 +194,26 @@ cx.subscribe(&group, |this, group, event, cx| match event { | Variant | Means | | --- | --- | -| `Incoming(Event)` | a fresh renderable message was folded in (use it for system notifications when the window is inactive) | +| `Incoming(Event)` | a fresh renderable row was folded in (use it for system notifications when the window is inactive) | | `Sent(Event)` | our own event was accepted by the relay; the registry re-reads the group, the panel normally ignores it | | `Updated` | metadata, state, membership, pins or a finished page changed | | `Eose` | the standing subscription settled | | `Refused(String)` | the relay `CLOSED` the standing subscription, with its message | The reads are all accessors: `key`, `metadata`, `members`, `admins`, `roles`, -`participants`, `pins`, `membership`, `elsewhere`, `messages`, `loading`, +`participants`, `pins`, `membership`, `elsewhere`, `rows`, `loading`, `loading_more`, `oldest`, `refused`. `display_name` reads the group's name and falls back to its id; `display_image` is the picture, for a row or avatar. +`rows` is the whole timeline in `created_at` order, one entry per renderable +event. `Row::Message(Box)` is a chat row; `Row::Activity(Activity)` +is a moderation row — who joined (`9021`), who left (`9022`), who was removed +(`9001`) and who was given roles (`9000` with roles; a `9000` without roles is the +relay echoing a join we already show and is not a row). `Activity::subject()` is +the affected person (the `p` tag target, or the author when there is none) and +`Activity::roles` the assigned roles, so the view only chooses wording and an +icon. `Group::message(id)` looks a chat row up for a pin label. + ### The read model There is **no `fetch_events` anywhere** in the crate. A group is read by @@ -213,7 +222,7 @@ and `crates/community`: ``` subscribe grp- the global pump observes every REQ on the client - │ (timeline + state + membership filters, one relay) + │ (timeline + membership + one state filter per kind, one relay) ▼ save_envelope 30078, d = event id, r = "|", k = kind │ content = the raw event JSON, signed by crate-local keys @@ -224,9 +233,13 @@ load_envelopes query by the r tag, parse, sort by created_at Group::hydrate the only place entity state changes ``` -The standing REQ carries three filters under one id: the timeline -(`GroupId::timeline_filter(100)`), the relay state (`state_filter`) and our own -membership (`membership_filter(me)`). Its id is `grp-` plus 8 hex of a hash of the +The standing REQ carries the timeline (`GroupId::timeline_filter(100)`), our own +membership (`membership_filter(me)`) and `state_filters()` — **one filter per +state kind**, because a relay that serves group metadata is allowed to refuse a +filter that mixes metadata kinds with other kinds (`groups.hzrd149.com` answers +`blocked: it's not allowed to mix metadata kinds with others` and drops the whole +REQ, which is what hid a group's name and picture until the filters were split). +Its id is `grp-` plus 8 hex of a hash of the key — NIP-01 caps ids at 64 characters, and the pump needs a prefix to recognise its own subscriptions, so the real key is held in the registry's `routes`, not encoded in the id. @@ -242,7 +255,10 @@ page, a live tail and a local send all take the same path. - `STATE_KINDS` → `metadata`, `admins`, `members`, `roles`, `participants`, `pins` (relay-signed, read-only, addressable by `d`). - `MEMBERSHIP_KINDS` → our membership only (see below). -- `RENDER_KINDS` → a `chat::Message`, inserted in `created_at` order. +- `RENDER_KINDS` → a `Row::Message`, inserted in `created_at` order. +- `ACTIVITY_KINDS` → a `Row::Activity`, inserted in `created_at` order. A `9001` + that answers a `9022` from the same subject within five minutes is dropped, so a + plain leave reads once. - everything else is kept only for the `previous` window. Every `h`-tagged event feeds the window regardless of kind. The window is the @@ -517,7 +533,7 @@ impl GroupsRegistry { one `Subscription` per group, the fork state, and its tasks. It holds no browse result: a browse is fan-out, and the caller keeps the candidates. - `Group` owns everything the panel draws: metadata, admins/members/roles, - participants, pins, membership, the `Vec` timeline, the `previous` + participants, pins, membership, the `Vec` timeline, the `previous` window, `loading`/`loading_more`/`refused`. - Views render `Entity` and re-read on `GroupEvent`; no protocol state lives in a view. @@ -525,8 +541,8 @@ impl GroupsRegistry { and notifies itself, so a view that watches only the registry (the sidebar, which renders `groups()` as rows) stays in sync when a group's metadata, membership, pins or fork state change. `GroupEvent::Updated` fires for state and - membership only, never for an arriving message — that is `GroupEvent::Incoming`, - and it is why a rebuild of the row list does not happen per message. A view that + membership only, never for an arriving row — that is `GroupEvent::Incoming`, + and it is why a rebuild of the row list does not happen per row. A view that renders the timeline watches the `Group` itself. ### Foreground and background @@ -550,14 +566,14 @@ notifications() ──sig──► flume ──recv──► this.update(cx, … ### Subscriptions and routing -One `Client` and one notification stream are shared with `chat` and `community`, -so every subscription the crate owns is identified by a prefix and nothing else: +One `Client` and one notification stream are shared with `chat`, so every +subscription the crate owns is identified by a prefix and nothing else: | Id | Meaning | | --- | --- | | `nip29-list` | the account's `10009` | | `nip29-forks` | the admins' `10009` | -| `grp-` | a group's standing REQ (timeline + state + membership) | +| `grp-` | a group's standing REQ (timeline + membership + state filters) | | `pg-` | a "load more" page | | `brw-` | a relay browse | @@ -575,6 +591,9 @@ before they reach the consumer, so a replay cannot double-hydrate. from a result. The panel has a read-only details dialog, a header `Manage` menu (edit metadata, invite link, members and roles) shown to admins, a message menu with reactions and admin pin/delete, a pins strip, a fork banner, and leave. + Opening a group also fills the sidebar with the group itself — avatar, banner + and the collapsible `Admins`/`Members` lists — with a back button in the title + bar, the same shape the sidebar used for a community before the migration. What a given account may actually do is the relay's call; the client shows the refusal verbatim. - **Remote deletions are not folded in.** A `9005` from another admin never reaches the