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
+153 -46
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,29 +581,7 @@ impl Group {
fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed {
if STATE_KINDS.contains(&event.kind) {
return if self.absorb_state(event) {
Absorbed::Changed
} else {
Absorbed::Ignored
};
}
if MEMBERSHIP_KINDS.contains(&event.kind) {
self.track(event);
if !event.tags.public_keys().any(|key| key == me) {
return Absorbed::Ignored;
}
if !self
.membership_events
.iter()
.any(|existing| existing.id == event.id)
{
self.membership_events.push(event.clone());
}
return if self.refresh_membership(me) {
Absorbed::Changed
Absorbed::Updated
} else {
Absorbed::Ignored
};
@@ -560,15 +589,49 @@ impl Group {
self.track(event);
let updated = self.absorb_membership(event, me);
if ACTIVITY_KINDS.contains(&event.kind) {
return Absorbed::Shown {
fresh: self.push_activity(event),
updated,
};
}
if RENDER_KINDS.contains(&event.kind) {
Absorbed::Rendered {
fresh: self.push_message(event),
}
return Absorbed::Shown {
fresh: self.push_row(event),
updated,
};
}
if updated {
Absorbed::Updated
} else {
Absorbed::Ignored
}
}
fn absorb_membership(&mut self, event: &Event, me: PublicKey) -> bool {
if !MEMBERSHIP_KINDS.contains(&event.kind) {
return false;
}
if !event.tags.public_keys().any(|key| key == me) {
return false;
}
if !self
.membership_events
.iter()
.any(|existing| existing.id == event.id)
{
self.membership_events.push(event.clone());
}
self.refresh_membership(me)
}
fn track(&mut self, event: &Event) {
self.window.push(event.clone());
@@ -609,17 +672,61 @@ impl Group {
self.state_ids.insert(event.kind, event.id) != Some(event.id)
}
fn push_message(&mut self, event: &Event) -> bool {
fn push_row(&mut self, event: &Event) -> bool {
if !self.index.insert(event.id) {
return false;
}
let message = Message::from(event);
let position = self
.messages
.partition_point(|existing| existing.created_at <= message.created_at);
.rows
.partition_point(|row| row.created_at() <= message.created_at);
self.messages.insert(position, message);
self.rows.insert(position, Row::Message(Box::new(message)));
true
}
fn push_activity(&mut self, event: &Event) -> bool {
let activity = match Activity::parse(event) {
Ok(activity) => activity,
Err(error) => {
log::warn!("nip29: ignoring a malformed group activity: {error}");
return false;
}
};
if activity.kind == Kind::GroupPutUser && activity.roles.is_empty() {
return false;
}
if activity.kind == Kind::GroupRemoveUser
&& self
.leave_requests
.get(&activity.subject())
.is_some_and(|left| {
activity.created_at >= *left
&& activity.created_at.as_secs().saturating_sub(left.as_secs())
<= LEAVE_WINDOW_SECS
})
{
return false;
}
if !self.index.insert(activity.id) {
return false;
}
if activity.kind == Kind::GroupLeaveRequest {
self.leave_requests
.insert(activity.subject(), activity.created_at);
}
let position = self
.rows
.partition_point(|row| row.created_at() <= activity.created_at);
self.rows.insert(position, Row::Activity(activity));
true
}
+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>);