feat: add support for NIP-29 (#56)
Reviewed-on: #56
This commit was merged in pull request #56.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "nip29"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
state = { path = "../state" }
|
||||
chat = { path = "../chat" }
|
||||
|
||||
gpui.workspace = true
|
||||
flume.workspace = true
|
||||
log.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -0,0 +1,748 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use chat::Message;
|
||||
use futures::io::AsyncReadExt;
|
||||
use gpui::http_client::{AsyncBody, Builder, HttpRequestExt, Method, RedirectPolicy, StatusCode};
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use nostr::nips::nip98::{HttpData, HttpMethod};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
use crate::LOCAL_KEYS;
|
||||
use crate::protocol::{
|
||||
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,
|
||||
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)]
|
||||
pub enum GroupEvent {
|
||||
Incoming(Event),
|
||||
Sent(Event),
|
||||
Updated,
|
||||
Eose,
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
pub struct Group {
|
||||
key: GroupKey,
|
||||
metadata: Option<GroupMetadata>,
|
||||
members: GroupMembers,
|
||||
admins: GroupAdmins,
|
||||
roles: GroupRoles,
|
||||
participants: GroupParticipants,
|
||||
pins: GroupPins,
|
||||
membership: Membership,
|
||||
membership_events: Vec<Event>,
|
||||
elsewhere: Option<GroupKey>,
|
||||
state_ids: HashMap<Kind, EventId>,
|
||||
rows: Vec<Row>,
|
||||
index: HashSet<EventId>,
|
||||
leave_requests: HashMap<PublicKey, Timestamp>,
|
||||
window: TimelineWindow,
|
||||
oldest: Option<Timestamp>,
|
||||
loading: bool,
|
||||
loading_more: bool,
|
||||
refused: Option<String>,
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
}
|
||||
|
||||
impl EventEmitter<GroupEvent> for Group {}
|
||||
|
||||
impl Group {
|
||||
pub(crate) fn new(key: GroupKey) -> Self {
|
||||
Self {
|
||||
key,
|
||||
metadata: None,
|
||||
members: GroupMembers::default(),
|
||||
admins: GroupAdmins::default(),
|
||||
roles: GroupRoles::default(),
|
||||
participants: GroupParticipants::default(),
|
||||
pins: GroupPins::default(),
|
||||
membership: Membership::Unknown,
|
||||
membership_events: Vec::new(),
|
||||
elsewhere: None,
|
||||
state_ids: HashMap::new(),
|
||||
rows: Vec::new(),
|
||||
index: HashSet::new(),
|
||||
leave_requests: HashMap::new(),
|
||||
window: TimelineWindow::new(),
|
||||
oldest: None,
|
||||
loading: true,
|
||||
loading_more: false,
|
||||
refused: None,
|
||||
tasks: smallvec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &GroupKey {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> Option<&GroupMetadata> {
|
||||
self.metadata.as_ref()
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> SharedString {
|
||||
let name = self
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.name())
|
||||
.unwrap_or_else(|| self.key.id().as_str());
|
||||
|
||||
SharedString::from(name.to_owned())
|
||||
}
|
||||
|
||||
pub fn display_image(&self) -> Option<SharedString> {
|
||||
self.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.picture.clone())
|
||||
.map(SharedString::from)
|
||||
}
|
||||
|
||||
pub fn total_members(&self) -> usize {
|
||||
self.members.len()
|
||||
}
|
||||
|
||||
pub fn members(&self) -> &GroupMembers {
|
||||
&self.members
|
||||
}
|
||||
|
||||
pub fn admins(&self) -> &GroupAdmins {
|
||||
&self.admins
|
||||
}
|
||||
|
||||
pub fn roles(&self) -> &GroupRoles {
|
||||
&self.roles
|
||||
}
|
||||
|
||||
pub fn participants(&self) -> &GroupParticipants {
|
||||
&self.participants
|
||||
}
|
||||
|
||||
pub fn pins(&self) -> &GroupPins {
|
||||
&self.pins
|
||||
}
|
||||
|
||||
pub fn membership(&self) -> &Membership {
|
||||
&self.membership
|
||||
}
|
||||
|
||||
pub fn elsewhere(&self) -> Option<&GroupKey> {
|
||||
self.elsewhere.as_ref()
|
||||
}
|
||||
|
||||
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 {
|
||||
self.loading
|
||||
}
|
||||
|
||||
pub fn loading_more(&self) -> bool {
|
||||
self.loading_more
|
||||
}
|
||||
|
||||
pub fn oldest(&self) -> Option<Timestamp> {
|
||||
self.oldest
|
||||
}
|
||||
|
||||
pub fn refused(&self) -> Option<&str> {
|
||||
self.refused.as_deref()
|
||||
}
|
||||
|
||||
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag> {
|
||||
self.window.previous_tag(me)
|
||||
}
|
||||
|
||||
pub fn send_message(
|
||||
&mut self,
|
||||
content: &str,
|
||||
replies: Vec<EventId>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.message(content, &replies, prev), cx)
|
||||
}
|
||||
|
||||
pub fn send_reaction(
|
||||
&mut self,
|
||||
target: EventId,
|
||||
emoji: String,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.reaction(target, &emoji, prev), cx)
|
||||
}
|
||||
|
||||
pub fn put_user(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
roles: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.put_user(public_key, &roles, prev), cx)
|
||||
}
|
||||
|
||||
pub fn remove_user(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.remove_user(public_key, prev), cx)
|
||||
}
|
||||
|
||||
pub fn edit_metadata(
|
||||
&mut self,
|
||||
metadata: &GroupMetadata,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.edit_metadata(metadata, prev), cx)
|
||||
}
|
||||
|
||||
pub fn delete_event(&mut self, id: EventId, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx)
|
||||
}
|
||||
|
||||
pub fn create_invite(&mut self, code: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.create_invite(code, prev), cx)
|
||||
}
|
||||
|
||||
pub fn update_pins(&mut self, pins: Vec<Pin>, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.update_pin_list(&pins, prev), cx)
|
||||
}
|
||||
|
||||
fn dispatch<F>(&mut self, build: F, cx: &mut Context<Self>) -> Task<Result<()>>
|
||||
where
|
||||
F: FnOnce(&GroupId, Option<Tag>) -> EventBuilder,
|
||||
{
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return Task::ready(Err(anyhow!("nip29: no signed-in account")));
|
||||
};
|
||||
|
||||
let builder = build(self.key.id(), self.window.previous_tag(me));
|
||||
let published = self.publish(builder, cx);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let event = published.await?;
|
||||
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(GroupEvent::Sent(event));
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn publish(&self, builder: EventBuilder, cx: &App) -> Task<Result<Event>> {
|
||||
let nostr = NostrRegistry::global(cx).read(cx);
|
||||
let client = nostr.client();
|
||||
let signer = nostr.signer();
|
||||
let relay = self.key.relay().clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
|
||||
log::warn!("nip29: connecting {relay} failed: {error}");
|
||||
}
|
||||
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&event).to([relay]).await?;
|
||||
|
||||
if let Some(reason) = output.failed.into_values().next() {
|
||||
bail!("{reason}");
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mark_pending(&mut self, since: Timestamp, cx: &mut Context<Self>) {
|
||||
if self.membership != Membership::Member {
|
||||
self.membership = Membership::Pending { since };
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_refused(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||
if self.membership == Membership::Member {
|
||||
return;
|
||||
}
|
||||
self.membership = Membership::Refused {
|
||||
reason: Some(reason),
|
||||
};
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_elsewhere(&mut self, elsewhere: Option<GroupKey>, cx: &mut Context<Self>) {
|
||||
if self.elsewhere == elsewhere {
|
||||
return;
|
||||
}
|
||||
self.elsewhere = elsewhere;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn connect(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let key = self.key.clone();
|
||||
let id = crate::subscription_id(&key);
|
||||
let relay = key.relay().clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
|
||||
log::warn!("nip29: connecting {relay} failed: {error}");
|
||||
}
|
||||
|
||||
let filters = [
|
||||
key.id().timeline_filter(REPLAY_LIMIT),
|
||||
key.id().membership_filter(me),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(key.id().state_filters())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay, filters))
|
||||
.with_id(id)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: subscribing to {} failed: {error}", key.id());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn load_page(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(until) = self.oldest else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_more = true;
|
||||
cx.notify();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let id = crate::page_subscription(&self.key);
|
||||
let relay = self.key.relay().clone();
|
||||
let filter = self.key.id().timeline_page(PAGE_LIMIT, until);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(PAGE_TIMEOUT));
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay, [filter]))
|
||||
.with_id(id)
|
||||
.close_on(opts)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: loading older messages failed: {error}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn page_finished(&mut self, cx: &mut Context<Self>) {
|
||||
self.loading_more = false;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn livekit_session(&self, cx: &App) -> Task<Result<LivekitSession>> {
|
||||
let url = match self.key.livekit_token_url() {
|
||||
Ok(url) => url,
|
||||
Err(error) => return Task::ready(Err(error)),
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let client = cx.http_client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let authorization = HttpData::new(url.clone(), HttpMethod::GET)
|
||||
.to_authorization(&signer)
|
||||
.await?;
|
||||
|
||||
let request = Builder::new()
|
||||
.uri(url.as_str())
|
||||
.method(Method::GET)
|
||||
.header("Authorization", authorization)
|
||||
.follow_redirects(RedirectPolicy::NoFollow)
|
||||
.timeout(LIVEKIT_TIMEOUT)
|
||||
.body(AsyncBody::default())?;
|
||||
|
||||
let response = client.send(request).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"nip29: the AV token endpoint answered {}",
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
|
||||
let mut body = Vec::new();
|
||||
response.into_body().read_to_end(&mut body).await?;
|
||||
|
||||
LivekitSession::parse(&body)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn livekit_supported(&self, cx: &App) -> Task<Result<bool>> {
|
||||
let url = match self.key.livekit_probe_url() {
|
||||
Ok(url) => url,
|
||||
Err(error) => return Task::ready(Err(error)),
|
||||
};
|
||||
|
||||
let client = cx.http_client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let request = Builder::new()
|
||||
.uri(url.as_str())
|
||||
.method(Method::GET)
|
||||
.follow_redirects(RedirectPolicy::NoFollow)
|
||||
.timeout(LIVEKIT_TIMEOUT)
|
||||
.body(AsyncBody::default())?;
|
||||
|
||||
let response = client.send(request).await?;
|
||||
|
||||
Ok(response.status() == StatusCode::NO_CONTENT)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task<Result<()>> {
|
||||
let cache_tag = self.key.cache_tag();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let tags = vec![
|
||||
Tag::identifier(event.id),
|
||||
Tag::custom("r", [cache_tag]),
|
||||
Tag::custom("k", [event.kind.to_string()]),
|
||||
];
|
||||
|
||||
let envelope = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
|
||||
.tags(tags)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&envelope).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_envelopes(&self, cx: &App) -> Task<Result<Vec<Event>>> {
|
||||
let cache_tag = self.key.cache_tag();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
|
||||
|
||||
let mut events: Vec<Event> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|envelope| Event::from_json(&envelope.content).ok())
|
||||
.collect();
|
||||
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
|
||||
Ok(events)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn hydrate(&mut self, events: Vec<Event>, me: PublicKey, cx: &mut Context<Self>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rendered = Vec::new();
|
||||
let mut changed = false;
|
||||
|
||||
for event in &events {
|
||||
match self.absorb(event, me) {
|
||||
Absorbed::Shown {
|
||||
fresh: true,
|
||||
updated,
|
||||
} => {
|
||||
rendered.push(event.clone());
|
||||
changed |= updated;
|
||||
}
|
||||
Absorbed::Shown {
|
||||
fresh: false,
|
||||
updated,
|
||||
} => changed |= updated,
|
||||
Absorbed::Updated => changed = true,
|
||||
Absorbed::Ignored => {}
|
||||
}
|
||||
}
|
||||
|
||||
for event in rendered {
|
||||
cx.emit(GroupEvent::Incoming(event));
|
||||
}
|
||||
|
||||
if changed {
|
||||
cx.emit(GroupEvent::Updated);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context<Self>) {
|
||||
self.window.remove(id);
|
||||
|
||||
if !self.index.remove(&id) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.rows.retain(|row| row.id() != id);
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn eose(&mut self, cx: &mut Context<Self>) {
|
||||
self.loading = false;
|
||||
cx.emit(GroupEvent::Eose);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn refuse(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||
self.loading = false;
|
||||
self.refused = Some(reason.clone());
|
||||
cx.emit(GroupEvent::Refused(reason));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed {
|
||||
if STATE_KINDS.contains(&event.kind) {
|
||||
return if self.absorb_state(event) {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
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 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());
|
||||
|
||||
if self.oldest.is_none_or(|oldest| event.created_at < oldest) {
|
||||
self.oldest = Some(event.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
fn absorb_state(&mut self, event: &Event) -> bool {
|
||||
match event.kind {
|
||||
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
||||
Ok(metadata) => self.metadata = Some(metadata),
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupAdmins => match GroupAdmins::parse(event) {
|
||||
Ok(admins) => self.admins = admins,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupMembers => match GroupMembers::parse(event) {
|
||||
Ok(members) => self.members = members,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupRoles => match GroupRoles::parse(event) {
|
||||
Ok(roles) => self.roles = roles,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupLivekitParticipants => match GroupParticipants::parse(event) {
|
||||
Ok(participants) => self.participants = participants,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::Custom(PIN_LIST) => match GroupPins::parse(event) {
|
||||
Ok(pins) => self.pins = pins,
|
||||
Err(_) => return false,
|
||||
},
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
self.state_ids.insert(event.kind, event.id) != Some(event.id)
|
||||
}
|
||||
|
||||
fn push_row(&mut self, event: &Event) -> bool {
|
||||
if !self.index.insert(event.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let message = Message::from(event);
|
||||
let position = self
|
||||
.rows
|
||||
.partition_point(|row| row.created_at() <= message.created_at);
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn refresh_membership(&mut self, me: PublicKey) -> bool {
|
||||
let derived = Membership::from_events(&self.membership_events, me);
|
||||
|
||||
if derived != Membership::Unknown && derived != self.membership {
|
||||
self.membership = derived;
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, Result};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
mod group;
|
||||
mod protocol;
|
||||
|
||||
pub use group::*;
|
||||
pub use protocol::*;
|
||||
|
||||
pub(crate) static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
const LIST_SUBSCRIPTION: &str = "nip29-list";
|
||||
const FORK_SUBSCRIPTION: &str = "nip29-forks";
|
||||
const GROUP_PREFIX: &str = "grp-";
|
||||
const BROWSE_PREFIX: &str = "brw-";
|
||||
const PAGE_PREFIX: &str = "pg-";
|
||||
const BROWSE_LIMIT: usize = 500;
|
||||
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalGroupsRegistry(Entity<GroupsRegistry>);
|
||||
|
||||
impl Global for GlobalGroupsRegistry {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GroupsEvent {
|
||||
Updated,
|
||||
Error(String),
|
||||
Browsed(Box<GroupCandidate>),
|
||||
BrowseSettled(RelayUrl),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Signal {
|
||||
Event(SubscriptionId, Event),
|
||||
Eose(SubscriptionId),
|
||||
Closed(SubscriptionId, String),
|
||||
BrowseSettled(SubscriptionId),
|
||||
Forks,
|
||||
}
|
||||
|
||||
fn list_subscription_id() -> SubscriptionId {
|
||||
SubscriptionId::new(LIST_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
fn fork_subscription_id() -> SubscriptionId {
|
||||
SubscriptionId::new(FORK_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
fn subscription_id(key: &GroupKey) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||
}
|
||||
|
||||
fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
relay.hash(&mut hasher);
|
||||
|
||||
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32))
|
||||
}
|
||||
|
||||
fn page_subscription(key: &GroupKey) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||
}
|
||||
|
||||
fn is_page(id: &SubscriptionId) -> bool {
|
||||
id.as_str().starts_with(PAGE_PREFIX)
|
||||
}
|
||||
|
||||
fn is_own_subscription(id: &SubscriptionId) -> bool {
|
||||
let id = id.as_str();
|
||||
id == LIST_SUBSCRIPTION
|
||||
|| id == FORK_SUBSCRIPTION
|
||||
|| id.starts_with(GROUP_PREFIX)
|
||||
|| id.starts_with(BROWSE_PREFIX)
|
||||
|| id.starts_with(PAGE_PREFIX)
|
||||
}
|
||||
|
||||
impl EventEmitter<GroupsEvent> for GroupsRegistry {}
|
||||
|
||||
pub struct GroupsRegistry {
|
||||
groups: Vec<Entity<Group>>,
|
||||
list: GroupList,
|
||||
browse: HashMap<SubscriptionId, RelayUrl>,
|
||||
routes: HashMap<SubscriptionId, GroupKey>,
|
||||
observers: HashMap<GroupKey, Subscription>,
|
||||
forks: HashMap<PublicKey, (Timestamp, GroupList)>,
|
||||
fork_authors: HashSet<PublicKey>,
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
signal_rx: flume::Receiver<Signal>,
|
||||
notification_listener: Option<Task<()>>,
|
||||
signal_consumer: Option<Task<()>>,
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl GroupsRegistry {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalGroupsRegistry>().0.clone()
|
||||
}
|
||||
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalGroupsRegistry(state));
|
||||
}
|
||||
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let this = cx.entity().downgrade();
|
||||
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
|
||||
if event.signer_changed() {
|
||||
this.reset(cx);
|
||||
this.handle_notifications(cx);
|
||||
this.load(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = this.update(cx, |this, cx| {
|
||||
this.handle_notifications(cx);
|
||||
|
||||
if nostr.read(cx).current_user().is_some() {
|
||||
this.load(cx);
|
||||
}
|
||||
}) {
|
||||
log::warn!("nip29: initializing the registry failed: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
groups: Vec::new(),
|
||||
list: GroupList::default(),
|
||||
browse: HashMap::new(),
|
||||
routes: HashMap::new(),
|
||||
observers: HashMap::new(),
|
||||
forks: HashMap::new(),
|
||||
fork_authors: HashSet::new(),
|
||||
signal_tx: tx,
|
||||
signal_rx: rx,
|
||||
notification_listener: None,
|
||||
signal_consumer: None,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn groups(&self) -> &[Entity<Group>] {
|
||||
&self.groups
|
||||
}
|
||||
|
||||
pub fn group(&self, key: &GroupKey, cx: &App) -> Option<Entity<Group>> {
|
||||
self.groups
|
||||
.iter()
|
||||
.find(|group| group.read(cx).key() == key)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn join(&mut self, reference: GroupReference, cx: &mut Context<Self>) {
|
||||
self.join_key(reference.key(), reference.invite().map(str::to_owned), cx);
|
||||
}
|
||||
|
||||
pub fn join_key(&mut self, key: GroupKey, invite: Option<String>, cx: &mut Context<Self>) {
|
||||
self.ensure(key.clone(), cx);
|
||||
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.upsert_entry(&key, None);
|
||||
self.publish_list(cx);
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.notify();
|
||||
|
||||
let builder = key.id().join_request(invite.as_deref(), "");
|
||||
let sent = group.read(cx).publish(builder, cx);
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.mark_pending(Timestamp::now(), cx);
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(error) = sent.await {
|
||||
let reason = error.to_string();
|
||||
|
||||
if let Err(error) = this.update(cx, |this, cx| {
|
||||
if let Some(group) = this.group(&key, cx) {
|
||||
group.update(cx, |group, cx| {
|
||||
group.mark_refused(reason, cx);
|
||||
});
|
||||
}
|
||||
}) {
|
||||
log::warn!("nip29: marking the refused join failed: {error}");
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn create(&mut self, key: GroupKey, metadata: GroupMetadata, cx: &mut Context<Self>) {
|
||||
self.ensure(key.clone(), cx);
|
||||
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.upsert_entry(&key, metadata.name().map(str::to_owned));
|
||||
self.publish_list(cx);
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.notify();
|
||||
|
||||
let builder = key.id().create_group(&metadata, None);
|
||||
let published = group.read(cx).publish(builder, cx);
|
||||
let logged = key.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = published.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.forget(logged, cx);
|
||||
cx.emit(GroupsEvent::Error(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn browse(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let id = browse_subscription(&relay);
|
||||
let tx = self.signal_tx.clone();
|
||||
|
||||
self.browse.insert(id.clone(), relay.clone());
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||
|
||||
let filter = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT);
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(BROWSE_TIMEOUT));
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay.clone(), [filter]))
|
||||
.with_id(id.clone())
|
||||
.close_on(opts)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: browsing {relay} failed: {error}");
|
||||
}
|
||||
|
||||
if let Err(error) = tx.send_async(Signal::BrowseSettled(id)).await {
|
||||
log::warn!("nip29: the browse settle signal was dropped: {error}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn settle_browse(&mut self, id: SubscriptionId, cx: &mut Context<Self>) {
|
||||
let Some(relay) = self.browse.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
cx.emit(GroupsEvent::BrowseSettled(relay));
|
||||
}
|
||||
|
||||
pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||
let Some(group) = self.group(key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if group.read(cx).loading_more() || group.read(cx).oldest().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.routes.insert(page_subscription(key), key.clone());
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.load_page(cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn leave(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||
let Some(group) = self.group(key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let builder = key.id().leave_request("");
|
||||
let sent = group.read_with(cx, |group, cx| group.publish(builder, cx));
|
||||
let key = key.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(error) = sent.await {
|
||||
log::warn!("nip29: leaving {} failed: {error}", key.id());
|
||||
return;
|
||||
}
|
||||
this.update(cx, |this, cx| {
|
||||
this.forget(key, cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
self.signal_consumer = None;
|
||||
self.tasks.clear();
|
||||
|
||||
let routes = std::mem::take(&mut self.routes);
|
||||
|
||||
self.groups.clear();
|
||||
self.observers.clear();
|
||||
self.forks.clear();
|
||||
self.fork_authors.clear();
|
||||
self.list = GroupList::default();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let mut ids: Vec<SubscriptionId> = routes.into_keys().collect();
|
||||
ids.push(list_subscription_id());
|
||||
ids.push(fork_subscription_id());
|
||||
ids.extend(self.browse.keys().cloned());
|
||||
|
||||
self.browse.clear();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
for id in ids {
|
||||
if let Err(error) = client.unsubscribe(&id).await {
|
||||
log::warn!("nip29: unsubscribing {id} failed: {error}");
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let task: Task<Result<GroupList, Error>> = cx.background_spawn(async move {
|
||||
let me = signer.get_public_key_async().await?;
|
||||
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
||||
|
||||
client
|
||||
.subscribe(ReqTarget::auto([filter.clone()]))
|
||||
.with_id(list_subscription_id())
|
||||
.await?;
|
||||
|
||||
match client.database().query(filter).await?.into_iter().next() {
|
||||
Some(event) => GroupList::parse(&event),
|
||||
None => Ok(GroupList::default()),
|
||||
}
|
||||
});
|
||||
|
||||
self.tasks
|
||||
.push(cx.spawn(async move |this, cx| match task.await {
|
||||
Ok(list) => {
|
||||
if let Err(error) = this.update(cx, |this, cx| this.merge(list, cx)) {
|
||||
log::warn!("nip29: applying the group list failed: {error}");
|
||||
}
|
||||
}
|
||||
Err(error) => log::warn!("nip29: loading the group list failed: {error}"),
|
||||
}));
|
||||
|
||||
self.watch_forks(cx);
|
||||
}
|
||||
|
||||
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
|
||||
let entries = list.entries().to_vec();
|
||||
|
||||
for entry in entries {
|
||||
self.list.upsert(entry.clone());
|
||||
self.ensure(GroupKey::new(entry.relay.clone(), entry.id.clone()), cx);
|
||||
}
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn watch_forks(&mut self, cx: &mut Context<Self>) {
|
||||
let executor = cx.background_executor().clone();
|
||||
let tx = self.signal_tx.clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
loop {
|
||||
executor.timer(FORK_INTERVAL).await;
|
||||
|
||||
if tx.send_async(Signal::Forks).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn admin_keys(&self, cx: &App) -> HashSet<PublicKey> {
|
||||
let mut keys = HashSet::new();
|
||||
|
||||
for group in &self.groups {
|
||||
for (admin, _) in group.read(cx).admins().entries() {
|
||||
keys.insert(*admin);
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
}
|
||||
|
||||
fn sync_forks(&mut self, force: bool, cx: &mut Context<Self>) {
|
||||
let authors = self.admin_keys(cx);
|
||||
|
||||
if authors.is_empty() || (!force && authors == self.fork_authors) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let filter = Filter::new()
|
||||
.authors(authors.iter().copied())
|
||||
.kind(Kind::SimpleGroups);
|
||||
|
||||
self.fork_authors = authors;
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::auto([filter]))
|
||||
.with_id(fork_subscription_id())
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: subscribing to the admins' group lists failed: {error}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn absorb_fork_list(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
let Ok(list) = GroupList::parse(event) else {
|
||||
log::warn!("nip29: ignoring a malformed admin group list");
|
||||
return;
|
||||
};
|
||||
|
||||
let author = event.pubkey;
|
||||
|
||||
if let Some((seen, _)) = self.forks.get(&author)
|
||||
&& *seen >= event.created_at
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.forks.insert(author, (event.created_at, list));
|
||||
self.evaluate_forks(cx);
|
||||
}
|
||||
|
||||
fn evaluate_forks(&mut self, cx: &mut Context<Self>) {
|
||||
let groups = self.groups.clone();
|
||||
|
||||
for group in groups {
|
||||
let elsewhere = {
|
||||
let group = group.read(cx);
|
||||
let key = group.key();
|
||||
|
||||
group
|
||||
.admins()
|
||||
.entries()
|
||||
.iter()
|
||||
.filter_map(|(admin, _)| self.forks.get(admin))
|
||||
.flat_map(|(_, list)| list.entries())
|
||||
.find(|entry| entry.id == *key.id() && entry.relay != *key.relay())
|
||||
.map(|entry| GroupKey::new(entry.relay.clone(), entry.id.clone()))
|
||||
};
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.set_elsewhere(elsewhere, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
||||
if self.group(&key, cx).is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let group = cx.new(|_| Group::new(key.clone()));
|
||||
self.groups.push(group.clone());
|
||||
|
||||
self.observers.insert(
|
||||
key.clone(),
|
||||
cx.subscribe(&group, |this, group, event, cx| match event {
|
||||
GroupEvent::Sent(event) => {
|
||||
if event.kind == Kind::GroupDeleteEvent {
|
||||
this.forget_event(group, event.clone(), cx);
|
||||
} else {
|
||||
let key = group.read(cx).key().clone();
|
||||
this.reload(key, Some(event.clone()), cx);
|
||||
}
|
||||
}
|
||||
GroupEvent::Updated => {
|
||||
this.refresh_entry(group, cx);
|
||||
this.sync_forks(false, cx);
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
_ => {}
|
||||
}),
|
||||
);
|
||||
|
||||
self.subscribe(key.clone(), cx);
|
||||
self.reload(key, None, cx);
|
||||
}
|
||||
|
||||
fn subscribe(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.routes.insert(subscription_id(&key), key);
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.connect(cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn forget(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
||||
let id = subscription_id(&key);
|
||||
let page = page_subscription(&key);
|
||||
let cache_tag = key.cache_tag();
|
||||
|
||||
self.routes.remove(&id);
|
||||
self.routes.remove(&page);
|
||||
self.observers.remove(&key);
|
||||
self.groups.retain(|group| group.read(cx).key() != &key);
|
||||
self.list.remove(&key);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
for id in [id, page] {
|
||||
if let Err(error) = client.unsubscribe(&id).await {
|
||||
log::warn!("nip29: unsubscribing {id} failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
|
||||
|
||||
if let Err(error) = client.database().delete(filter).await {
|
||||
log::warn!("nip29: clearing the group cache failed: {error}");
|
||||
}
|
||||
}));
|
||||
|
||||
self.publish_list(cx);
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn entry_name(&self, key: &GroupKey) -> Option<String> {
|
||||
self.list
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|entry| entry.id == *key.id() && entry.relay == *key.relay())
|
||||
.and_then(|entry| entry.name.clone())
|
||||
}
|
||||
|
||||
fn upsert_entry(&mut self, key: &GroupKey, name: Option<String>) {
|
||||
let name = name.or_else(|| self.entry_name(key));
|
||||
|
||||
self.list.upsert(GroupListEntry {
|
||||
id: key.id().clone(),
|
||||
relay: key.relay().clone(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
fn refresh_entry(&mut self, group: Entity<Group>, cx: &mut Context<Self>) {
|
||||
let (key, Some(name)) = ({
|
||||
let group = group.read(cx);
|
||||
|
||||
(
|
||||
group.key().clone(),
|
||||
group
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.name().map(str::to_owned)),
|
||||
)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if self.entry_name(&key).as_deref() == Some(name.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.upsert_entry(&key, Some(name));
|
||||
self.publish_list(cx);
|
||||
}
|
||||
|
||||
fn publish_list(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let builder = self.list.to_builder();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
||||
let event = match builder.finalize_async(&signer).await {
|
||||
Ok(event) => event,
|
||||
Err(error) => {
|
||||
log::warn!("nip29: signing the group list failed: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.send_event(&event).to_nip65().await {
|
||||
Ok(output) if output.failed.is_empty() => {}
|
||||
Ok(output) => log::warn!(
|
||||
"nip29: {} relay(s) rejected the group list",
|
||||
output.failed.len()
|
||||
),
|
||||
Err(error) => log::warn!("nip29: publishing the group list failed: {error}"),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn reload(&mut self, key: GroupKey, event: Option<Event>, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let logged = key.clone();
|
||||
let load = group.read(cx).load_envelopes(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, cx| {
|
||||
if let Some(event) = event
|
||||
&& let Err(error) = group
|
||||
.read_with(cx, |group, cx| group.save_envelope(event, cx))
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: caching failed: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
match load.await {
|
||||
Ok(events) => {
|
||||
group.update(cx, |group, cx| {
|
||||
group.hydrate(events, me, cx);
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("nip29: reloading {} failed: {error}", logged.id());
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn forget_event(&mut self, group: Entity<Group>, event: Event, cx: &mut Context<Self>) {
|
||||
let Some(target) = event.tags.event_ids().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let key = group.read(cx).key().clone();
|
||||
let cache_tag = key.cache_tag();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let purge = cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(target.to_hex())
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
|
||||
|
||||
client.database().delete(filter).await
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(error) = purge.await {
|
||||
log::warn!("nip29: dropping the deleted envelope failed: {error}");
|
||||
}
|
||||
this.update(cx, |this, cx| {
|
||||
group.update(cx, |group, cx| {
|
||||
group.forget(target, cx);
|
||||
});
|
||||
this.reload(key, Some(event), cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context<Self>) {
|
||||
if id == list_subscription_id() {
|
||||
match GroupList::parse(&event) {
|
||||
Ok(list) => self.merge(list, cx),
|
||||
Err(error) => log::warn!("nip29: ignoring a malformed group list: {error}"),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if id == fork_subscription_id() {
|
||||
self.absorb_fork_list(&event, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.browse.contains_key(&id) {
|
||||
self.absorb_candidate(&id, &event, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(key) = self.routes.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.reload(key, Some(event), cx);
|
||||
}
|
||||
|
||||
fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context<Self>) {
|
||||
let Some(relay) = self.browse.get(id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(metadata) = GroupMetadata::parse(event) else {
|
||||
log::warn!("nip29: ignoring malformed group metadata from {relay}");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(group) = metadata.id().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
cx.emit(GroupsEvent::Browsed(Box::new(GroupCandidate {
|
||||
key: GroupKey::new(relay, group),
|
||||
metadata,
|
||||
})));
|
||||
}
|
||||
|
||||
fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context<Self>) {
|
||||
let Some(key) = self.routes.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if is_page(&id) {
|
||||
self.routes.remove(&id);
|
||||
|
||||
if let Some(group) = self.group(&key, cx) {
|
||||
group.update(cx, |group, cx| {
|
||||
group.page_finished(cx);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.eose(cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context<Self>) {
|
||||
if let Some(relay) = self.browse.get(&id) {
|
||||
log::warn!("nip29: browsing {relay} was refused: {reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
if id == fork_subscription_id() {
|
||||
log::warn!("nip29: the admins' group lists were refused: {reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(key) = self.routes.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if is_page(&id) {
|
||||
self.routes.remove(&id);
|
||||
log::warn!(
|
||||
"nip29: loading older messages in {} was refused: {reason}",
|
||||
key.id()
|
||||
);
|
||||
|
||||
if let Some(group) = self.group(&key, cx) {
|
||||
group.update(cx, |group, cx| {
|
||||
group.page_finished(cx);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.refuse(reason, cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
self.signal_consumer = None;
|
||||
|
||||
let nostr = NostrRegistry::global(cx).read(cx);
|
||||
let client = nostr.client();
|
||||
|
||||
let tx = self.signal_tx.clone();
|
||||
let rx = self.signal_rx.clone();
|
||||
|
||||
self.notification_listener = Some(cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
let mut processed = HashSet::new();
|
||||
const MAX_PROCESSED: usize = 10_000;
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
let ClientNotification::Message { message, .. } = notification else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let signal = match *message {
|
||||
RelayMessage::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
} => {
|
||||
let id = subscription_id.into_owned();
|
||||
|
||||
if !is_own_subscription(&id) {
|
||||
None
|
||||
} else {
|
||||
if processed.len() >= MAX_PROCESSED {
|
||||
processed.clear();
|
||||
}
|
||||
processed
|
||||
.insert(event.id)
|
||||
.then(|| Signal::Event(id, event.into_owned()))
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id) => {
|
||||
let id = id.into_owned();
|
||||
is_own_subscription(&id).then_some(Signal::Eose(id))
|
||||
}
|
||||
RelayMessage::Closed {
|
||||
subscription_id,
|
||||
message,
|
||||
} => {
|
||||
let id = subscription_id.into_owned();
|
||||
is_own_subscription(&id).then_some(Signal::Closed(id, message.into_owned()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(signal) = signal {
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(signal) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| match signal {
|
||||
Signal::Event(id, event) => {
|
||||
this.handle_event(id, event, cx);
|
||||
}
|
||||
Signal::Eose(id) => {
|
||||
this.handle_eose(id, cx);
|
||||
}
|
||||
Signal::Closed(id, reason) => {
|
||||
this.handle_closed(id, reason, cx);
|
||||
}
|
||||
Signal::BrowseSettled(id) => {
|
||||
this.settle_browse(id, cx);
|
||||
}
|
||||
Signal::Forks => {
|
||||
this.sync_forks(true, cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
pub const TIMELINE_WINDOW: usize = 50;
|
||||
|
||||
const PREVIOUS_REFS: usize = 3;
|
||||
const PREVIOUS_REF_LEN: usize = 8;
|
||||
|
||||
pub const RENDER_KINDS: [Kind; 5] = [
|
||||
Kind::TextNote,
|
||||
Kind::ChatMessage,
|
||||
Kind::Thread,
|
||||
Kind::Comment,
|
||||
Kind::Reaction,
|
||||
];
|
||||
|
||||
pub const PIN_LIST: u16 = 39_005;
|
||||
pub const UPDATE_PIN_LIST: u16 = 9_010;
|
||||
|
||||
pub const STATE_KINDS: [Kind; 6] = [
|
||||
Kind::GroupMetadata,
|
||||
Kind::GroupAdmins,
|
||||
Kind::GroupMembers,
|
||||
Kind::GroupRoles,
|
||||
Kind::GroupLivekitParticipants,
|
||||
Kind::Custom(PIN_LIST),
|
||||
];
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
tags.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
.and_then(|tag| tag.content())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct GroupId(String);
|
||||
|
||||
impl GroupId {
|
||||
pub fn new(id: impl Into<String>) -> Result<Self> {
|
||||
let id = id.into();
|
||||
let id = id.trim();
|
||||
|
||||
if id.is_empty() {
|
||||
bail!("group id is empty");
|
||||
}
|
||||
|
||||
Ok(Self(id.to_owned()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn h_tag(&self) -> Tag {
|
||||
Tag::custom("h", [self.0.clone()])
|
||||
}
|
||||
|
||||
pub fn timeline_filter(&self, limit: usize) -> Filter {
|
||||
Filter::new()
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
|
||||
.kinds(RENDER_KINDS)
|
||||
.kinds(ACTIVITY_KINDS)
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
pub fn timeline_page(&self, limit: usize, until: Timestamp) -> Filter {
|
||||
self.timeline_filter(limit).until(until)
|
||||
}
|
||||
|
||||
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 {
|
||||
Filter::new()
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_P, me.to_hex())
|
||||
.kinds(MEMBERSHIP_KINDS)
|
||||
}
|
||||
|
||||
pub fn message(
|
||||
&self,
|
||||
content: &str,
|
||||
replies: &[EventId],
|
||||
previous: Option<Tag>,
|
||||
) -> EventBuilder {
|
||||
EventBuilder::new(Kind::ChatMessage, content)
|
||||
.tag(self.h_tag())
|
||||
.tags(replies.iter().copied().map(Tag::event))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn reaction(&self, target: EventId, emoji: &str, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::Reaction, emoji)
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::event(target))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn join_request(&self, code: Option<&str>, reason: &str) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupJoinRequest, reason)
|
||||
.tag(self.h_tag())
|
||||
.tag_maybe(code.map(|code| Tag::custom("code", [code])))
|
||||
}
|
||||
|
||||
pub fn leave_request(&self, reason: &str) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupLeaveRequest, reason).tag(self.h_tag())
|
||||
}
|
||||
|
||||
pub fn put_user(
|
||||
&self,
|
||||
public_key: PublicKey,
|
||||
roles: &[String],
|
||||
previous: Option<Tag>,
|
||||
) -> EventBuilder {
|
||||
let mut values = Vec::with_capacity(1 + roles.len());
|
||||
values.push(public_key.to_hex());
|
||||
values.extend(roles.iter().cloned());
|
||||
|
||||
EventBuilder::new(Kind::GroupPutUser, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::custom("p", values))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn remove_user(&self, public_key: PublicKey, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupRemoveUser, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::public_key(public_key))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn edit_metadata(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupEditMetadata, "")
|
||||
.tags(self.metadata_tags(metadata))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn create_group(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupCreateGroup, "")
|
||||
.tags(self.metadata_tags(metadata))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
fn metadata_tags(&self, metadata: &GroupMetadata) -> Vec<Tag> {
|
||||
let mut tags = vec![self.h_tag()];
|
||||
|
||||
for (name, value) in [
|
||||
("name", &metadata.name),
|
||||
("picture", &metadata.picture),
|
||||
("banner", &metadata.banner),
|
||||
("about", &metadata.about),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
tags.push(Tag::custom(name, [value.clone()]));
|
||||
}
|
||||
}
|
||||
|
||||
for (name, set) in [
|
||||
("private", metadata.private),
|
||||
("restricted", metadata.restricted),
|
||||
("hidden", metadata.hidden),
|
||||
("closed", metadata.closed),
|
||||
("livekit", metadata.livekit),
|
||||
] {
|
||||
if set {
|
||||
tags.push(Tag::custom(name, Vec::<String>::new()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = &metadata.parent {
|
||||
tags.push(Tag::custom("parent", [parent.as_str()]));
|
||||
}
|
||||
|
||||
for child in &metadata.children {
|
||||
tags.push(Tag::custom("child", [child.as_str()]));
|
||||
}
|
||||
|
||||
if let Some(kinds) = &metadata.supported_kinds {
|
||||
tags.push(Tag::custom(
|
||||
"supported_kinds",
|
||||
kinds.iter().map(ToString::to_string),
|
||||
));
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
pub fn delete_event(&self, id: EventId, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupDeleteEvent, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::event(id))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn create_invite(&self, code: &str, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupCreateInvite, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::custom("code", [code]))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn update_pin_list(&self, pins: &[Pin], previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::Custom(UPDATE_PIN_LIST), "")
|
||||
.tag(self.h_tag())
|
||||
.tags(pins.iter().map(Pin::tag))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GroupId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct GroupKey {
|
||||
relay: RelayUrl,
|
||||
id: GroupId,
|
||||
}
|
||||
|
||||
impl GroupKey {
|
||||
pub fn new(relay: RelayUrl, id: GroupId) -> Self {
|
||||
Self { relay, id }
|
||||
}
|
||||
|
||||
pub fn relay(&self) -> &RelayUrl {
|
||||
&self.relay
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &GroupId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn cache_tag(&self) -> String {
|
||||
format!("{}|{}", self.relay.as_str(), self.id)
|
||||
}
|
||||
|
||||
pub fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
self.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn livekit_token_url(&self) -> Result<Url> {
|
||||
self.livekit_url(Some(self.id.as_str()))
|
||||
}
|
||||
|
||||
pub fn livekit_probe_url(&self) -> Result<Url> {
|
||||
self.livekit_url(None)
|
||||
}
|
||||
|
||||
fn livekit_url(&self, group: Option<&str>) -> Result<Url> {
|
||||
let mut url: Url = self.relay.clone().into();
|
||||
let scheme = if self.relay.scheme().is_secure() {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
|
||||
url.set_path("/");
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
url.set_scheme(scheme)
|
||||
.map_err(|_| anyhow!("{scheme} is not available for {}", self.relay))?;
|
||||
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| anyhow!("{} cannot carry a path", self.relay))?;
|
||||
|
||||
segments
|
||||
.pop_if_empty()
|
||||
.push(".well-known")
|
||||
.push("nip29")
|
||||
.push("livekit");
|
||||
|
||||
if let Some(group) = group {
|
||||
segments.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GroupReference {
|
||||
id: GroupId,
|
||||
relay: RelayUrl,
|
||||
invite: Option<String>,
|
||||
}
|
||||
|
||||
impl GroupReference {
|
||||
pub fn parse(input: &str) -> Result<Self> {
|
||||
let input = input.trim();
|
||||
|
||||
let (address, query) = match input.split_once('?') {
|
||||
Some((address, query)) => (address, Some(query)),
|
||||
None => (input, None),
|
||||
};
|
||||
|
||||
let coordinate = Nip19Coordinate::from_bech32(address)?;
|
||||
|
||||
if coordinate.kind != Kind::GroupMetadata {
|
||||
bail!("not a group reference: kind {}", coordinate.kind);
|
||||
}
|
||||
|
||||
let relay = coordinate
|
||||
.relays
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("group reference has no relay hint"))?;
|
||||
|
||||
let id = GroupId::new(coordinate.identifier.as_str())?;
|
||||
|
||||
let invite = query
|
||||
.and_then(|query| {
|
||||
query
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("invite="))
|
||||
})
|
||||
.map(str::to_owned);
|
||||
|
||||
Ok(Self { id, relay, invite })
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &GroupId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn relay(&self) -> &RelayUrl {
|
||||
&self.relay
|
||||
}
|
||||
|
||||
pub fn invite(&self) -> Option<&str> {
|
||||
self.invite.as_deref()
|
||||
}
|
||||
|
||||
pub fn key(&self) -> GroupKey {
|
||||
GroupKey::new(self.relay.clone(), self.id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for GroupReference {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self> {
|
||||
Self::parse(input)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupMetadata {
|
||||
pub name: Option<String>,
|
||||
pub picture: Option<String>,
|
||||
pub banner: Option<String>,
|
||||
pub about: Option<String>,
|
||||
pub private: bool,
|
||||
pub restricted: bool,
|
||||
pub hidden: bool,
|
||||
pub closed: bool,
|
||||
pub livekit: bool,
|
||||
pub parent: Option<GroupId>,
|
||||
pub children: Vec<GroupId>,
|
||||
pub supported_kinds: Option<Vec<Kind>>,
|
||||
id: Option<GroupId>,
|
||||
author: Option<PublicKey>,
|
||||
}
|
||||
|
||||
impl GroupMetadata {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupMetadata {
|
||||
bail!("not a group metadata event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let identifier = event
|
||||
.tags
|
||||
.identifier()
|
||||
.ok_or_else(|| anyhow!("group metadata has no d tag"))?;
|
||||
|
||||
let mut supported_kinds: Option<Vec<Kind>> = None;
|
||||
|
||||
for tag in event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.kind() == "supported_kinds")
|
||||
{
|
||||
let kinds = supported_kinds.get_or_insert_with(Vec::new);
|
||||
|
||||
for value in tag.as_slice().iter().skip(1) {
|
||||
kinds.push(Kind::from_str(value)?);
|
||||
}
|
||||
}
|
||||
|
||||
let parent = match tag_value(&event.tags, "parent") {
|
||||
Some(parent) => Some(GroupId::new(parent)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut children = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "child") {
|
||||
if let Some(child) = tag.content() {
|
||||
children.push(GroupId::new(child)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
name: tag_value(&event.tags, "name").map(str::to_owned),
|
||||
picture: tag_value(&event.tags, "picture").map(str::to_owned),
|
||||
banner: tag_value(&event.tags, "banner").map(str::to_owned),
|
||||
about: tag_value(&event.tags, "about").map(str::to_owned),
|
||||
private: has_tag(&event.tags, "private"),
|
||||
restricted: has_tag(&event.tags, "restricted"),
|
||||
hidden: has_tag(&event.tags, "hidden"),
|
||||
closed: has_tag(&event.tags, "closed"),
|
||||
livekit: has_tag(&event.tags, "livekit"),
|
||||
parent,
|
||||
children,
|
||||
supported_kinds,
|
||||
id: Some(GroupId::new(identifier)?),
|
||||
author: Some(event.pubkey),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn id(&self) -> Option<&GroupId> {
|
||||
self.id.as_ref()
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
}
|
||||
|
||||
pub fn supports(&self, kind: Kind) -> bool {
|
||||
match &self.supported_kinds {
|
||||
None => true,
|
||||
Some(kinds) => kinds.contains(&kind),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result<String> {
|
||||
let id = self.id.as_ref().ok_or_else(|| anyhow!("no id"))?;
|
||||
let author = self.author.ok_or_else(|| anyhow!("no author"))?;
|
||||
|
||||
let coordinate = Coordinate::new(Kind::GroupMetadata, author).identifier(id.as_str());
|
||||
let address = Nip19Coordinate::new(coordinate, [relay.clone()]).to_bech32()?;
|
||||
|
||||
Ok(match invite {
|
||||
Some(invite) => format!("{address}?invite={invite}"),
|
||||
None => address,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GroupCandidate {
|
||||
pub key: GroupKey,
|
||||
pub metadata: GroupMetadata,
|
||||
}
|
||||
|
||||
impl GroupCandidate {
|
||||
pub fn display_name(&self) -> &str {
|
||||
self.metadata
|
||||
.name()
|
||||
.unwrap_or_else(|| self.key.id().as_str())
|
||||
}
|
||||
|
||||
pub fn display_image(&self) -> Option<&str> {
|
||||
self.metadata.picture.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupAdmins(Vec<(PublicKey, Vec<String>)>);
|
||||
|
||||
impl GroupAdmins {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupAdmins {
|
||||
bail!("not a group admins event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut admins = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "p") {
|
||||
let mut fields = tag.as_slice().iter().skip(1);
|
||||
|
||||
let public_key = fields
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("admin tag has no public key"))?;
|
||||
let public_key = PublicKey::from_hex(public_key)?;
|
||||
|
||||
admins.push((public_key, fields.cloned().collect()));
|
||||
}
|
||||
|
||||
Ok(Self(admins))
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[(PublicKey, Vec<String>)] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn contains(&self, public_key: &PublicKey) -> bool {
|
||||
self.0.iter().any(|(key, _)| key == public_key)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupMembers(Vec<PublicKey>);
|
||||
|
||||
impl GroupMembers {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupMembers {
|
||||
bail!("not a group members event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
Ok(Self(event.tags.public_keys().collect()))
|
||||
}
|
||||
|
||||
pub fn contains(&self, public_key: &PublicKey) -> bool {
|
||||
self.0.contains(public_key)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &PublicKey> {
|
||||
self.0.iter()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Role {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupRoles(Vec<Role>);
|
||||
|
||||
impl GroupRoles {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupRoles {
|
||||
bail!("not a group roles event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut roles = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "role") {
|
||||
let mut fields = tag.as_slice().iter().skip(1);
|
||||
let name = fields
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("role tag has no name"))?;
|
||||
|
||||
roles.push(Role {
|
||||
name: name.clone(),
|
||||
description: fields.next().cloned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self(roles))
|
||||
}
|
||||
|
||||
pub fn roles(&self) -> &[Role] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupParticipants(Vec<PublicKey>);
|
||||
|
||||
impl GroupParticipants {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupLivekitParticipants {
|
||||
bail!("not a group participants event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut participants = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "participant") {
|
||||
let Some(public_key) = tag.content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
participants.push(PublicKey::parse(public_key)?);
|
||||
}
|
||||
|
||||
Ok(Self(participants))
|
||||
}
|
||||
|
||||
pub fn contains(&self, public_key: &PublicKey) -> bool {
|
||||
self.0.contains(public_key)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &PublicKey> {
|
||||
self.0.iter()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Pin {
|
||||
Event(EventId),
|
||||
Address(Coordinate),
|
||||
}
|
||||
|
||||
impl Pin {
|
||||
fn tag(&self) -> Tag {
|
||||
match self {
|
||||
Pin::Event(id) => Tag::event(*id),
|
||||
Pin::Address(coordinate) => Tag::coordinate(coordinate.clone(), None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupPins(Vec<Pin>);
|
||||
|
||||
impl GroupPins {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::Custom(PIN_LIST) {
|
||||
bail!("not a pinned list event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut pins = Vec::new();
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match tag.kind() {
|
||||
"e" => {
|
||||
let Some(id) = tag.content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
pins.push(Pin::Event(EventId::from_hex(id)?));
|
||||
}
|
||||
"a" => {
|
||||
let Some(address) = tag.content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
pins.push(Pin::Address(Coordinate::from_str(address)?));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self(pins))
|
||||
}
|
||||
|
||||
pub fn pins(&self) -> &[Pin] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LivekitSession {
|
||||
pub token: String,
|
||||
pub server_url: String,
|
||||
}
|
||||
|
||||
impl LivekitSession {
|
||||
pub fn parse(body: &[u8]) -> Result<Self> {
|
||||
let value: serde_json::Value = serde_json::from_slice(body)?;
|
||||
|
||||
let token = value
|
||||
.get("token")
|
||||
.or_else(|| value.get("jwt"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| anyhow!("the AV response carries no token"))?;
|
||||
|
||||
let server_url = value
|
||||
.get("url")
|
||||
.or_else(|| value.get("server_url"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| anyhow!("the AV response carries no server url"))?;
|
||||
|
||||
Ok(Self {
|
||||
token: token.to_owned(),
|
||||
server_url: server_url.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GroupListEntry {
|
||||
pub id: GroupId,
|
||||
pub relay: RelayUrl,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupList(Vec<GroupListEntry>);
|
||||
|
||||
impl GroupList {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::SimpleGroups {
|
||||
bail!("not a group list event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "group") {
|
||||
let fields = tag.as_slice();
|
||||
|
||||
let (Some(id), Some(relay)) = (fields.get(1), fields.get(2)) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(id) = GroupId::new(id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(relay) = RelayUrl::parse(relay.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
entries.push(GroupListEntry {
|
||||
id,
|
||||
relay,
|
||||
name: fields.get(3).cloned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self(entries))
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[GroupListEntry] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, entry: GroupListEntry) {
|
||||
match self
|
||||
.0
|
||||
.iter_mut()
|
||||
.find(|existing| existing.id == entry.id && existing.relay == entry.relay)
|
||||
{
|
||||
Some(existing) => *existing = entry,
|
||||
None => self.0.push(entry),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &GroupKey) {
|
||||
self.0
|
||||
.retain(|entry| entry.id != *key.id() || entry.relay != *key.relay());
|
||||
}
|
||||
|
||||
pub fn to_builder(&self) -> EventBuilder {
|
||||
let mut tags: Vec<Tag> = Vec::with_capacity(self.0.len() * 2);
|
||||
|
||||
for entry in &self.0 {
|
||||
let mut values = vec![
|
||||
entry.id.as_str().to_owned(),
|
||||
entry.relay.as_str().to_owned(),
|
||||
];
|
||||
|
||||
if let Some(name) = &entry.name {
|
||||
values.push(name.clone());
|
||||
}
|
||||
|
||||
tags.push(Tag::custom("group", values));
|
||||
tags.push(Tag::custom("r", [entry.relay.as_str()]));
|
||||
}
|
||||
|
||||
EventBuilder::new(Kind::SimpleGroups, "").tags(tags)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Membership {
|
||||
Unknown,
|
||||
Pending { since: Timestamp },
|
||||
Member,
|
||||
Refused { reason: Option<String> },
|
||||
Removed,
|
||||
}
|
||||
|
||||
impl Membership {
|
||||
pub fn from_events(events: &[Event], me: PublicKey) -> Self {
|
||||
let mut latest: Option<&Event> = None;
|
||||
|
||||
for event in events {
|
||||
if !MEMBERSHIP_KINDS.contains(&event.kind) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !event.tags.public_keys().any(|key| key == me) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let newer = match latest {
|
||||
None => true,
|
||||
Some(current) => {
|
||||
event.created_at > current.created_at
|
||||
|| (event.created_at == current.created_at
|
||||
&& current.kind == Kind::GroupPutUser
|
||||
&& event.kind == Kind::GroupRemoveUser)
|
||||
}
|
||||
};
|
||||
|
||||
if newer {
|
||||
latest = Some(event);
|
||||
}
|
||||
}
|
||||
|
||||
match latest {
|
||||
Some(event) if event.kind == Kind::GroupRemoveUser => Membership::Removed,
|
||||
Some(_) => Membership::Member,
|
||||
None => Membership::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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>);
|
||||
|
||||
impl TimelineWindow {
|
||||
pub fn new() -> Self {
|
||||
Self(Vec::new())
|
||||
}
|
||||
|
||||
pub fn push(&mut self, event: Event) {
|
||||
self.0.retain(|existing| existing.id != event.id);
|
||||
let position = self.0.partition_point(|e| e.created_at > event.created_at);
|
||||
|
||||
self.0.insert(position, event);
|
||||
self.0.truncate(TIMELINE_WINDOW);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: EventId) {
|
||||
self.0.retain(|event| event.id != id);
|
||||
}
|
||||
|
||||
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag> {
|
||||
let references: Vec<String> = self
|
||||
.0
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me)
|
||||
.take(PREVIOUS_REFS)
|
||||
.map(|event| event.id.to_hex().chars().take(PREVIOUS_REF_LEN).collect())
|
||||
.collect();
|
||||
|
||||
if references.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Tag::custom("previous", references))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user