From fbf01de8739b5d66d5b022474200947c6950c8c9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 8 Oct 2026 04:54:10 +0700 Subject: [PATCH] add group preview mode to browse panel --- crates/nip29/src/lib.rs | 53 +++++++++++-- crates/nip29_ui/src/lib.rs | 103 ++++++++++++-------------- crates/workspace/src/panels/browse.rs | 66 +++++++---------- 3 files changed, 119 insertions(+), 103 deletions(-) diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index ad019b6e..10420191 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -65,6 +65,7 @@ impl EventEmitter for GroupsRegistry {} pub struct GroupsRegistry { groups: Vec>, + previews: HashMap>, list: GroupList, browse: HashMap, routes: HashMap, @@ -120,6 +121,7 @@ impl GroupsRegistry { Self { groups: Vec::new(), + previews: HashMap::new(), list: GroupList::default(), browse: HashMap::new(), routes: HashMap::new(), @@ -145,6 +147,7 @@ impl GroupsRegistry { .iter() .find(|group| group.read(cx).key() == key) .cloned() + .or_else(|| self.previews.get(key).cloned()) } pub fn join(&mut self, reference: GroupReference, cx: &mut Context) { @@ -268,6 +271,11 @@ impl GroupsRegistry { .collect() } + /// Whether the group is one of the user's groups, not a preview. + fn joined(&self, key: &GroupKey, cx: &App) -> bool { + self.groups.iter().any(|group| group.read(cx).key() == key) + } + /// Load the discoverable groups from the local database only. pub fn cached_discovery(&self, cx: &App) -> Task { let client = NostrRegistry::global(cx).read(cx).client(); @@ -340,6 +348,7 @@ impl GroupsRegistry { let routes = std::mem::take(&mut self.routes); self.groups.clear(); + self.previews.clear(); self.observers.clear(); self.forks.clear(); self.fork_authors.clear(); @@ -532,6 +541,12 @@ impl GroupsRegistry { } fn ensure(&mut self, key: GroupKey, cx: &mut Context) { + // A previewed group becomes one of the user's groups, keeping its state. + if let Some(group) = self.previews.remove(&key) { + self.groups.push(group); + return; + } + if self.group(&key, cx).is_some() { return; } @@ -539,9 +554,31 @@ impl GroupsRegistry { let group = cx.new(|_| Group::new(key.clone())); self.groups.push(group.clone()); + self.observe_group(key.clone(), &group, cx); + self.subscribe(key.clone(), cx); + self.load_state(key, cx); + } + + /// Track a group opened for preview: connected, but not joined. + pub fn open(&mut self, key: GroupKey, cx: &mut Context) -> Entity { + if let Some(group) = self.group(&key, cx) { + return group; + } + + let group = cx.new(|_| Group::new(key.clone())); + self.previews.insert(key.clone(), group.clone()); + + self.observe_group(key.clone(), &group, cx); + self.subscribe(key.clone(), cx); + self.load_state(key, cx); + + group + } + + fn observe_group(&mut self, key: GroupKey, group: &Entity, cx: &mut Context) { self.observers.insert( - key.clone(), - cx.subscribe(&group, |this, group, event, cx| match event { + key, + cx.subscribe(group, |this, group, event, cx| match event { GroupEvent::Sent(event) => { if event.kind == Kind::GroupDeleteEvent { this.forget_event(group, event.clone(), cx); @@ -551,7 +588,13 @@ impl GroupsRegistry { } } GroupEvent::Updated => { - this.refresh_entry(group, cx); + let key = group.read(cx).key().clone(); + + // Only the user's groups belong in their group list. + if this.joined(&key, cx) { + this.refresh_entry(group, cx); + } + this.sync_forks(false, cx); cx.emit(GroupsEvent::Updated); @@ -560,9 +603,6 @@ impl GroupsRegistry { _ => {} }), ); - - self.subscribe(key.clone(), cx); - self.load_state(key, cx); } fn subscribe(&mut self, key: GroupKey, cx: &mut Context) { @@ -586,6 +626,7 @@ impl GroupsRegistry { self.routes.remove(&page); self.observers.remove(&key); self.groups.retain(|group| group.read(cx).key() != &key); + self.previews.remove(&key); self.list.remove(&key); let nostr = NostrRegistry::global(cx); diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 4da55506..d56f1fec 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -9,7 +9,7 @@ use gpui::{ PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, }; -use nip29::{Group, GroupEvent, GroupsRegistry, Membership, Pin, Row}; +use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row}; use nostr_sdk::prelude::*; use person::PersonRegistry; use serde::Deserialize; @@ -64,33 +64,8 @@ enum Notice { impl Notice { fn of(group: &Group) -> Option { - if let Some(reason) = group.refused() { - return Some(Self::Relay(reason.to_owned())); - } - - let closed = group.metadata().is_some_and(|metadata| metadata.closed); - - match group.membership() { - Membership::Member => {} - Membership::Pending { .. } => return Some(Self::Pending), - Membership::Refused { reason } => { - return Some(Self::Join { - refused: reason.clone(), - closed, - }); - } - Membership::Unknown | Membership::Removed => { - return Some(Self::Join { - refused: None, - closed, - }); - } - } - - group - .metadata() - .is_some_and(|metadata| !metadata.supports(Kind::ChatMessage)) - .then_some(Self::Unsupported) + let reason = group.refused()?; + Some(Self::Relay(reason.to_owned())) } } @@ -654,12 +629,8 @@ impl GroupPanel { Button::new("group-moved") .label("Join") .small() - .on_click(cx.listener(move |_this, _event, _window, cx| { - let registry = GroupsRegistry::global(cx); - - registry.update(cx, |registry, cx| { - registry.join_key(moved.clone(), None, cx); - }); + .on_click(cx.listener(move |this, _event, _window, cx| { + this.request_join(moved.clone(), cx); })), ) .into_any_element() @@ -758,6 +729,13 @@ impl GroupPanel { .into_any_element() } + /// Ask the relay to admit the user into the group. + fn request_join(&self, key: GroupKey, cx: &mut App) { + GroupsRegistry::global(cx).update(cx, |registry, cx| { + registry.join_key(key, None, cx); + }); + } + fn render_notice(&self, notice: &Notice, cx: &mut Context) -> AnyElement { let row = h_flex() .w_full() @@ -767,7 +745,7 @@ impl GroupPanel { .px_3() .py_2() .text_sm() - .text_color(cx.theme().text_placeholder); + .text_color(cx.theme().text_muted); match notice { Notice::Relay(reason) => row @@ -781,7 +759,7 @@ impl GroupPanel { if *closed { return row - .child("This group is closed; an invite link is required.") + .child("Group is closed. An invite link is required.") .into_any_element(); } @@ -793,17 +771,17 @@ impl GroupPanel { return row.into_any_element(); }; - row.child( - Button::new("join-group") - .label("Request to join") - .small() - .on_click(cx.listener(move |_this, _event, _window, cx| { - GroupsRegistry::global(cx).update(cx, |registry, cx| { - registry.join_key(key.clone(), None, cx); - }); - })), - ) - .into_any_element() + row.child("You're in preview mode. Join this group to talk.") + .child( + Button::new("join") + .label("Request to Join") + .small() + .ghost_alt() + .on_click(cx.listener(move |this, _event, _window, cx| { + this.request_join(key.clone(), cx); + })), + ) + .into_any_element() } Notice::Pending => row .child("Waiting for the relay to approve.") @@ -871,18 +849,31 @@ impl GroupPanel { ) } - fn render_composer(&self, cx: &mut Context) -> impl IntoElement { - let can_send = self.group.upgrade().is_some_and(|group| { + fn render_composer(&self, cx: &mut Context) -> AnyElement { + let notice = self.group.upgrade().and_then(|group| { let group = group.read(cx); - let metadata = group.metadata(); - let supports_text = - metadata.is_none_or(|metadata| metadata.supports(Kind::ChatMessage)); - let writable = metadata.is_none_or(|metadata| !metadata.restricted) - || matches!(group.membership(), Membership::Member); - supports_text && writable + if !group + .metadata() + .is_none_or(|metadata| metadata.supports(Kind::ChatMessage)) + { + return Some(Notice::Unsupported); + } + + match group.membership() { + Membership::Member => None, + Membership::Pending { .. } => Some(Notice::Pending), + _ => Some(Notice::Join { + refused: None, + closed: group.metadata().is_some_and(|metadata| metadata.closed), + }), + } }); + if let Some(notice) = notice { + return self.render_notice(¬ice, cx); + } + v_flex() .flex_shrink_0() .w_full() @@ -912,12 +903,12 @@ impl GroupPanel { .tooltip("Send") .ghost() .large() - .disabled(!can_send) .on_click(cx.listener(|this, _event, window, cx| { this.send(window, cx); })), ), ) + .into_any_element() } fn render_attachments(&self, cx: &mut Context) -> Vec { diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index 69910689..b39319a8 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -9,7 +9,7 @@ use gpui::{ size, }; use gpui_base::{VirtualListScrollHandle, v_virtual_list}; -use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry}; +use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry, Membership}; use nostr_sdk::prelude::{RelayUrl, Timestamp}; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; @@ -20,7 +20,7 @@ use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::input::{Input, InputEvent, InputState}; use ui::notification::Notification; use ui::scroll::Scrollbar; -use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; +use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; const TITLE: &str = "Browse"; const INPUT_PLACEHOLDER: &str = "wss://relay.example.com"; @@ -223,17 +223,10 @@ impl BrowsePanel { cx.notify(); } - /// Join a browsed group and open it. - fn join(dock: &WeakEntity, key: GroupKey, window: &mut Window, cx: &mut App) { + /// Open the group's panel to preview (or revisit) it. + fn open_panel(dock: &WeakEntity, key: GroupKey, window: &mut Window, cx: &mut App) { let registry = GroupsRegistry::global(cx); - - registry.update(cx, |registry, cx| { - registry.join_key(key.clone(), None, cx); - }); - - let Some(group) = registry.read(cx).group(&key, cx) else { - return; - }; + let group = registry.update(cx, |registry, cx| registry.open(key, cx)); ui::dock::add_panel_to( dock, @@ -244,19 +237,20 @@ impl BrowsePanel { ); } - /// The discovered groups the user has not joined yet. + /// The discovered groups the user is not a member of. fn discovered_cards(&self, cx: &App) -> Vec { - let joined: Vec = GroupsRegistry::global(cx) + let member_of: Vec = GroupsRegistry::global(cx) .read(cx) .groups() .iter() + .filter(|group| matches!(group.read(cx).membership(), Membership::Member)) .map(|group| group.read(cx).key().clone()) .collect(); self.discovery .discovered() .iter() - .filter(|candidate| !joined.contains(&candidate.key)) + .filter(|candidate| !member_of.contains(&candidate.key)) .cloned() .collect() } @@ -357,6 +351,10 @@ impl BrowsePanel { } fn render_card(&self, candidate: &GroupCandidate, cx: &mut Context) -> AnyElement { + let registry = GroupsRegistry::global(cx); + let hide_avatar = AppSettings::get_hide_avatar(cx); + let dock = self.dock.clone(); + let key = candidate.key.clone(); let seed = key.cache_tag(); let members = self.discovery.members(&key).unwrap_or(0); @@ -372,33 +370,19 @@ impl BrowsePanel { .display_about() .map(|about| SharedString::from(about.to_owned())); - let registry = GroupsRegistry::global(cx); - let hide_avatar = AppSettings::get_hide_avatar(cx); - let joined = registry.read(cx).group(&key, cx).is_some(); + let member = registry + .read(cx) + .group(&key, cx) + .is_some_and(|group| matches!(group.read(cx).membership(), Membership::Member)); - let action = if joined { - Button::new(format!("joined-{seed}")) - .label("Joined") - .small() - .ghost() - .disabled(true) - .into_any_element() - } else { - let panel = cx.entity().downgrade(); - let dock = self.dock.clone(); - let clicked = key; - - Button::new(format!("join-{seed}")) - .label("Join") - .small() - .ghost() - .on_click(move |_event, window, cx| { - Self::join(&dock, clicked.clone(), window, cx); - - let _ = panel.update(cx, |_, cx| cx.notify()); - }) - .into_any_element() - }; + let action = Button::new(format!("open-{seed}")) + .label(if member { "Open" } else { "Preview" }) + .small() + .ghost() + .on_click(move |_event, window, cx| { + Self::open_panel(&dock, key.clone(), window, cx); + }) + .into_any_element(); v_flex() .flex_1()