feat: add invite management, replies and subgroup support
This commit is contained in:
@@ -12,7 +12,7 @@ use crate::LOCAL_KEYS;
|
|||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
||||||
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
|
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
|
||||||
TimelineWindow,
|
TimelineWindow, tag_value,
|
||||||
};
|
};
|
||||||
|
|
||||||
const REPLAY_LIMIT: usize = 100;
|
const REPLAY_LIMIT: usize = 100;
|
||||||
@@ -34,6 +34,13 @@ pub struct Reaction {
|
|||||||
pub author: PublicKey,
|
pub author: PublicKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Invite {
|
||||||
|
pub id: EventId,
|
||||||
|
pub code: String,
|
||||||
|
pub created_at: Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Row {
|
pub enum Row {
|
||||||
Message(Box<Message>),
|
Message(Box<Message>),
|
||||||
@@ -86,6 +93,8 @@ pub struct Group {
|
|||||||
rows: Vec<Row>,
|
rows: Vec<Row>,
|
||||||
index: HashSet<EventId>,
|
index: HashSet<EventId>,
|
||||||
reactions: HashMap<EventId, Vec<Reaction>>,
|
reactions: HashMap<EventId, Vec<Reaction>>,
|
||||||
|
/// Active invite codes; revocations arrive as 9005 deletions.
|
||||||
|
invites: Vec<Invite>,
|
||||||
leave_requests: HashMap<PublicKey, Timestamp>,
|
leave_requests: HashMap<PublicKey, Timestamp>,
|
||||||
window: TimelineWindow,
|
window: TimelineWindow,
|
||||||
oldest: Option<Timestamp>,
|
oldest: Option<Timestamp>,
|
||||||
@@ -114,6 +123,7 @@ impl Group {
|
|||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
index: HashSet::new(),
|
index: HashSet::new(),
|
||||||
reactions: HashMap::new(),
|
reactions: HashMap::new(),
|
||||||
|
invites: Vec::new(),
|
||||||
leave_requests: HashMap::new(),
|
leave_requests: HashMap::new(),
|
||||||
window: TimelineWindow::new(),
|
window: TimelineWindow::new(),
|
||||||
oldest: None,
|
oldest: None,
|
||||||
@@ -193,6 +203,10 @@ impl Group {
|
|||||||
self.reactions.get(id).map(Vec::as_slice).unwrap_or(&[])
|
self.reactions.get(id).map(Vec::as_slice).unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn invites(&self) -> &[Invite] {
|
||||||
|
&self.invites
|
||||||
|
}
|
||||||
|
|
||||||
pub fn loading_more(&self) -> bool {
|
pub fn loading_more(&self) -> bool {
|
||||||
self.loading_more
|
self.loading_more
|
||||||
}
|
}
|
||||||
@@ -375,6 +389,7 @@ impl Group {
|
|||||||
let filters = [
|
let filters = [
|
||||||
key.id().timeline_filter(REPLAY_LIMIT),
|
key.id().timeline_filter(REPLAY_LIMIT),
|
||||||
key.id().membership_filter(me),
|
key.id().membership_filter(me),
|
||||||
|
key.id().moderation_filter(REPLAY_LIMIT),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(key.id().state_filters())
|
.chain(key.id().state_filters())
|
||||||
@@ -523,13 +538,22 @@ impl Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context<Self>) {
|
pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context<Self>) {
|
||||||
|
if self.retract(id) {
|
||||||
|
cx.emit(GroupEvent::Updated);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an event and everything attached to it.
|
||||||
|
fn retract(&mut self, id: EventId) -> bool {
|
||||||
self.window.remove(id);
|
self.window.remove(id);
|
||||||
|
|
||||||
if !self.index.remove(&id) {
|
if !self.index.remove(&id) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.rows.retain(|row| row.id() != id);
|
self.rows.retain(|row| row.id() != id);
|
||||||
|
self.invites.retain(|invite| invite.id != id);
|
||||||
self.reactions.remove(&id);
|
self.reactions.remove(&id);
|
||||||
|
|
||||||
for reactions in self.reactions.values_mut() {
|
for reactions in self.reactions.values_mut() {
|
||||||
@@ -538,8 +562,7 @@ impl Group {
|
|||||||
|
|
||||||
self.reactions.retain(|_, reactions| !reactions.is_empty());
|
self.reactions.retain(|_, reactions| !reactions.is_empty());
|
||||||
|
|
||||||
cx.emit(GroupEvent::Updated);
|
true
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn eose(&mut self, cx: &mut Context<Self>) {
|
pub(crate) fn eose(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -557,7 +580,7 @@ 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, me) {
|
||||||
Absorbed::Updated
|
Absorbed::Updated
|
||||||
} else {
|
} else {
|
||||||
Absorbed::Ignored
|
Absorbed::Ignored
|
||||||
@@ -583,6 +606,28 @@ impl Group {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if event.kind == Kind::GroupCreateInvite {
|
||||||
|
return if self.push_invite(event) {
|
||||||
|
Absorbed::Updated
|
||||||
|
} else {
|
||||||
|
Absorbed::Ignored
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.kind == Kind::GroupDeleteEvent {
|
||||||
|
let retracted = event
|
||||||
|
.tags
|
||||||
|
.event_ids()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|target| self.retract(target));
|
||||||
|
|
||||||
|
return if retracted {
|
||||||
|
Absorbed::Updated
|
||||||
|
} else {
|
||||||
|
Absorbed::Ignored
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if RENDER_KINDS.contains(&event.kind) {
|
if RENDER_KINDS.contains(&event.kind) {
|
||||||
return Absorbed::Shown {
|
return Absorbed::Shown {
|
||||||
fresh: self.push_row(event),
|
fresh: self.push_row(event),
|
||||||
@@ -625,18 +670,24 @@ impl Group {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn absorb_state(&mut self, event: &Event) -> bool {
|
fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool {
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
||||||
Ok(metadata) => self.metadata = Some(metadata),
|
Ok(metadata) => self.metadata = Some(metadata),
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
},
|
},
|
||||||
Kind::GroupAdmins => match GroupAdmins::parse(event) {
|
Kind::GroupAdmins => match GroupAdmins::parse(event) {
|
||||||
Ok(admins) => self.admins = admins,
|
Ok(admins) => {
|
||||||
|
self.admins = admins;
|
||||||
|
self.adopt_roster_membership(me);
|
||||||
|
}
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
},
|
},
|
||||||
Kind::GroupMembers => match GroupMembers::parse(event) {
|
Kind::GroupMembers => match GroupMembers::parse(event) {
|
||||||
Ok(members) => self.members = members,
|
Ok(members) => {
|
||||||
|
self.members = members;
|
||||||
|
self.adopt_roster_membership(me);
|
||||||
|
}
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
},
|
},
|
||||||
Kind::GroupRoles => match GroupRoles::parse(event) {
|
Kind::GroupRoles => match GroupRoles::parse(event) {
|
||||||
@@ -653,6 +704,21 @@ 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 adopt_roster_membership(&mut self, me: PublicKey) -> bool {
|
||||||
|
if self.membership != Membership::Unknown {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let listed = self.admins.contains(&me) || self.members.iter().any(|member| *member == me);
|
||||||
|
|
||||||
|
if !listed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.membership = Membership::Member;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn push_row(&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;
|
||||||
@@ -686,6 +752,24 @@ impl Group {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn push_invite(&mut self, event: &Event) -> bool {
|
||||||
|
let Some(code) = tag_value(&event.tags, "code") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.index.insert(event.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.invites.push(Invite {
|
||||||
|
id: event.id,
|
||||||
|
code: code.to_owned(),
|
||||||
|
created_at: event.created_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn push_activity(&mut self, event: &Event) -> bool {
|
fn push_activity(&mut self, event: &Event) -> bool {
|
||||||
let activity = match Activity::parse(event) {
|
let activity = match Activity::parse(event) {
|
||||||
Ok(activity) => activity,
|
Ok(activity) => activity,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ fn has_tag(tags: &Tags, name: &str) -> bool {
|
|||||||
tags.iter().any(|tag| tag.kind() == name)
|
tags.iter().any(|tag| tag.kind() == name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
pub(crate) fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||||
tags.iter()
|
tags.iter()
|
||||||
.find(|tag| tag.kind() == name)
|
.find(|tag| tag.kind() == name)
|
||||||
.and_then(|tag| tag.content())
|
.and_then(|tag| tag.content())
|
||||||
@@ -98,6 +98,13 @@ impl GroupId {
|
|||||||
.kinds(MEMBERSHIP_KINDS)
|
.kinds(MEMBERSHIP_KINDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn moderation_filter(&self, limit: usize) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
|
||||||
|
.kinds([Kind::GroupCreateInvite, Kind::GroupDeleteEvent])
|
||||||
|
.limit(limit)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn message(
|
pub fn message(
|
||||||
&self,
|
&self,
|
||||||
content: &str,
|
content: &str,
|
||||||
|
|||||||
+157
-59
@@ -1,78 +1,185 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use common::TimestampExt;
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, ClipboardItem, ParentElement, SharedString, Styled, WeakEntity, Window, div,
|
App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled,
|
||||||
px,
|
WeakEntity, Window, div, px,
|
||||||
};
|
};
|
||||||
use nip29::Group;
|
use nip29::{Group, Invite};
|
||||||
use nostr_sdk::prelude::SecretKey;
|
use nostr_sdk::prelude::SecretKey;
|
||||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||||
use ui::dialog::DialogButtonProps;
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::input::InputState;
|
use ui::input::InputState;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::{WindowExtension, v_flex};
|
use ui::{Sizable, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
use crate::util::field;
|
use crate::util::{field, report};
|
||||||
|
|
||||||
const CODE_LEN: usize = 12;
|
const CODE_LEN: usize = 12;
|
||||||
|
|
||||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||||
let code = SecretKey::generate().to_secret_hex();
|
|
||||||
let code: String = code.chars().take(CODE_LEN).collect();
|
|
||||||
|
|
||||||
let input = cx.new(|cx| {
|
let input = cx.new(|cx| {
|
||||||
InputState::new(window, cx)
|
InputState::new(window, cx)
|
||||||
.placeholder("Invite code")
|
.placeholder("Invite code")
|
||||||
.default_value(code)
|
.default_value(random_code())
|
||||||
});
|
});
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, cx| {
|
window.open_dialog(cx, move |this, _window, cx| {
|
||||||
let group = group.clone();
|
this.max_h(px(600.))
|
||||||
let input = input.clone();
|
.show_close(true)
|
||||||
|
.title("Invites")
|
||||||
|
.child(body(group.clone(), input.clone(), cx))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.width(px(420.))
|
fn random_code() -> String {
|
||||||
.confirm()
|
SecretKey::generate()
|
||||||
.title("Invite link")
|
.to_secret_hex()
|
||||||
.button_props(
|
.chars()
|
||||||
DialogButtonProps::default()
|
.take(CODE_LEN)
|
||||||
.cancel_text("Cancel")
|
.collect()
|
||||||
.ok_text("Copy link"),
|
}
|
||||||
|
|
||||||
|
fn body(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
|
||||||
|
let invites = group
|
||||||
|
.upgrade()
|
||||||
|
.map(|entity| entity.read(cx).invites().to_vec())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.text_style(TextStyle::Callout)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Anyone with a code can ask to join. Revoking a code stops new joins, it doesn't remove anyone."),
|
||||||
|
)
|
||||||
|
.when(invites.is_empty(), |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_faint)
|
||||||
|
.child("No active invites"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.children(invites.iter().map(|invite| invite_row(group.clone(), invite, cx)))
|
||||||
|
.child(create_row(group, input, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invite_row(group: WeakEntity<Group>, invite: &Invite, cx: &App) -> impl IntoElement {
|
||||||
|
let created = div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.text_color(cx.theme().text_faint)
|
||||||
|
.child(invite.created_at.to_ago());
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.justify_between()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_2()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
|
.truncate()
|
||||||
|
.child(SharedString::from(invite.code.clone())),
|
||||||
|
)
|
||||||
|
.child(created),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex().gap_3().child(
|
h_flex()
|
||||||
div()
|
.flex_shrink_0()
|
||||||
.text_style(TextStyle::Callout)
|
.gap_1()
|
||||||
.text_color(cx.theme().text_muted)
|
.child(copy_button(&group, &invite.code))
|
||||||
.child("Share the code with whoever should be able to join. The relay must accept it."),
|
.child(revoke_button(&group, invite.id, &invite.code)),
|
||||||
)
|
)
|
||||||
.child(field("Code", &input, cx)),
|
}
|
||||||
)
|
|
||||||
.on_ok(move |_event, window, cx| {
|
|
||||||
let code = input.read(cx).value().trim().to_owned();
|
|
||||||
|
|
||||||
if code.is_empty() {
|
fn copy_button(group: &WeakEntity<Group>, code: &str) -> Button {
|
||||||
return false;
|
let group = group.clone();
|
||||||
}
|
let code = code.to_owned();
|
||||||
|
|
||||||
let link = group
|
Button::new(SharedString::from(format!("invite-copy-{code}")))
|
||||||
.read_with(cx, |group, _cx| {
|
.label("Copy")
|
||||||
group.metadata().and_then(|metadata| {
|
.ghost()
|
||||||
metadata
|
.small()
|
||||||
.naddr(group.key().relay(), Some(&code))
|
.on_click(move |_event, window, cx| {
|
||||||
|
let Some(link) = group
|
||||||
|
.read_with(cx, |group, _cx| invite_link(group, &code))
|
||||||
.ok()
|
.ok()
|
||||||
.map(SharedString::from)
|
.flatten()
|
||||||
})
|
else {
|
||||||
})
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
let Some(link) = link else {
|
|
||||||
window.push_notification(
|
window.push_notification(
|
||||||
Notification::error(
|
Notification::error(
|
||||||
"The relay doesn't publish group details, so no invite link can be made",
|
"The relay doesn't publish group details, so no invite link can be made",
|
||||||
),
|
),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
return false;
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
cx.write_to_clipboard(ClipboardItem::new_string(link));
|
||||||
|
window.push_notification(Notification::success("Invite link copied"), cx);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn revoke_button(group: &WeakEntity<Group>, id: nostr_sdk::prelude::EventId, code: &str) -> Button {
|
||||||
|
let group = group.clone();
|
||||||
|
|
||||||
|
Button::new(SharedString::from(format!("invite-revoke-{code}")))
|
||||||
|
.label("Revoke")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
match group.update(cx, |group, cx| group.delete_event(id, cx)) {
|
||||||
|
Ok(task) => report(window, cx, task),
|
||||||
|
Err(error) => log::warn!("nip29: revoking an invite failed: {error}"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_row(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.items_end()
|
||||||
|
.gap_2()
|
||||||
|
.child(field("New code", &input, cx).flex_1())
|
||||||
|
.child(
|
||||||
|
Button::new("invite-create")
|
||||||
|
.label("Create & copy link")
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
create(group.clone(), input.clone(), window, cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invite_link(group: &Group, code: &str) -> Option<String> {
|
||||||
|
group
|
||||||
|
.metadata()
|
||||||
|
.and_then(|metadata| metadata.naddr(group.key().relay(), Some(code)).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(group: WeakEntity<Group>, input: Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||||
|
let code = input.read(cx).value().trim().to_owned();
|
||||||
|
|
||||||
|
if code.is_empty() {
|
||||||
|
window.push_notification(Notification::error("The invite code is empty"), cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(link) = group
|
||||||
|
.read_with(cx, |group, _cx| invite_link(group, &code))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
else {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error(
|
||||||
|
"The relay doesn't publish group details, so no invite link can be made",
|
||||||
|
),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
match group.update(cx, |group, cx| group.create_invite(&code, cx)) {
|
match group.update(cx, |group, cx| group.create_invite(&code, cx)) {
|
||||||
@@ -81,22 +188,17 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
.spawn(cx, async move |cx| -> Result<()> {
|
.spawn(cx, async move |cx| -> Result<()> {
|
||||||
if let Err(error) = task.await {
|
if let Err(error) = task.await {
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
window.push_notification(
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
Notification::error(error.to_string()),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
})?;
|
})?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
input.update(cx, |input, cx| {
|
||||||
link.to_string(),
|
input.set_value("", window, cx);
|
||||||
));
|
});
|
||||||
window.push_notification(
|
cx.write_to_clipboard(ClipboardItem::new_string(link));
|
||||||
Notification::success("Invite link copied"),
|
window.push_notification(Notification::success("Invite link copied"), cx);
|
||||||
cx,
|
|
||||||
);
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -105,8 +207,4 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
}
|
}
|
||||||
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
|
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ use ui::markdown::RenderedText;
|
|||||||
use ui::message::WelcomeMessage;
|
use ui::message::WelcomeMessage;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
use util::{attachment_name, display_name, opens_run, pin_label, report};
|
use util::{attachment_name, display_name, opens_run, pin_label, report};
|
||||||
|
|
||||||
mod details;
|
mod details;
|
||||||
@@ -310,6 +310,8 @@ pub struct GroupPanel {
|
|||||||
group: WeakEntity<Group>,
|
group: WeakEntity<Group>,
|
||||||
list_state: ListState,
|
list_state: ListState,
|
||||||
head: Option<EventId>,
|
head: Option<EventId>,
|
||||||
|
/// The message the composer is replying to, if any.
|
||||||
|
reply_to: Option<EventId>,
|
||||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||||
input: Entity<TextareaState>,
|
input: Entity<TextareaState>,
|
||||||
/// Uploaded, non-encrypted attachments waiting to be sent.
|
/// Uploaded, non-encrypted attachments waiting to be sent.
|
||||||
@@ -380,6 +382,7 @@ impl GroupPanel {
|
|||||||
group: group.downgrade(),
|
group: group.downgrade(),
|
||||||
list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)),
|
list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)),
|
||||||
head,
|
head,
|
||||||
|
reply_to: None,
|
||||||
rendered_texts_by_id: BTreeMap::new(),
|
rendered_texts_by_id: BTreeMap::new(),
|
||||||
input,
|
input,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
@@ -452,6 +455,20 @@ impl GroupPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn start_reply(&mut self, id: EventId, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.reply_to = Some(id);
|
||||||
|
self.input.update(cx, |input, cx| {
|
||||||
|
input.focus(window, cx);
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_reply(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.reply_to.take().is_some() {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let mut content = self.input.read(cx).value().trim().to_owned();
|
let mut content = self.input.read(cx).value().trim().to_owned();
|
||||||
|
|
||||||
@@ -474,9 +491,11 @@ impl GroupPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let replies: Vec<EventId> = self.reply_to.take().into_iter().collect();
|
||||||
|
|
||||||
let Ok(send) = self
|
let Ok(send) = self
|
||||||
.group
|
.group
|
||||||
.update(cx, |group, cx| group.send_message(&content, vec![], cx))
|
.update(cx, |group, cx| group.send_message(&content, replies, cx))
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -911,6 +930,9 @@ impl GroupPanel {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.p_2()
|
.p_2()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
|
.when_some(self.reply_to, |this, target| {
|
||||||
|
this.child(self.render_reply_target(target, cx))
|
||||||
|
})
|
||||||
.children(self.render_attachments(cx))
|
.children(self.render_attachments(cx))
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -943,6 +965,73 @@ impl GroupPanel {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_reply_target(&self, target: EventId, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let context = self.group.upgrade().and_then(|entity| {
|
||||||
|
entity.read_with(cx, |group, cx| {
|
||||||
|
let message = group.message(target)?;
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let author = persons.read(cx).get(&message.author, cx);
|
||||||
|
let line = message.content.lines().next().unwrap_or_default().trim();
|
||||||
|
|
||||||
|
Some((
|
||||||
|
author.name(),
|
||||||
|
SharedString::from(line.chars().take(80).collect::<String>()),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let (author, snippet) = context.unwrap_or_else(|| {
|
||||||
|
(
|
||||||
|
SharedString::from("Message"),
|
||||||
|
SharedString::from(target.to_hex().chars().take(8).collect::<String>()),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.min_w_0()
|
||||||
|
.items_center()
|
||||||
|
.gap_1()
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.rounded(px(control_radius()))
|
||||||
|
.bg(cx.theme().surface_raised)
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Replying to"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.max_w(px(140.))
|
||||||
|
.truncate()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
.child(author),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
|
.flex_1()
|
||||||
|
.truncate()
|
||||||
|
.text_color(cx.theme().text_faint)
|
||||||
|
.child(snippet),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("cancel-reply")
|
||||||
|
.icon(IconName::Close)
|
||||||
|
.tooltip("Cancel reply")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||||
|
this.cancel_reply(cx);
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||||
self.attachments
|
self.attachments
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ pub(crate) fn render(
|
|||||||
)
|
)
|
||||||
.author(author.name())
|
.author(author.name())
|
||||||
.timestamp(message.created_at.to_human_time())
|
.timestamp(message.created_at.to_human_time())
|
||||||
|
.when_some(message.replies_to.first().copied(), |this, target| {
|
||||||
|
this.child(reply_context(target, &actions.group, cx))
|
||||||
|
})
|
||||||
.child(content)
|
.child(content)
|
||||||
.when(!message.media.is_empty(), |this| {
|
.when(!message.media.is_empty(), |this| {
|
||||||
this.child(media(&message.media, cx))
|
this.child(media(&message.media, cx))
|
||||||
@@ -71,7 +74,17 @@ pub(crate) fn render(
|
|||||||
format!("message-menu-{}", id.to_hex()),
|
format!("message-menu-{}", id.to_hex()),
|
||||||
row,
|
row,
|
||||||
move |menu, window, cx| {
|
move |menu, window, cx| {
|
||||||
let menu = menu.submenu("React", window, cx, {
|
let menu = menu
|
||||||
|
.item({
|
||||||
|
let panel = panel.clone();
|
||||||
|
|
||||||
|
PopupMenuItem::new("Reply").on_click(move |_event, _window, cx| {
|
||||||
|
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||||
|
panel.start_reply(id, window, cx);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.submenu("React", window, cx, {
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
|
|
||||||
@@ -89,11 +102,9 @@ pub(crate) fn render(
|
|||||||
|
|
||||||
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click(
|
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click(
|
||||||
move |_event, _window, cx| {
|
move |_event, _window, cx| {
|
||||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||||
panel.toggle_pin(group.clone(), id, window, cx);
|
panel.toggle_pin(group.clone(), id, window, cx);
|
||||||
}) {
|
});
|
||||||
log::warn!("nip29: pinning a message failed: {error}");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -102,11 +113,9 @@ pub(crate) fn render(
|
|||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
|
|
||||||
PopupMenuItem::new("Delete message").on_click(move |_event, _window, cx| {
|
PopupMenuItem::new("Delete message").on_click(move |_event, _window, cx| {
|
||||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
let _ = panel.update_in(cx, |panel, window, cx| {
|
||||||
panel.delete_message(group.clone(), id, window, cx);
|
panel.delete_message(group.clone(), id, window, cx);
|
||||||
}) {
|
});
|
||||||
log::warn!("nip29: deleting a message failed: {error}");
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -249,6 +258,58 @@ fn reaction_bar(reactions: &[Reaction], cx: &App) -> AnyElement {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reply_context(target: EventId, group: &WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||||
|
let context = group.upgrade().and_then(|entity| {
|
||||||
|
entity.read_with(cx, |group, cx| {
|
||||||
|
let message = group.message(target)?;
|
||||||
|
let persons = PersonRegistry::global(cx);
|
||||||
|
let author = persons.read(cx).get(&message.author, cx);
|
||||||
|
let line = message.content.lines().next().unwrap_or_default().trim();
|
||||||
|
|
||||||
|
Some((
|
||||||
|
author.name(),
|
||||||
|
SharedString::from(line.chars().take(80).collect::<String>()),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let (author, snippet) = context.unwrap_or_else(|| {
|
||||||
|
(
|
||||||
|
SharedString::from("Message"),
|
||||||
|
SharedString::from("original not loaded"),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.min_w_0()
|
||||||
|
.gap_1()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
Icon::new(IconName::Reply)
|
||||||
|
.small()
|
||||||
|
.text_color(cx.theme().text_faint),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.max_w(px(120.))
|
||||||
|
.truncate()
|
||||||
|
.font_semibold()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(author),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
|
.truncate()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.text_color(cx.theme().text_faint)
|
||||||
|
.child(snippet),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window, px};
|
use std::sync::Arc;
|
||||||
use nip29::{Group, GroupMetadata};
|
|
||||||
|
use gpui::http_client::{AsyncBody, HttpClient};
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, AppContext, Entity, IntoElement, ParentElement, SharedString, Styled,
|
||||||
|
WeakEntity, Window, div, px,
|
||||||
|
};
|
||||||
|
use nip29::{Group, GroupId, GroupMetadata};
|
||||||
|
use nostr_sdk::prelude::RelayUrl;
|
||||||
|
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||||
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dialog::DialogButtonProps;
|
use ui::dialog::DialogButtonProps;
|
||||||
use ui::input::InputState;
|
use ui::input::InputState;
|
||||||
|
use ui::notification::Notification;
|
||||||
use ui::switch::Switch;
|
use ui::switch::Switch;
|
||||||
use ui::{WindowExtension, v_flex};
|
use ui::{Sizable, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
use crate::util::field;
|
use crate::util::field;
|
||||||
|
|
||||||
@@ -53,10 +64,40 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
.default_value(base.picture.clone().unwrap_or_default())
|
.default_value(base.picture.clone().unwrap_or_default())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let parent = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("Parent group id, empty for a root group")
|
||||||
|
.default_value(
|
||||||
|
base.parent
|
||||||
|
.as_ref()
|
||||||
|
.map(|id| id.as_str().to_owned())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let children = cx.new(|_cx| base.children.clone());
|
||||||
let flags = cx.new(|_cx| Flags::of(&base));
|
let flags = cx.new(|_cx| Flags::of(&base));
|
||||||
|
let livekit = cx.new(|_cx| None);
|
||||||
|
|
||||||
|
if let Ok(relay) = group.read_with(cx, |group, _cx| group.key().relay().clone()) {
|
||||||
|
let http = cx.http_client();
|
||||||
|
let probe = cx.background_spawn(async move { probe_livekit(http, relay).await });
|
||||||
|
let livekit = livekit.clone();
|
||||||
|
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
let supported = probe.await;
|
||||||
|
|
||||||
|
livekit.update(cx, |livekit, cx| {
|
||||||
|
*livekit = Some(supported);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, cx| {
|
window.open_dialog(cx, move |this, _window, cx| {
|
||||||
let current = *flags.read(cx);
|
let current = *flags.read(cx);
|
||||||
|
let supported = *livekit.read(cx) != Some(false);
|
||||||
|
|
||||||
this.confirm()
|
this.confirm()
|
||||||
.title("Edit Group")
|
.title("Edit Group")
|
||||||
@@ -71,6 +112,8 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
.child(field("Name", &name, cx))
|
.child(field("Name", &name, cx))
|
||||||
.child(field("About", &about, cx))
|
.child(field("About", &about, cx))
|
||||||
.child(field("Picture", &picture, cx))
|
.child(field("Picture", &picture, cx))
|
||||||
|
.child(field("Parent", &parent, cx))
|
||||||
|
.child(children_section(&children, cx))
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap(px(theme::SPACE))
|
.gap(px(theme::SPACE))
|
||||||
@@ -105,9 +148,26 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
.child(
|
.child(
|
||||||
Switch::new("livekit")
|
Switch::new("livekit")
|
||||||
.label("LiveKit")
|
.label("LiveKit")
|
||||||
.description("Announce audio and video support.")
|
.description(if supported {
|
||||||
|
"Announce audio and video support."
|
||||||
|
} else {
|
||||||
|
"The relay doesn't announce LiveKit support."
|
||||||
|
})
|
||||||
.checked(current.livekit)
|
.checked(current.livekit)
|
||||||
.on_click(toggle(&flags, |flags| &mut flags.livekit)),
|
.on_click({
|
||||||
|
let flags = flags.clone();
|
||||||
|
let livekit = livekit.clone();
|
||||||
|
|
||||||
|
move |next, _window, cx| {
|
||||||
|
if *next && *livekit.read(cx) == Some(false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flags.update(cx, |flags, cx| {
|
||||||
|
flags.livekit = *next;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -116,6 +176,8 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
let name = name.clone();
|
let name = name.clone();
|
||||||
let about = about.clone();
|
let about = about.clone();
|
||||||
let picture = picture.clone();
|
let picture = picture.clone();
|
||||||
|
let parent = parent.clone();
|
||||||
|
let children = children.clone();
|
||||||
let flags = flags.clone();
|
let flags = flags.clone();
|
||||||
|
|
||||||
move |_event, window, cx| {
|
move |_event, window, cx| {
|
||||||
@@ -129,6 +191,40 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
next.about = value(&about, cx);
|
next.about = value(&about, cx);
|
||||||
next.picture = value(&picture, cx);
|
next.picture = value(&picture, cx);
|
||||||
|
|
||||||
|
match value(&parent, cx).map(GroupId::new) {
|
||||||
|
Some(Ok(id)) => next.parent = Some(id),
|
||||||
|
Some(Err(error)) => {
|
||||||
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
None => next.parent = None,
|
||||||
|
}
|
||||||
|
|
||||||
|
next.children = children.read(cx).clone();
|
||||||
|
|
||||||
|
// The relay must reject these anyway; catch them early.
|
||||||
|
if let Some(id) = &next.parent {
|
||||||
|
let key = group
|
||||||
|
.read_with(cx, |group, _cx| group.key().id().as_str().to_owned())
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
if key.as_deref() == Some(id.as_str()) {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error("A group can't be its own parent"),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if next.children.iter().any(|child| child == id) {
|
||||||
|
window.push_notification(
|
||||||
|
Notification::error("The parent is one of this group's children"),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let current = flags.read(cx);
|
let current = flags.read(cx);
|
||||||
next.private = current.private;
|
next.private = current.private;
|
||||||
next.restricted = current.restricted;
|
next.restricted = current.restricted;
|
||||||
@@ -147,6 +243,88 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NIP-29: relays announce LiveKit support with a 204 at this well-known path.
|
||||||
|
async fn probe_livekit(http: Arc<dyn HttpClient>, relay: RelayUrl) -> bool {
|
||||||
|
let base = relay
|
||||||
|
.as_str()
|
||||||
|
.replacen("wss://", "https://", 1)
|
||||||
|
.replacen("ws://", "http://", 1);
|
||||||
|
let url = format!("{}/.well-known/nip29/livekit", base.trim_end_matches('/'));
|
||||||
|
|
||||||
|
match http.get(&url, AsyncBody::default(), true).await {
|
||||||
|
Ok(response) => response.status() == 204,
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
|
||||||
|
let list = children.read(cx);
|
||||||
|
|
||||||
|
let mut section = v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_style(TextStyle::Callout)
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Children");
|
||||||
|
|
||||||
|
if list.is_empty() {
|
||||||
|
return section
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(cx.theme().text_faint)
|
||||||
|
.child("No subgroups"),
|
||||||
|
)
|
||||||
|
.into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ix, child) in list.iter().enumerate() {
|
||||||
|
let order = h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.gap_1()
|
||||||
|
.when(ix > 0, |this| {
|
||||||
|
this.child(move_button(children.clone(), ix, ix - 1, "up"))
|
||||||
|
})
|
||||||
|
.when(ix + 1 < list.len(), |this| {
|
||||||
|
this.child(move_button(children.clone(), ix, ix + 1, "down"))
|
||||||
|
});
|
||||||
|
|
||||||
|
section = section.child(
|
||||||
|
h_flex()
|
||||||
|
.justify_between()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
|
.truncate()
|
||||||
|
.text_color(cx.theme().text)
|
||||||
|
.child(SharedString::from(child.as_str().to_owned())),
|
||||||
|
)
|
||||||
|
.child(order),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_button(
|
||||||
|
children: Entity<Vec<GroupId>>,
|
||||||
|
ix: usize,
|
||||||
|
target: usize,
|
||||||
|
direction: &'static str,
|
||||||
|
) -> Button {
|
||||||
|
Button::new(SharedString::from(format!("child-{direction}-{ix}")))
|
||||||
|
.label(if direction == "up" { "Up" } else { "Down" })
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(move |_event, _window, cx| {
|
||||||
|
children.update(cx, |children, cx| {
|
||||||
|
if target < children.len() {
|
||||||
|
children.swap(ix, target);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn toggle(
|
fn toggle(
|
||||||
flags: &Entity<Flags>,
|
flags: &Entity<Flags>,
|
||||||
select: fn(&mut Flags) -> &mut bool,
|
select: fn(&mut Flags) -> &mut bool,
|
||||||
|
|||||||
+37
-36
@@ -16,12 +16,12 @@ trade-offs; each has real gaps the other closed.
|
|||||||
timeline refs, membership-from-9000/9001 (the spec's "implementation
|
timeline refs, membership-from-9000/9001 (the spec's "implementation
|
||||||
quirks" section), and fork/migration detection via admins' `kind:10009`.
|
quirks" section), and fork/migration detection via admins' `kind:10009`.
|
||||||
nostord implements none of those three.
|
nostord implements none of those three.
|
||||||
- nostord is ahead on product surface: invite code listing/revocation, full
|
- nostord is ahead on product surface: full LiveKit AV (token endpoint +
|
||||||
LiveKit AV (token endpoint + `kind:39004`), threads UI, subgroup hierarchy
|
`kind:39004`) and a threads pane. Invite code management, subgroup
|
||||||
management, member rosters as a membership source.
|
reparent/reorder and reply chips landed here since the comparison.
|
||||||
- Spec bugs in ours worth fixing: no `39002`/`39001` fallback for
|
- Spec bugs in ours worth fixing: none open. The `duplicate:` join
|
||||||
membership. (Since this comparison: `duplicate:` join rejection, 9008
|
rejection, 9008 delete-group, reaction aggregation and the `39002`/`39001`
|
||||||
delete-group and reaction aggregation were implemented.)
|
membership fallback were implemented.
|
||||||
|
|
||||||
## 1. Protocol compliance vs NIP-29
|
## 1. Protocol compliance vs NIP-29
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ trade-offs; each has real gaps the other closed.
|
|||||||
| **9008 delete-group** | yes, plus confirm dialog | yes |
|
| **9008 delete-group** | yes, plus confirm dialog | yes |
|
||||||
| **9010 update-pin-list + 39005** | send + parse + UI | stub ("disabled until the pinning backend exists") |
|
| **9010 update-pin-list + 39005** | send + parse + UI | stub ("disabled until the pinning backend exists") |
|
||||||
| `previous` tag (last 50 seen, excl. self, ≥3 × 8 chars) | `TimelineWindow` (`crates/nip29/src/protocol.rs`) | not built, not validated |
|
| `previous` tag (last 50 seen, excl. self, ≥3 × 8 chars) | `TimelineWindow` (`crates/nip29/src/protocol.rs`) | not built, not validated |
|
||||||
| Membership = latest of own 9000/9001 | `Membership::from_events` | derived from 39001/39002 rosters + local markers instead |
|
| Membership = latest of own 9000/9001 | `Membership::from_events`, rosters resolve `Unknown` as fallback | derived from 39001/39002 rosters + local markers instead |
|
||||||
| Join request `code` tag / `naddr1…?invite=` | `GroupReference::parse` | yes, plus legacy formats (`wss://host'id`, `?relay=&group=`) |
|
| Join request `code` tag / `naddr1…?invite=` | `GroupReference::parse` | yes, plus legacy formats (`wss://host'id`, `?relay=&group=`) |
|
||||||
| `duplicate:` 9021 rejection = already member | treated as success (`mark_member`) | matched by wording, treated as success |
|
| `duplicate:` 9021 rejection = already member | treated as success (`mark_member`) | matched by wording, treated as success |
|
||||||
| 39000 metadata: all flags + `supported_kinds` + `parent`/`child` | yes, plus banner and content-JSON fallback | yes, minus banner; tags only |
|
| 39000 metadata: all flags + `supported_kinds` + `parent`/`child` | yes, plus banner and content-JSON fallback | yes, minus banner; tags only |
|
||||||
@@ -85,30 +85,30 @@ waits for AUTH explicitly before REQs; both work on auth-gating relays.
|
|||||||
|
|
||||||
### P2 — client-parity features
|
### P2 — client-parity features
|
||||||
|
|
||||||
4. **Invite management.** We create a 9009 code and copy the link, but never
|
4. ~~**Invite management.**~~ **done.** The group subscription now includes a
|
||||||
subscribe to 9009, so there is no list of active codes and no revoke
|
moderation filter (`9009` + `9005`, `GroupId::moderation_filter`),
|
||||||
(nostord: active codes = 9009 minus 9005 revocations, per-code copy and
|
`Group::invites()` exposes active codes (revocations arrive as 9005
|
||||||
revoke). Natural fit: add 9009 to the group's state subscription and an
|
deletions and via the publisher's own `Sent` path), and the invite dialog
|
||||||
"Invites" section in the details dialog.
|
lists codes with per-code copy-link and revoke.
|
||||||
5. **Membership fallback to rosters.** We only derive membership from
|
5. ~~**Membership fallback to rosters.**~~ **done.** `Group::
|
||||||
9000/9001. If a relay prunes moderation history (or the user was added
|
adopt_roster_membership`: while the latest own 9000/9001 leaves membership
|
||||||
without us seeing the 9000), membership shows `Unknown` while 39002 lists
|
`Unknown`, being listed in 39001/39002 counts as `Member`. `Pending`,
|
||||||
us. Consider nostord's "external add" adoption: own pubkey in 39001/39002
|
`Refused` and `Removed` are never overridden.
|
||||||
→ treat as member (or invite card), without weakening the 9000/9001
|
6. **LiveKit — partially done.** The edit dialog probes
|
||||||
override.
|
`/.well-known/nip29/livekit` (expecting 204) and gates the toggle with a
|
||||||
6. **LiveKit.** The flag is parsed and editable, but joining an AV group does
|
description when the relay doesn't announce support. Remaining: the token
|
||||||
nothing. At minimum: the `/.well-known/nip29/livekit` probe (204) to gate
|
flow (`/.well-known/nip29/livekit/<id>` with NIP-98 kind 27235) and a
|
||||||
the toggle, and the token flow
|
`kind:39004` participant subscription — both only useful once an AV
|
||||||
(`/.well-known/nip29/livekit/<id>` with NIP-98 kind 27235 plus a 39004
|
client is on the roadmap.
|
||||||
participant subscription) if AV is on the roadmap.
|
7. ~~**Subgroup management UI.**~~ **done.** The edit dialog has a Parent
|
||||||
7. **Subgroup management UI.** `parent` is set at creation (`CreateChannel`),
|
field (empty = root; reparenting) and a children list with Up/Down
|
||||||
and `edit_metadata` already carries `parent`/`child` tags, but the edit
|
reordering (the spec's 9002 child-list replace semantics). Self-parent and
|
||||||
dialog cannot reparent or reorder children (the spec's 9002 child-list
|
parent-is-child are rejected client-side; NIP-11 `nip29.subgroups` is not
|
||||||
replace semantics). nostord has a full Hierarchy tab (move under / make
|
read yet.
|
||||||
root / reorder). Nothing reads NIP-11 `nip29.subgroups` to gate the UI.
|
8. **Reply UI — partially done.** The composer can target a reply
|
||||||
8. **No reply/thread UI.** `send_message` accepts `replies` but
|
(context menu → Reply, chip with cancel), sends kind 11 with `e` tags, and
|
||||||
`GroupPanel::send` always passes empty; kind 11/1111 render inline only.
|
messages render a reply-context line (author + first line) resolved from
|
||||||
nostord has reply chips, a threads pane, and NIP-22 tag nesting.
|
loaded rows. Remaining: a dedicated threads pane (nostord has one).
|
||||||
|
|
||||||
### P3 — polish
|
### P3 — polish
|
||||||
|
|
||||||
@@ -129,8 +129,9 @@ waits for AUTH explicitly before REQs; both work on auth-gating relays.
|
|||||||
1. ~~Fix `duplicate:`-prefix handling on 9021~~ done.
|
1. ~~Fix `duplicate:`-prefix handling on 9021~~ done.
|
||||||
2. ~~Exclude/aggregate kind-7 reactions in group rows~~ done.
|
2. ~~Exclude/aggregate kind-7 reactions in group rows~~ done.
|
||||||
3. ~~Add 9008 delete-group + confirm dialog~~ done.
|
3. ~~Add 9008 delete-group + confirm dialog~~ done.
|
||||||
4. Subscribe 9009 → invite list + revoke via 9005 (medium).
|
4. ~~Subscribe 9009 → invite list + revoke via 9005~~ done.
|
||||||
5. 39002/39001 membership fallback (medium).
|
5. ~~39002/39001 membership fallback~~ done.
|
||||||
6. Subgroup reparent/reorder in the edit dialog (medium, uses the existing
|
6. ~~Subgroup reparent/reorder in the edit dialog~~ done.
|
||||||
9002 path).
|
7. LiveKit probe (done, gates the toggle) + token flow (larger; only if AV is
|
||||||
7. LiveKit probe + token flow (larger; only if AV is in scope).
|
in scope).
|
||||||
|
8. Reply sending + chips (done); a dedicated threads pane remains.
|
||||||
|
|||||||
Reference in New Issue
Block a user