feat: add invite management, replies and subgroup support

This commit is contained in:
2026-10-11 08:51:03 +07:00
parent 4c279a0686
commit 440d6dfea3
7 changed files with 675 additions and 157 deletions
+92 -8
View File
@@ -12,7 +12,7 @@ use crate::LOCAL_KEYS;
use crate::protocol::{
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
TimelineWindow,
TimelineWindow, tag_value,
};
const REPLAY_LIMIT: usize = 100;
@@ -34,6 +34,13 @@ pub struct Reaction {
pub author: PublicKey,
}
#[derive(Debug, Clone)]
pub struct Invite {
pub id: EventId,
pub code: String,
pub created_at: Timestamp,
}
#[derive(Debug, Clone)]
pub enum Row {
Message(Box<Message>),
@@ -86,6 +93,8 @@ pub struct Group {
rows: Vec<Row>,
index: HashSet<EventId>,
reactions: HashMap<EventId, Vec<Reaction>>,
/// Active invite codes; revocations arrive as 9005 deletions.
invites: Vec<Invite>,
leave_requests: HashMap<PublicKey, Timestamp>,
window: TimelineWindow,
oldest: Option<Timestamp>,
@@ -114,6 +123,7 @@ impl Group {
rows: Vec::new(),
index: HashSet::new(),
reactions: HashMap::new(),
invites: Vec::new(),
leave_requests: HashMap::new(),
window: TimelineWindow::new(),
oldest: None,
@@ -193,6 +203,10 @@ impl Group {
self.reactions.get(id).map(Vec::as_slice).unwrap_or(&[])
}
pub fn invites(&self) -> &[Invite] {
&self.invites
}
pub fn loading_more(&self) -> bool {
self.loading_more
}
@@ -375,6 +389,7 @@ impl Group {
let filters = [
key.id().timeline_filter(REPLAY_LIMIT),
key.id().membership_filter(me),
key.id().moderation_filter(REPLAY_LIMIT),
]
.into_iter()
.chain(key.id().state_filters())
@@ -523,13 +538,22 @@ impl Group {
}
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);
if !self.index.remove(&id) {
return;
return false;
}
self.rows.retain(|row| row.id() != id);
self.invites.retain(|invite| invite.id != id);
self.reactions.remove(&id);
for reactions in self.reactions.values_mut() {
@@ -538,8 +562,7 @@ impl Group {
self.reactions.retain(|_, reactions| !reactions.is_empty());
cx.emit(GroupEvent::Updated);
cx.notify();
true
}
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 {
if STATE_KINDS.contains(&event.kind) {
return if self.absorb_state(event) {
return if self.absorb_state(event, me) {
Absorbed::Updated
} else {
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) {
return Absorbed::Shown {
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 {
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,
Ok(admins) => {
self.admins = admins;
self.adopt_roster_membership(me);
}
Err(_) => return false,
},
Kind::GroupMembers => match GroupMembers::parse(event) {
Ok(members) => self.members = members,
Ok(members) => {
self.members = members;
self.adopt_roster_membership(me);
}
Err(_) => return false,
},
Kind::GroupRoles => match GroupRoles::parse(event) {
@@ -653,6 +704,21 @@ impl Group {
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 {
if !self.index.insert(event.id) {
return false;
@@ -686,6 +752,24 @@ impl Group {
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 {
let activity = match Activity::parse(event) {
Ok(activity) => activity,
+8 -1
View File
@@ -43,7 +43,7 @@ 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> {
pub(crate) fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
tags.iter()
.find(|tag| tag.kind() == name)
.and_then(|tag| tag.content())
@@ -98,6 +98,13 @@ impl GroupId {
.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(
&self,
content: &str,
+190 -92
View File
@@ -1,112 +1,210 @@
use anyhow::Result;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, ClipboardItem, ParentElement, SharedString, Styled, WeakEntity, Window, div,
px,
App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled,
WeakEntity, Window, div, px,
};
use nip29::Group;
use nip29::{Group, Invite};
use nostr_sdk::prelude::SecretKey;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use ui::dialog::DialogButtonProps;
use ui::button::{Button, ButtonVariants};
use ui::input::InputState;
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;
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| {
InputState::new(window, cx)
.placeholder("Invite code")
.default_value(code)
.default_value(random_code())
});
window.open_dialog(cx, move |this, _window, cx| {
let group = group.clone();
let input = input.clone();
this.width(px(420.))
.confirm()
.title("Invite link")
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Copy link"),
)
.child(
v_flex().gap_3().child(
div()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_muted)
.child("Share the code with whoever should be able to join. The relay must accept it."),
)
.child(field("Code", &input, cx)),
)
.on_ok(move |_event, window, cx| {
let code = input.read(cx).value().trim().to_owned();
if code.is_empty() {
return false;
}
let link = group
.read_with(cx, |group, _cx| {
group.metadata().and_then(|metadata| {
metadata
.naddr(group.key().relay(), Some(&code))
.ok()
.map(SharedString::from)
})
})
.ok()
.flatten();
let Some(link) = link else {
window.push_notification(
Notification::error(
"The relay doesn't publish group details, so no invite link can be made",
),
cx,
);
return false;
};
match group.update(cx, |group, cx| group.create_invite(&code, cx)) {
Ok(task) => {
window
.spawn(cx, async move |cx| -> Result<()> {
if let Err(error) = task.await {
cx.update(|window, cx| {
window.push_notification(
Notification::error(error.to_string()),
cx,
);
})?;
return Ok(());
}
cx.update(|window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
link.to_string(),
));
window.push_notification(
Notification::success("Invite link copied"),
cx,
);
})?;
Ok(())
})
.detach();
}
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
}
true
})
this.max_h(px(600.))
.show_close(true)
.title("Invites")
.child(body(group.clone(), input.clone(), cx))
});
}
fn random_code() -> String {
SecretKey::generate()
.to_secret_hex()
.chars()
.take(CODE_LEN)
.collect()
}
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(
h_flex()
.flex_shrink_0()
.gap_1()
.child(copy_button(&group, &invite.code))
.child(revoke_button(&group, invite.id, &invite.code)),
)
}
fn copy_button(group: &WeakEntity<Group>, code: &str) -> Button {
let group = group.clone();
let code = code.to_owned();
Button::new(SharedString::from(format!("invite-copy-{code}")))
.label("Copy")
.ghost()
.small()
.on_click(move |_event, window, cx| {
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;
};
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)) {
Ok(task) => {
window
.spawn(cx, async move |cx| -> Result<()> {
if let Err(error) = task.await {
cx.update(|window, cx| {
window.push_notification(Notification::error(error.to_string()), cx);
})?;
return Ok(());
}
cx.update(|window, cx| {
input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
cx.write_to_clipboard(ClipboardItem::new_string(link));
window.push_notification(Notification::success("Invite link copied"), cx);
})?;
Ok(())
})
.detach();
}
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
}
}
+91 -2
View File
@@ -26,7 +26,7 @@ use ui::markdown::RenderedText;
use ui::message::WelcomeMessage;
use ui::notification::Notification;
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};
mod details;
@@ -310,6 +310,8 @@ pub struct GroupPanel {
group: WeakEntity<Group>,
list_state: ListState,
head: Option<EventId>,
/// The message the composer is replying to, if any.
reply_to: Option<EventId>,
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
input: Entity<TextareaState>,
/// Uploaded, non-encrypted attachments waiting to be sent.
@@ -380,6 +382,7 @@ impl GroupPanel {
group: group.downgrade(),
list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)),
head,
reply_to: None,
rendered_texts_by_id: BTreeMap::new(),
input,
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>) {
let mut content = self.input.read(cx).value().trim().to_owned();
@@ -474,9 +491,11 @@ impl GroupPanel {
return;
}
let replies: Vec<EventId> = self.reply_to.take().into_iter().collect();
let Ok(send) = self
.group
.update(cx, |group, cx| group.send_message(&content, vec![], cx))
.update(cx, |group, cx| group.send_message(&content, replies, cx))
else {
return;
};
@@ -911,6 +930,9 @@ impl GroupPanel {
.w_full()
.p_2()
.gap_1()
.when_some(self.reply_to, |this, target| {
this.child(self.render_reply_target(target, cx))
})
.children(self.render_attachments(cx))
.child(
h_flex()
@@ -943,6 +965,73 @@ impl GroupPanel {
.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> {
self.attachments
.iter()
+74 -13
View File
@@ -52,6 +52,9 @@ pub(crate) fn render(
)
.author(author.name())
.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)
.when(!message.media.is_empty(), |this| {
this.child(media(&message.media, cx))
@@ -71,12 +74,22 @@ pub(crate) fn render(
format!("message-menu-{}", id.to_hex()),
row,
move |menu, window, cx| {
let menu = menu.submenu("React", window, cx, {
let group = group.clone();
let panel = panel.clone();
let menu = menu
.item({
let panel = panel.clone();
move |menu, _window, _cx| react_menu(menu, group.clone(), panel.clone(), id)
});
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 panel = panel.clone();
move |menu, _window, _cx| react_menu(menu, group.clone(), panel.clone(), id)
});
if !admin {
return menu;
@@ -89,11 +102,9 @@ pub(crate) fn render(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).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.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();
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);
}) {
log::warn!("nip29: deleting a message failed: {error}");
}
});
})
})
},
@@ -249,6 +258,58 @@ fn reaction_bar(reactions: &[Reaction], cx: &App) -> AnyElement {
.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 {
if let [only] = media {
return div()
+183 -5
View File
@@ -1,9 +1,20 @@
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window, px};
use nip29::{Group, GroupMetadata};
use std::sync::Arc;
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::input::InputState;
use ui::notification::Notification;
use ui::switch::Switch;
use ui::{WindowExtension, v_flex};
use ui::{Sizable, WindowExtension, h_flex, v_flex};
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())
});
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 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| {
let current = *flags.read(cx);
let supported = *livekit.read(cx) != Some(false);
this.confirm()
.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("About", &about, cx))
.child(field("Picture", &picture, cx))
.child(field("Parent", &parent, cx))
.child(children_section(&children, cx))
.child(
v_flex()
.gap(px(theme::SPACE))
@@ -105,9 +148,26 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
.child(
Switch::new("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)
.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 about = about.clone();
let picture = picture.clone();
let parent = parent.clone();
let children = children.clone();
let flags = flags.clone();
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.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);
next.private = current.private;
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(
flags: &Entity<Flags>,
select: fn(&mut Flags) -> &mut bool,