add group activity rows to nip29 timeline

This commit is contained in:
2026-10-01 04:43:20 +07:00
parent 41e48de4fd
commit 61aa7c4a70
6 changed files with 579 additions and 99 deletions
+146 -39
View File
@@ -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<Message>),
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<Event>,
elsewhere: Option<GroupKey>,
state_ids: HashMap<Kind, EventId>,
messages: Vec<Message>,
rows: Vec<Row>,
index: HashSet<EventId>,
leave_requests: HashMap<PublicKey, Timestamp>,
window: TimelineWindow,
oldest: Option<Timestamp>,
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::<Vec<_>>();
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,17 +581,44 @@ 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
Absorbed::Updated
} else {
Absorbed::Ignored
};
}
if MEMBERSHIP_KINDS.contains(&event.kind) {
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) {
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 Absorbed::Ignored;
return false;
}
if !self
@@ -551,22 +629,7 @@ impl Group {
self.membership_events.push(event.clone());
}
return if self.refresh_membership(me) {
Absorbed::Changed
} else {
Absorbed::Ignored
};
}
self.track(event);
if RENDER_KINDS.contains(&event.kind) {
Absorbed::Rendered {
fresh: self.push_message(event),
}
} else {
Absorbed::Ignored
}
self.refresh_membership(me)
}
fn track(&mut self, event: &Event) {
@@ -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
}
+63 -2
View File
@@ -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<Filter> {
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<PublicKey>,
pub roles: Vec<String>,
pub created_at: Timestamp,
}
impl Activity {
pub fn parse(event: &Event) -> Result<Self> {
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<Event>);
+32 -32
View File
@@ -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<Group>, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>,
) -> AnyElement {
fn render_row(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) -> 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(),
+36 -4
View File
@@ -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()
+262 -1
View File
@@ -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<String>,
},
}
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<WeakEntity<Group>>,
/// 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<Group>, cx: &mut Context<Self>) {
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<Self>) {
if self.group.take().is_none() {
return;
}
cx.notify();
}
fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context<Self>) {
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<AutoUpdater>, 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<Group>, cx: &mut Context<Self>) -> 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<usize>,
rows: &[GroupRow],
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
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<Self>) -> 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<Self>,
) -> 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()
+33 -14
View File
@@ -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<chat::Message>)` 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-<hash> 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 = "<relay>|<id>", 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<Message>` timeline, the `previous`
participants, pins, membership, the `Vec<Row>` timeline, the `previous`
window, `loading`/`loading_more`/`refused`.
- Views render `Entity<Group>` 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-<hash>` | a group's standing REQ (timeline + state + membership) |
| `grp-<hash>` | a group's standing REQ (timeline + membership + state filters) |
| `pg-<hash>` | a "load more" page |
| `brw-<hash>` | 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