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::LOCAL_KEYS;
|
||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupParticipants, GroupPins,
|
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
||||||
GroupRoles, LivekitSession, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS,
|
GroupParticipants, GroupPins, GroupRoles, LivekitSession, MEMBERSHIP_KINDS, Membership,
|
||||||
STATE_KINDS, TimelineWindow,
|
PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, TimelineWindow,
|
||||||
};
|
};
|
||||||
|
|
||||||
const REPLAY_LIMIT: usize = 100;
|
const REPLAY_LIMIT: usize = 100;
|
||||||
const PAGE_LIMIT: usize = 50;
|
const PAGE_LIMIT: usize = 50;
|
||||||
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
const LIVEKIT_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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum Absorbed {
|
enum Absorbed {
|
||||||
Ignored,
|
Ignored,
|
||||||
Rendered { fresh: bool },
|
Shown { fresh: bool, updated: bool },
|
||||||
Changed,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -51,8 +81,9 @@ pub struct Group {
|
|||||||
membership_events: Vec<Event>,
|
membership_events: Vec<Event>,
|
||||||
elsewhere: Option<GroupKey>,
|
elsewhere: Option<GroupKey>,
|
||||||
state_ids: HashMap<Kind, EventId>,
|
state_ids: HashMap<Kind, EventId>,
|
||||||
messages: Vec<Message>,
|
rows: Vec<Row>,
|
||||||
index: HashSet<EventId>,
|
index: HashSet<EventId>,
|
||||||
|
leave_requests: HashMap<PublicKey, Timestamp>,
|
||||||
window: TimelineWindow,
|
window: TimelineWindow,
|
||||||
oldest: Option<Timestamp>,
|
oldest: Option<Timestamp>,
|
||||||
loading: bool,
|
loading: bool,
|
||||||
@@ -77,8 +108,9 @@ impl Group {
|
|||||||
membership_events: Vec::new(),
|
membership_events: Vec::new(),
|
||||||
elsewhere: None,
|
elsewhere: None,
|
||||||
state_ids: HashMap::new(),
|
state_ids: HashMap::new(),
|
||||||
messages: Vec::new(),
|
rows: Vec::new(),
|
||||||
index: HashSet::new(),
|
index: HashSet::new(),
|
||||||
|
leave_requests: HashMap::new(),
|
||||||
window: TimelineWindow::new(),
|
window: TimelineWindow::new(),
|
||||||
oldest: None,
|
oldest: None,
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -141,8 +173,15 @@ impl Group {
|
|||||||
self.elsewhere.as_ref()
|
self.elsewhere.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn messages(&self) -> &[Message] {
|
pub fn rows(&self) -> &[Row] {
|
||||||
&self.messages
|
&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 {
|
pub fn loading(&self) -> bool {
|
||||||
@@ -311,11 +350,13 @@ impl Group {
|
|||||||
log::warn!("nip29: connecting {relay} failed: {error}");
|
log::warn!("nip29: connecting {relay} failed: {error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let filters = vec![
|
let filters = [
|
||||||
key.id().timeline_filter(REPLAY_LIMIT),
|
key.id().timeline_filter(REPLAY_LIMIT),
|
||||||
key.id().state_filter(),
|
|
||||||
key.id().membership_filter(me),
|
key.id().membership_filter(me),
|
||||||
];
|
]
|
||||||
|
.into_iter()
|
||||||
|
.chain(key.id().state_filters())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
if let Err(error) = client
|
if let Err(error) = client
|
||||||
.subscribe(ReqTarget::single(relay, filters))
|
.subscribe(ReqTarget::single(relay, filters))
|
||||||
@@ -485,9 +526,19 @@ impl Group {
|
|||||||
|
|
||||||
for event in &events {
|
for event in &events {
|
||||||
match self.absorb(event, me) {
|
match self.absorb(event, me) {
|
||||||
Absorbed::Rendered { fresh: true } => rendered.push(event.clone()),
|
Absorbed::Shown {
|
||||||
Absorbed::Changed => changed = true,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.messages.retain(|message| message.id != id);
|
self.rows.retain(|row| row.id() != id);
|
||||||
cx.emit(GroupEvent::Updated);
|
cx.emit(GroupEvent::Updated);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
@@ -530,17 +581,44 @@ impl Group {
|
|||||||
fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed {
|
fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed {
|
||||||
if STATE_KINDS.contains(&event.kind) {
|
if STATE_KINDS.contains(&event.kind) {
|
||||||
return if self.absorb_state(event) {
|
return if self.absorb_state(event) {
|
||||||
Absorbed::Changed
|
Absorbed::Updated
|
||||||
} else {
|
} else {
|
||||||
Absorbed::Ignored
|
Absorbed::Ignored
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if MEMBERSHIP_KINDS.contains(&event.kind) {
|
|
||||||
self.track(event);
|
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) {
|
if !event.tags.public_keys().any(|key| key == me) {
|
||||||
return Absorbed::Ignored;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self
|
if !self
|
||||||
@@ -551,22 +629,7 @@ impl Group {
|
|||||||
self.membership_events.push(event.clone());
|
self.membership_events.push(event.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
return if self.refresh_membership(me) {
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn track(&mut self, event: &Event) {
|
fn track(&mut self, event: &Event) {
|
||||||
@@ -609,17 +672,61 @@ impl Group {
|
|||||||
self.state_ids.insert(event.kind, event.id) != Some(event.id)
|
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) {
|
if !self.index.insert(event.id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = Message::from(event);
|
let message = Message::from(event);
|
||||||
let position = self
|
let position = self
|
||||||
.messages
|
.rows
|
||||||
.partition_point(|existing| existing.created_at <= message.created_at);
|
.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
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ pub const STATE_KINDS: [Kind; 6] = [
|
|||||||
|
|
||||||
pub const MEMBERSHIP_KINDS: [Kind; 2] = [Kind::GroupPutUser, Kind::GroupRemoveUser];
|
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 {
|
fn has_tag(tags: &Tags, name: &str) -> bool {
|
||||||
tags.iter().any(|tag| tag.kind() == name)
|
tags.iter().any(|tag| tag.kind() == name)
|
||||||
}
|
}
|
||||||
@@ -76,8 +83,11 @@ impl GroupId {
|
|||||||
self.timeline_filter(limit).until(until)
|
self.timeline_filter(limit).until(until)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn state_filter(&self) -> Filter {
|
pub fn state_filters(&self) -> Vec<Filter> {
|
||||||
Filter::new().identifier(self.0.clone()).kinds(STATE_KINDS)
|
STATE_KINDS
|
||||||
|
.iter()
|
||||||
|
.map(|kind| Filter::new().identifier(self.0.clone()).kind(*kind))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn membership_filter(&self, me: PublicKey) -> Filter {
|
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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct TimelineWindow(Vec<Event>);
|
pub struct TimelineWindow(Vec<Event>);
|
||||||
|
|
||||||
|
|||||||
+32
-32
@@ -1,7 +1,6 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use chat::Message;
|
|
||||||
use chat_ui::text::rendered_text;
|
use chat_ui::text::rendered_text;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -9,7 +8,7 @@ use gpui::{
|
|||||||
Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
|
Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
|
||||||
Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
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 nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -88,20 +87,23 @@ fn display_name(group: &Group) -> SharedString {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn opens_run(messages: &[Message], index: usize) -> bool {
|
fn opens_run(rows: &[Row], index: usize) -> bool {
|
||||||
let Some(current) = messages.get(index) else {
|
let (Some(current), Some(previous)) = (
|
||||||
|
rows.get(index),
|
||||||
|
index.checked_sub(1).and_then(|index| rows.get(index)),
|
||||||
|
) else {
|
||||||
return true;
|
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;
|
return true;
|
||||||
};
|
}
|
||||||
|
|
||||||
current.author != previous.author
|
current.author() != previous.author()
|
||||||
|| current
|
|| current
|
||||||
.created_at
|
.created_at()
|
||||||
.as_secs()
|
.as_secs()
|
||||||
.saturating_sub(previous.created_at.as_secs())
|
.saturating_sub(previous.created_at().as_secs())
|
||||||
> RUN_WINDOW_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::Address(coordinate) => SharedString::from(format!("{coordinate}")),
|
||||||
Pin::Event(id) => {
|
Pin::Event(id) => {
|
||||||
let snippet = group
|
let snippet = group
|
||||||
.messages()
|
.message(*id)
|
||||||
.iter()
|
|
||||||
.find(|message| message.id == *id)
|
|
||||||
.and_then(|message| message.content.lines().next())
|
.and_then(|message| message.content.lines().next())
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|line| !line.is_empty());
|
.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 {
|
pub fn new(group: Entity<Group>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let (id, name, count, head) = {
|
let (id, name, count, head) = {
|
||||||
let group = group.read(cx);
|
let group = group.read(cx);
|
||||||
let messages = group.messages();
|
let rows = group.rows();
|
||||||
|
|
||||||
(
|
(
|
||||||
SharedString::from(format!("group-{}", group.key().cache_tag())),
|
SharedString::from(format!("group-{}", group.key().cache_tag())),
|
||||||
display_name(group),
|
display_name(group),
|
||||||
messages.len(),
|
rows.len(),
|
||||||
messages.first().map(|message| message.id),
|
rows.first().map(Row::id),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ impl GroupPanel {
|
|||||||
fn item_count(&self, cx: &App) -> usize {
|
fn item_count(&self, cx: &App) -> usize {
|
||||||
self.group
|
self.group
|
||||||
.upgrade()
|
.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) {
|
fn sync(&mut self, cx: &App) {
|
||||||
@@ -229,9 +229,9 @@ impl GroupPanel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let group = group.read(cx);
|
let group = group.read(cx);
|
||||||
let messages = group.messages();
|
let rows = group.rows();
|
||||||
let head = messages.first().map(|message| message.id);
|
let head = rows.first().map(Row::id);
|
||||||
let total = messages.len() + 2;
|
let total = rows.len() + 2;
|
||||||
let old_total = self.list_state.item_count();
|
let old_total = self.list_state.item_count();
|
||||||
|
|
||||||
if total == old_total {
|
if total == old_total {
|
||||||
@@ -242,8 +242,8 @@ impl GroupPanel {
|
|||||||
let old_len = old_total.saturating_sub(2);
|
let old_len = old_total.saturating_sub(2);
|
||||||
let prepended = self
|
let prepended = self
|
||||||
.head
|
.head
|
||||||
.and_then(|old_head| messages.iter().position(|message| message.id == old_head))
|
.and_then(|old_head| rows.iter().position(|row| row.id() == old_head))
|
||||||
.filter(|prepended| prepended + old_len <= messages.len());
|
.filter(|prepended| prepended + old_len <= rows.len());
|
||||||
|
|
||||||
match prepended {
|
match prepended {
|
||||||
Some(prepended) => {
|
Some(prepended) => {
|
||||||
@@ -251,7 +251,7 @@ impl GroupPanel {
|
|||||||
self.list_state.splice(2..2, prepended);
|
self.list_state.splice(2..2, prepended);
|
||||||
}
|
}
|
||||||
|
|
||||||
let appended = messages.len() - old_len - prepended;
|
let appended = rows.len() - old_len - prepended;
|
||||||
|
|
||||||
if appended > 0 {
|
if appended > 0 {
|
||||||
let at = old_total + prepended;
|
let at = old_total + prepended;
|
||||||
@@ -837,12 +837,7 @@ impl GroupPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_message(
|
fn render_row(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||||
&mut self,
|
|
||||||
ix: usize,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> AnyElement {
|
|
||||||
if ix == 0 {
|
if ix == 0 {
|
||||||
return self.render_welcome(cx);
|
return self.render_welcome(cx);
|
||||||
}
|
}
|
||||||
@@ -856,12 +851,17 @@ impl GroupPanel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let group = group.read(cx);
|
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();
|
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 nostr = NostrRegistry::global(cx);
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
|
|
||||||
@@ -878,7 +878,7 @@ impl GroupPanel {
|
|||||||
.current_user()
|
.current_user()
|
||||||
.is_some_and(|me| group.admins().contains(&me));
|
.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));
|
let pinned = group.pins().pins().contains(&Pin::Event(message.id));
|
||||||
|
|
||||||
message::render(
|
message::render(
|
||||||
@@ -1005,7 +1005,7 @@ impl Render for GroupPanel {
|
|||||||
list(
|
list(
|
||||||
self.list_state.clone(),
|
self.list_state.clone(),
|
||||||
cx.processor(move |this, ix, window, cx| {
|
cx.processor(move |this, ix, window, cx| {
|
||||||
this.render_message(ix, window, cx)
|
this.render_row(ix, window, cx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.size_full(),
|
.size_full(),
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ use chat::Message;
|
|||||||
use common::TimestampExt;
|
use common::TimestampExt;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedUri, Styled,
|
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedString,
|
||||||
StyledImage, WeakEntity, div, img, px,
|
SharedUri, Styled, StyledImage, WeakEntity, div, img, px,
|
||||||
};
|
};
|
||||||
use nip29::Group;
|
use nip29::{Activity, Group};
|
||||||
use nostr_sdk::prelude::EventId;
|
use nostr_sdk::prelude::{EventId, Kind};
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use theme::ActiveTheme;
|
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 {
|
fn media(media: &[SharedUri], cx: &App) -> AnyElement {
|
||||||
if let [only] = media {
|
if let [only] = media {
|
||||||
return div()
|
return div()
|
||||||
|
|||||||
@@ -52,6 +52,31 @@ pub enum SidebarRow {
|
|||||||
Contact { public_key: PublicKey },
|
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 {
|
pub struct Sidebar {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
scroll_handles: [UniformListScrollHandle; 2],
|
scroll_handles: [UniformListScrollHandle; 2],
|
||||||
@@ -60,6 +85,15 @@ pub struct Sidebar {
|
|||||||
banner: SharedString,
|
banner: SharedString,
|
||||||
active_tab: SidebarTab,
|
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.
|
/// Whether the inbox list is showing contacts to start a chat with.
|
||||||
contacts_open: bool,
|
contacts_open: bool,
|
||||||
/// The signed-in user's contacts, loaded when the contact picker opens.
|
/// The signed-in user's contacts, loaded when the contact picker opens.
|
||||||
@@ -130,6 +164,10 @@ impl Sidebar {
|
|||||||
dock,
|
dock,
|
||||||
banner: pick_banner(),
|
banner: pick_banner(),
|
||||||
active_tab: SidebarTab::Inbox,
|
active_tab: SidebarTab::Inbox,
|
||||||
|
group: None,
|
||||||
|
group_scroll: UniformListScrollHandle::new(),
|
||||||
|
admins_open: true,
|
||||||
|
members_open: true,
|
||||||
contacts_open: false,
|
contacts_open: false,
|
||||||
contacts: None,
|
contacts: None,
|
||||||
selected_contacts: HashSet::new(),
|
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>) {
|
fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context<Self>) {
|
||||||
if self.active_tab == tab {
|
if self.active_tab == tab {
|
||||||
return;
|
return;
|
||||||
@@ -378,6 +430,18 @@ impl Sidebar {
|
|||||||
.when_some(AutoUpdater::try_global(cx), |this, updater| {
|
.when_some(AutoUpdater::try_global(cx), |this, updater| {
|
||||||
this.child(self.render_updater(updater, cx))
|
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 {
|
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
|
||||||
@@ -768,6 +832,7 @@ impl Sidebar {
|
|||||||
}
|
}
|
||||||
SidebarRow::Group { group } => {
|
SidebarRow::Group { group } => {
|
||||||
let dock = self.dock.clone();
|
let dock = self.dock.clone();
|
||||||
|
let sidebar = cx.entity().downgrade();
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
|
|
||||||
let name = group.read(cx).display_name();
|
let name = group.read(cx).display_name();
|
||||||
@@ -812,6 +877,12 @@ impl Sidebar {
|
|||||||
window,
|
window,
|
||||||
cx,
|
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()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -819,6 +890,191 @@ impl Sidebar {
|
|||||||
})
|
})
|
||||||
.collect()
|
.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 {
|
impl Panel for Sidebar {
|
||||||
@@ -856,6 +1112,8 @@ impl Render for Sidebar {
|
|||||||
let chat = ChatRegistry::global(cx);
|
let chat = ChatRegistry::global(cx);
|
||||||
let loading = chat.read(cx).loading();
|
let loading = chat.read(cx).loading();
|
||||||
|
|
||||||
|
let group = self.group.clone().and_then(|group| group.upgrade());
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.image_cache(retain_all("sidebar"))
|
.image_cache(retain_all("sidebar"))
|
||||||
.size_full()
|
.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| {
|
.when(loading && logged_in, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
+33
-14
@@ -178,7 +178,7 @@ use nip29::{Group, GroupEvent};
|
|||||||
let Some(group) = registry.read(cx).group(&key, cx) else {
|
let Some(group) = registry.read(cx).group(&key, cx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let messages = group.read(cx).messages(); // &[chat::Message]
|
let rows = group.read(cx).rows(); // &[nip29::Row]
|
||||||
```
|
```
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -194,17 +194,26 @@ cx.subscribe(&group, |this, group, event, cx| match event {
|
|||||||
|
|
||||||
| Variant | Means |
|
| 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 |
|
| `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 |
|
| `Updated` | metadata, state, membership, pins or a finished page changed |
|
||||||
| `Eose` | the standing subscription settled |
|
| `Eose` | the standing subscription settled |
|
||||||
| `Refused(String)` | the relay `CLOSED` the standing subscription, with its message |
|
| `Refused(String)` | the relay `CLOSED` the standing subscription, with its message |
|
||||||
|
|
||||||
The reads are all accessors: `key`, `metadata`, `members`, `admins`, `roles`,
|
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
|
`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.
|
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
|
### The read model
|
||||||
|
|
||||||
There is **no `fetch_events` anywhere** in the crate. A group is read by
|
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
|
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
|
save_envelope 30078, d = event id, r = "<relay>|<id>", k = kind
|
||||||
│ content = the raw event JSON, signed by crate-local keys
|
│ 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
|
Group::hydrate the only place entity state changes
|
||||||
```
|
```
|
||||||
|
|
||||||
The standing REQ carries three filters under one id: the timeline
|
The standing REQ carries the timeline (`GroupId::timeline_filter(100)`), our own
|
||||||
(`GroupId::timeline_filter(100)`), the relay state (`state_filter`) and our own
|
membership (`membership_filter(me)`) and `state_filters()` — **one filter per
|
||||||
membership (`membership_filter(me)`). Its id is `grp-` plus 8 hex of a hash of the
|
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
|
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
|
its own subscriptions, so the real key is held in the registry's `routes`, not
|
||||||
encoded in the id.
|
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`,
|
- `STATE_KINDS` → `metadata`, `admins`, `members`, `roles`, `participants`,
|
||||||
`pins` (relay-signed, read-only, addressable by `d`).
|
`pins` (relay-signed, read-only, addressable by `d`).
|
||||||
- `MEMBERSHIP_KINDS` → our membership only (see below).
|
- `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.
|
- everything else is kept only for the `previous` window.
|
||||||
|
|
||||||
Every `h`-tagged event feeds the window regardless of kind. The window is the
|
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
|
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.
|
result: a browse is fan-out, and the caller keeps the candidates.
|
||||||
- `Group` owns everything the panel draws: metadata, admins/members/roles,
|
- `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`.
|
window, `loading`/`loading_more`/`refused`.
|
||||||
- Views render `Entity<Group>` and re-read on `GroupEvent`; no protocol state
|
- Views render `Entity<Group>` and re-read on `GroupEvent`; no protocol state
|
||||||
lives in a view.
|
lives in a view.
|
||||||
@@ -525,8 +541,8 @@ impl GroupsRegistry {
|
|||||||
and notifies itself, so a view that watches only the registry (the sidebar,
|
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,
|
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, pins or fork state change. `GroupEvent::Updated` fires for state and
|
||||||
membership only, never for an arriving message — that is `GroupEvent::Incoming`,
|
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 message. A view that
|
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.
|
renders the timeline watches the `Group` itself.
|
||||||
|
|
||||||
### Foreground and background
|
### Foreground and background
|
||||||
@@ -550,14 +566,14 @@ notifications() ──sig──► flume ──recv──► this.update(cx, …
|
|||||||
|
|
||||||
### Subscriptions and routing
|
### Subscriptions and routing
|
||||||
|
|
||||||
One `Client` and one notification stream are shared with `chat` and `community`,
|
One `Client` and one notification stream are shared with `chat`, so every
|
||||||
so every subscription the crate owns is identified by a prefix and nothing else:
|
subscription the crate owns is identified by a prefix and nothing else:
|
||||||
|
|
||||||
| Id | Meaning |
|
| Id | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `nip29-list` | the account's `10009` |
|
| `nip29-list` | the account's `10009` |
|
||||||
| `nip29-forks` | the admins' `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 |
|
| `pg-<hash>` | a "load more" page |
|
||||||
| `brw-<hash>` | a relay browse |
|
| `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
|
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
|
(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.
|
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
|
What a given account may actually do is the relay's call; the client shows the
|
||||||
refusal verbatim.
|
refusal verbatim.
|
||||||
- **Remote deletions are not folded in.** A `9005` from another admin never reaches the
|
- **Remote deletions are not folded in.** A `9005` from another admin never reaches the
|
||||||
|
|||||||
Reference in New Issue
Block a user