add group activity rows to nip29 timeline
This commit is contained in:
+146
-39
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user