add group preview mode to browse panel

This commit is contained in:
2026-10-08 04:54:10 +07:00
parent 65c617aff1
commit fbf01de873
3 changed files with 119 additions and 103 deletions
+47 -6
View File
@@ -65,6 +65,7 @@ impl EventEmitter<GroupsEvent> for GroupsRegistry {}
pub struct GroupsRegistry { pub struct GroupsRegistry {
groups: Vec<Entity<Group>>, groups: Vec<Entity<Group>>,
previews: HashMap<GroupKey, Entity<Group>>,
list: GroupList, list: GroupList,
browse: HashMap<SubscriptionId, RelayUrl>, browse: HashMap<SubscriptionId, RelayUrl>,
routes: HashMap<SubscriptionId, GroupKey>, routes: HashMap<SubscriptionId, GroupKey>,
@@ -120,6 +121,7 @@ impl GroupsRegistry {
Self { Self {
groups: Vec::new(), groups: Vec::new(),
previews: HashMap::new(),
list: GroupList::default(), list: GroupList::default(),
browse: HashMap::new(), browse: HashMap::new(),
routes: HashMap::new(), routes: HashMap::new(),
@@ -145,6 +147,7 @@ impl GroupsRegistry {
.iter() .iter()
.find(|group| group.read(cx).key() == key) .find(|group| group.read(cx).key() == key)
.cloned() .cloned()
.or_else(|| self.previews.get(key).cloned())
} }
pub fn join(&mut self, reference: GroupReference, cx: &mut Context<Self>) { pub fn join(&mut self, reference: GroupReference, cx: &mut Context<Self>) {
@@ -268,6 +271,11 @@ impl GroupsRegistry {
.collect() .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. /// Load the discoverable groups from the local database only.
pub fn cached_discovery(&self, cx: &App) -> Task<Discovery> { pub fn cached_discovery(&self, cx: &App) -> Task<Discovery> {
let client = NostrRegistry::global(cx).read(cx).client(); let client = NostrRegistry::global(cx).read(cx).client();
@@ -340,6 +348,7 @@ impl GroupsRegistry {
let routes = std::mem::take(&mut self.routes); let routes = std::mem::take(&mut self.routes);
self.groups.clear(); self.groups.clear();
self.previews.clear();
self.observers.clear(); self.observers.clear();
self.forks.clear(); self.forks.clear();
self.fork_authors.clear(); self.fork_authors.clear();
@@ -532,6 +541,12 @@ impl GroupsRegistry {
} }
fn ensure(&mut self, key: GroupKey, cx: &mut Context<Self>) { fn ensure(&mut self, key: GroupKey, cx: &mut Context<Self>) {
// 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() { if self.group(&key, cx).is_some() {
return; return;
} }
@@ -539,9 +554,31 @@ impl GroupsRegistry {
let group = cx.new(|_| Group::new(key.clone())); let group = cx.new(|_| Group::new(key.clone()));
self.groups.push(group.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<Self>) -> Entity<Group> {
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<Group>, cx: &mut Context<Self>) {
self.observers.insert( self.observers.insert(
key.clone(), key,
cx.subscribe(&group, |this, group, event, cx| match event { cx.subscribe(group, |this, group, event, cx| match event {
GroupEvent::Sent(event) => { GroupEvent::Sent(event) => {
if event.kind == Kind::GroupDeleteEvent { if event.kind == Kind::GroupDeleteEvent {
this.forget_event(group, event.clone(), cx); this.forget_event(group, event.clone(), cx);
@@ -551,7 +588,13 @@ impl GroupsRegistry {
} }
} }
GroupEvent::Updated => { 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); this.sync_forks(false, cx);
cx.emit(GroupsEvent::Updated); 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<Self>) { fn subscribe(&mut self, key: GroupKey, cx: &mut Context<Self>) {
@@ -586,6 +626,7 @@ impl GroupsRegistry {
self.routes.remove(&page); self.routes.remove(&page);
self.observers.remove(&key); self.observers.remove(&key);
self.groups.retain(|group| group.read(cx).key() != &key); self.groups.retain(|group| group.read(cx).key() != &key);
self.previews.remove(&key);
self.list.remove(&key); self.list.remove(&key);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
+47 -56
View File
@@ -9,7 +9,7 @@ use gpui::{
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
list, px, list, px,
}; };
use nip29::{Group, GroupEvent, GroupsRegistry, Membership, Pin, Row}; use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use serde::Deserialize; use serde::Deserialize;
@@ -64,33 +64,8 @@ enum Notice {
impl Notice { impl Notice {
fn of(group: &Group) -> Option<Self> { fn of(group: &Group) -> Option<Self> {
if let Some(reason) = group.refused() { let reason = group.refused()?;
return Some(Self::Relay(reason.to_owned())); 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)
} }
} }
@@ -654,12 +629,8 @@ impl GroupPanel {
Button::new("group-moved") Button::new("group-moved")
.label("Join") .label("Join")
.small() .small()
.on_click(cx.listener(move |_this, _event, _window, cx| { .on_click(cx.listener(move |this, _event, _window, cx| {
let registry = GroupsRegistry::global(cx); this.request_join(moved.clone(), cx);
registry.update(cx, |registry, cx| {
registry.join_key(moved.clone(), None, cx);
});
})), })),
) )
.into_any_element() .into_any_element()
@@ -758,6 +729,13 @@ impl GroupPanel {
.into_any_element() .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<Self>) -> AnyElement { fn render_notice(&self, notice: &Notice, cx: &mut Context<Self>) -> AnyElement {
let row = h_flex() let row = h_flex()
.w_full() .w_full()
@@ -767,7 +745,7 @@ impl GroupPanel {
.px_3() .px_3()
.py_2() .py_2()
.text_sm() .text_sm()
.text_color(cx.theme().text_placeholder); .text_color(cx.theme().text_muted);
match notice { match notice {
Notice::Relay(reason) => row Notice::Relay(reason) => row
@@ -781,7 +759,7 @@ impl GroupPanel {
if *closed { if *closed {
return row return row
.child("This group is closed; an invite link is required.") .child("Group is closed. An invite link is required.")
.into_any_element(); .into_any_element();
} }
@@ -793,17 +771,17 @@ impl GroupPanel {
return row.into_any_element(); return row.into_any_element();
}; };
row.child( row.child("You're in preview mode. Join this group to talk.")
Button::new("join-group") .child(
.label("Request to join") Button::new("join")
.small() .label("Request to Join")
.on_click(cx.listener(move |_this, _event, _window, cx| { .small()
GroupsRegistry::global(cx).update(cx, |registry, cx| { .ghost_alt()
registry.join_key(key.clone(), None, cx); .on_click(cx.listener(move |this, _event, _window, cx| {
}); this.request_join(key.clone(), cx);
})), })),
) )
.into_any_element() .into_any_element()
} }
Notice::Pending => row Notice::Pending => row
.child("Waiting for the relay to approve.") .child("Waiting for the relay to approve.")
@@ -871,18 +849,31 @@ impl GroupPanel {
) )
} }
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
let can_send = self.group.upgrade().is_some_and(|group| { let notice = self.group.upgrade().and_then(|group| {
let group = group.read(cx); 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(&notice, cx);
}
v_flex() v_flex()
.flex_shrink_0() .flex_shrink_0()
.w_full() .w_full()
@@ -912,12 +903,12 @@ impl GroupPanel {
.tooltip("Send") .tooltip("Send")
.ghost() .ghost()
.large() .large()
.disabled(!can_send)
.on_click(cx.listener(|this, _event, window, cx| { .on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx); this.send(window, cx);
})), })),
), ),
) )
.into_any_element()
} }
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> { fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
+25 -41
View File
@@ -9,7 +9,7 @@ use gpui::{
size, size,
}; };
use gpui_base::{VirtualListScrollHandle, v_virtual_list}; 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 nostr_sdk::prelude::{RelayUrl, Timestamp};
use settings::AppSettings; use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
@@ -20,7 +20,7 @@ use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::input::{Input, InputEvent, InputState}; use ui::input::{Input, InputEvent, InputState};
use ui::notification::Notification; use ui::notification::Notification;
use ui::scroll::Scrollbar; 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 TITLE: &str = "Browse";
const INPUT_PLACEHOLDER: &str = "wss://relay.example.com"; const INPUT_PLACEHOLDER: &str = "wss://relay.example.com";
@@ -223,17 +223,10 @@ impl BrowsePanel {
cx.notify(); cx.notify();
} }
/// Join a browsed group and open it. /// Open the group's panel to preview (or revisit) it.
fn join(dock: &WeakEntity<DockArea>, key: GroupKey, window: &mut Window, cx: &mut App) { fn open_panel(dock: &WeakEntity<DockArea>, key: GroupKey, window: &mut Window, cx: &mut App) {
let registry = GroupsRegistry::global(cx); let registry = GroupsRegistry::global(cx);
let group = registry.update(cx, |registry, cx| registry.open(key, cx));
registry.update(cx, |registry, cx| {
registry.join_key(key.clone(), None, cx);
});
let Some(group) = registry.read(cx).group(&key, cx) else {
return;
};
ui::dock::add_panel_to( ui::dock::add_panel_to(
dock, 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<GroupCandidate> { fn discovered_cards(&self, cx: &App) -> Vec<GroupCandidate> {
let joined: Vec<GroupKey> = GroupsRegistry::global(cx) let member_of: Vec<GroupKey> = GroupsRegistry::global(cx)
.read(cx) .read(cx)
.groups() .groups()
.iter() .iter()
.filter(|group| matches!(group.read(cx).membership(), Membership::Member))
.map(|group| group.read(cx).key().clone()) .map(|group| group.read(cx).key().clone())
.collect(); .collect();
self.discovery self.discovery
.discovered() .discovered()
.iter() .iter()
.filter(|candidate| !joined.contains(&candidate.key)) .filter(|candidate| !member_of.contains(&candidate.key))
.cloned() .cloned()
.collect() .collect()
} }
@@ -357,6 +351,10 @@ impl BrowsePanel {
} }
fn render_card(&self, candidate: &GroupCandidate, cx: &mut Context<Self>) -> AnyElement { fn render_card(&self, candidate: &GroupCandidate, cx: &mut Context<Self>) -> 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 key = candidate.key.clone();
let seed = key.cache_tag(); let seed = key.cache_tag();
let members = self.discovery.members(&key).unwrap_or(0); let members = self.discovery.members(&key).unwrap_or(0);
@@ -372,33 +370,19 @@ impl BrowsePanel {
.display_about() .display_about()
.map(|about| SharedString::from(about.to_owned())); .map(|about| SharedString::from(about.to_owned()));
let registry = GroupsRegistry::global(cx); let member = registry
let hide_avatar = AppSettings::get_hide_avatar(cx); .read(cx)
let joined = registry.read(cx).group(&key, cx).is_some(); .group(&key, cx)
.is_some_and(|group| matches!(group.read(cx).membership(), Membership::Member));
let action = if joined { let action = Button::new(format!("open-{seed}"))
Button::new(format!("joined-{seed}")) .label(if member { "Open" } else { "Preview" })
.label("Joined") .small()
.small() .ghost()
.ghost() .on_click(move |_event, window, cx| {
.disabled(true) Self::open_panel(&dock, key.clone(), window, cx);
.into_any_element() })
} else { .into_any_element();
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()
};
v_flex() v_flex()
.flex_1() .flex_1()