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::{Sizable, WindowExtension, h_flex, v_flex}; use crate::util::field; #[derive(Clone, Copy, Default)] struct Flags { private: bool, restricted: bool, hidden: bool, closed: bool, livekit: bool, } impl Flags { fn of(metadata: &GroupMetadata) -> Self { Self { private: metadata.private, restricted: metadata.restricted, hidden: metadata.hidden, closed: metadata.closed, livekit: metadata.livekit, } } } pub(crate) fn open(group: WeakEntity, window: &mut Window, cx: &mut App) { let base = group .read_with(cx, |group, _cx| group.metadata().cloned()) .ok() .flatten() .unwrap_or_default(); let name = cx.new(|cx| { InputState::new(window, cx) .placeholder("Group name") .default_value(base.name.clone().unwrap_or_default()) }); let about = cx.new(|cx| { InputState::new(window, cx) .placeholder("About") .default_value(base.about.clone().unwrap_or_default()) }); let picture = cx.new(|cx| { InputState::new(window, cx) .placeholder("https://example.com/picture.png") .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") .button_props( DialogButtonProps::default() .cancel_text("Cancel") .ok_text("Save"), ) .child( v_flex() .gap_3() .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)) .child( Switch::new("private") .label("Private") .description("Only members can read group messages") .checked(current.private) .on_click(toggle(&flags, |flags| &mut flags.private)), ) .child( Switch::new("restricted") .label("Restricted") .description("Only members can post messages") .checked(current.restricted) .on_click(toggle(&flags, |flags| &mut flags.restricted)), ) .child( Switch::new("hidden") .label("Hidden") .description("Hidden from non-members, not discoverable") .checked(current.hidden) .on_click(toggle(&flags, |flags| &mut flags.hidden)), ) .child( Switch::new("closed") .label("Closed") .description("Join requests are ignored (invite-only)") .checked(current.closed) .on_click(toggle(&flags, |flags| &mut flags.closed)), ) .child( Switch::new("livekit") .label("LiveKit") .description(if supported { "Announce audio and video support." } else { "The relay doesn't announce LiveKit support." }) .checked(current.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(); }); } }), ), ), ) .on_ok({ let group = group.clone(); 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| { let mut next = group .read_with(cx, |group, _cx| group.metadata().cloned()) .ok() .flatten() .unwrap_or_default(); next.name = value(&name, cx); 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; next.hidden = current.hidden; next.closed = current.closed; next.livekit = current.livekit; match group.update(cx, |group, cx| group.edit_metadata(&next, cx)) { Ok(task) => crate::util::report(window, cx, task), Err(error) => log::warn!("nip29: editing the group failed: {error}"), } true } }) }); } /// NIP-29: relays announce LiveKit support with a 204 at this well-known path. async fn probe_livekit(http: Arc, 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>, 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>, 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, select: fn(&mut Flags) -> &mut bool, ) -> impl Fn(&bool, &mut Window, &mut App) + 'static { let flags = flags.clone(); move |next, _window, cx| { flags.update(cx, |flags, cx| { *select(flags) = *next; cx.notify(); }); } } fn value(input: &Entity, cx: &App) -> Option { let value = input.read(cx).value().trim().to_owned(); (!value.is_empty()).then_some(value) }