Author SHA1 Message Date
reya 91c40b0799 refactor state init 2026-09-18 20:22:51 +07:00
reya 1b6ef6f574 clean up 2026-09-18 20:06:16 +07:00
reya f9e538257a update 2026-09-18 20:00:12 +07:00
reya 70140b2454 update community backend 2026-09-18 19:27:30 +07:00
reya 2def7548ef add community crate 2026-09-18 18:49:21 +07:00
reya 1320a2c361 refactor avatar 2026-09-18 17:03:17 +07:00
reya 88005fbc41 update sidebar 2026-09-18 15:53:40 +07:00
reya 41bd0ce345 . 2026-09-18 15:43:48 +07:00
reya 492e50746f wip 2026-09-18 15:35:42 +07:00
reya e75b1b9f10 wip 2026-09-18 15:17:42 +07:00
reya 0ad491cb92 refactor sidebar (wip) 2026-09-18 15:04:57 +07:00
reya 98903de1d0 add plan 2026-09-18 13:44:22 +07:00
reya 2ccbfcd4a8 redesign dock and titlebar 2026-09-18 13:21:40 +07:00
43 changed files with 3283 additions and 1251 deletions
Generated
+19
View File
@@ -1274,6 +1274,23 @@ dependencies = [
"regex", "regex",
] ]
[[package]]
name = "community"
version = "1.0.2"
dependencies = [
"anyhow",
"concord",
"flume 0.11.1",
"gpui-pre",
"log",
"nostr-memory",
"nostr-sdk",
"serde_json",
"smallvec",
"smol",
"state",
]
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.43" version = "0.4.43"
@@ -1412,6 +1429,7 @@ dependencies = [
"auto_update", "auto_update",
"chat", "chat",
"common", "common",
"community",
"device", "device",
"gpui-pre", "gpui-pre",
"gpui-pre-linux", "gpui-pre-linux",
@@ -1437,6 +1455,7 @@ dependencies = [
"assets", "assets",
"chat", "chat",
"common", "common",
"community",
"console_error_panic_hook", "console_error_panic_hook",
"console_log", "console_log",
"device", "device",
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><ellipse cx="12" cy="12" rx="3.5" ry="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 9.25H20.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 14.75H20.5" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 5.75V17.25C2.75 18.3546 3.64543 19.25 4.75 19.25H19.25C20.3546 19.25 21.25 18.3546 21.25 17.25V8.75C21.25 7.64543 20.3546 6.75 19.25 6.75H13.0704C12.4017 6.75 11.7772 6.4158 11.4063 5.8594L10.5937 4.6406C10.2228 4.0842 9.59834 3.75 8.92963 3.75H4.75C3.64543 3.75 2.75 4.64543 2.75 5.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

+8 -15
View File
@@ -3,7 +3,7 @@
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window}; use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version}; use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
use instant::Duration; use instant::Duration;
@@ -35,7 +35,7 @@ fn uses_managed_updates() -> bool {
} }
/// Initialize the auto-update system. /// Initialize the auto-update system.
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
if uses_managed_updates() { if uses_managed_updates() {
log::info!( log::info!(
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)" "Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
@@ -60,10 +60,7 @@ pub fn init(window: &mut Window, cx: &mut App) {
return; return;
}; };
AutoUpdater::set_global( AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(version, filter, cx)), cx);
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
cx,
);
} }
struct GlobalAutoUpdater(Entity<AutoUpdater>); struct GlobalAutoUpdater(Entity<AutoUpdater>);
@@ -103,21 +100,17 @@ impl AutoUpdater {
cx.set_global(GlobalAutoUpdater(state)); cx.set_global(GlobalAutoUpdater(state));
} }
fn new( fn new(version: Version, filter: AssetFilter, cx: &mut Context<Self>) -> Self {
window: &mut Window, let entity = cx.entity().downgrade();
version: Version,
filter: AssetFilter,
cx: &mut Context<Self>,
) -> Self {
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter); let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum); let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
let engine = Arc::new(UpdateEngine::new(source, config)); let engine = Arc::new(UpdateEngine::new(source, config));
// Schedule an auto-check after a 2-minute delay // Schedule an auto-check after a 2-minute delay
cx.defer_in(window, |_this, _window, cx| { cx.defer(move |cx| {
cx.spawn(async move |this, cx| { cx.spawn(async move |cx| {
cx.background_executor().timer(AUTO_CHECK_DELAY).await; cx.background_executor().timer(AUTO_CHECK_DELAY).await;
this.update(cx, |this, cx| this.check(cx)).ok(); entity.update(cx, |this, cx| this.check(cx)).ok();
}) })
.detach(); .detach();
}); });
+24 -6
View File
@@ -26,8 +26,8 @@ pub use state::FileAttachment;
/// A static keypair used only for signing locally-cached rumor events. /// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate); static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx); ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
} }
struct GlobalChatRegistry(Entity<ChatRegistry>); struct GlobalChatRegistry(Entity<ChatRegistry>);
@@ -150,7 +150,8 @@ impl ChatRegistry {
} }
/// Create a new chat registry instance /// Create a new chat registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let (tx, rx) = flume::unbounded::<Signal>(); let (tx, rx) = flume::unbounded::<Signal>();
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
@@ -167,9 +168,12 @@ impl ChatRegistry {
}), }),
); );
// Run at the end of the current cycle cx.defer(move |cx| {
cx.defer_in(window, |this, _window, cx| { entity
.update(cx, |this, cx| {
this.get_rooms(cx); this.get_rooms(cx);
})
.ok();
}); });
Self { Self {
@@ -221,7 +225,21 @@ impl ChatRegistry {
}; };
match *message { match *message {
RelayMessage::Event { event, .. } => { RelayMessage::Event {
subscription_id,
event,
..
} => {
let chat_sub = subscription_id.as_str() != sub_id1.as_str();
let device_sub = subscription_id.as_str() != sub_id2.as_str();
// Concord wraps are also kind 1059.
//
// Only the two gift wrap subscriptions carry NIP-59 wraps for this account.
if event.kind == Kind::GiftWrap && chat_sub && device_sub {
continue;
}
// Prune the dedup set before it grows unbounded // Prune the dedup set before it grows unbounded
if processed_events.len() >= MAX_PROCESSED { if processed_events.len() >= MAX_PROCESSED {
processed_events.clear(); processed_events.clear();
+14 -5
View File
@@ -289,12 +289,21 @@ impl Room {
} }
} }
/// Gets the display image for the room /// Gets the display picture for the room, if it has one
pub fn display_image(&self, cx: &App) -> SharedString { pub fn display_image(&self, cx: &App) -> Option<SharedString> {
if !self.is_group() { if self.is_group() {
self.display_member(cx).avatar() None
} else { } else {
SharedString::from("brand/group.png") self.display_member(cx).avatar()
}
}
/// A stable seed for the room's generated avatar
pub fn display_image_seed(&self, cx: &App) -> SharedString {
if self.is_group() {
SharedString::from(self.id.to_string())
} else {
self.display_member(cx).avatar_seed()
} }
} }
+5 -3
View File
@@ -1203,6 +1203,7 @@ impl ChatPanel {
if show_author { if show_author {
this.child( this.child(
Avatar::new(author.avatar()) Avatar::new(author.avatar())
.seed(author.avatar_seed())
.flex_shrink_0() .flex_shrink_0()
.relative() .relative()
.dropdown_menu(move |this, _window, _cx| { .dropdown_menu(move |this, _window, _cx| {
@@ -1470,7 +1471,7 @@ impl ChatPanel {
h_flex() h_flex()
.gap_1() .gap_1()
.font_semibold() .font_semibold()
.child(Avatar::new(avatar).small()) .child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
.child(name.clone()), .child(name.clone()),
), ),
) )
@@ -1978,11 +1979,12 @@ impl Panel for ChatPanel {
self.room self.room
.read_with(cx, |this, cx| { .read_with(cx, |this, cx| {
let label = this.display_name(cx); let label = this.display_name(cx);
let url = this.display_image(cx); let picture = this.display_image(cx);
let seed = this.display_image_seed(cx);
h_flex() h_flex()
.gap_1p5() .gap_1p5()
.child(Avatar::new(url).xsmall()) .child(Avatar::new(picture).seed(seed).xsmall())
.child(label) .child(label)
.into_any_element() .into_any_element()
}) })
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "community"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
concord = { path = "../concord" }
state = { path = "../state" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
flume.workspace = true
log.workspace = true
serde_json.workspace = true
smallvec.workspace = true
[dev-dependencies]
nostr-memory.workspace = true
smol.workspace = true
+129
View File
@@ -0,0 +1,129 @@
use std::collections::{BTreeMap, BTreeSet};
use anyhow::Result;
use concord::cord02::ControlFold;
use concord::store::{ChannelKeyRef, CommunityState};
use concord::{ChannelId, CommunityId, Epoch};
use gpui::{AppContext, Context, EventEmitter, Task};
use nostr_sdk::prelude::*;
use state::NostrRegistry;
use crate::sync::{self, Snapshot};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionKey {
control_pks: BTreeMap<u64, PublicKey>,
channels: Vec<(ChannelId, Epoch, bool)>,
relays: Vec<RelayUrl>,
}
impl SubscriptionKey {
fn of(state: &CommunityState) -> Self {
Self {
control_pks: state.control_pks.clone(),
channels: state
.channels
.iter()
.map(|channel| (channel.id, channel.epoch, channel.private))
.collect(),
relays: state.relays.clone(),
}
}
pub(crate) fn relays(&self) -> &[RelayUrl] {
&self.relays
}
}
#[derive(Debug, Clone)]
pub enum CommunityEvent {
Updated(CommunityId),
Error(String),
}
pub struct Community {
state: CommunityState,
control: ControlFold,
members: BTreeSet<PublicKey>,
dirty: bool,
refresh_task: Option<Task<Result<()>>>,
}
impl EventEmitter<CommunityEvent> for Community {}
impl Community {
pub fn new(state: CommunityState) -> Self {
Self {
state,
control: ControlFold::default(),
members: BTreeSet::new(),
dirty: false,
refresh_task: None,
}
}
pub fn id(&self) -> CommunityId {
self.state.id
}
pub fn state(&self) -> &CommunityState {
&self.state
}
pub fn control(&self) -> &ControlFold {
&self.control
}
pub fn members(&self) -> &BTreeSet<PublicKey> {
&self.members
}
pub fn channels(&self) -> &[ChannelKeyRef] {
&self.state.channels
}
pub fn subscription_key(&self) -> SubscriptionKey {
SubscriptionKey::of(&self.state)
}
/// Rebuilds the community from the wraps in the local database.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refresh_task.is_some() {
self.dirty = true;
return;
}
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let state = self.state.clone();
let folded = cx.background_spawn(async move { sync::fold(&client, &state).await });
self.refresh_task = Some(cx.spawn(async move |this, cx| {
let result = folded.await;
this.update(cx, |this, cx| this.apply(result, cx))?;
Ok(())
}));
}
fn apply(&mut self, result: Result<Option<Snapshot>>, cx: &mut Context<Self>) {
self.refresh_task = None;
match result {
Ok(Some(snapshot)) => {
self.state = snapshot.state;
self.control = snapshot.control;
self.members = snapshot.members;
cx.emit(CommunityEvent::Updated(self.state.id));
cx.notify();
}
Ok(None) => {}
Err(error) => cx.emit(CommunityEvent::Error(error.to_string())),
}
if self.dirty {
self.dirty = false;
self.refresh(cx);
}
}
}
+327
View File
@@ -0,0 +1,327 @@
use std::collections::HashMap;
use anyhow::Result;
use concord::CommunityId;
use concord::cord01::KIND_WRAP;
use concord::store::CommunityState;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
mod community;
mod sync;
pub use community::*;
pub use sync::*;
pub fn init(cx: &mut App) {
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
}
struct GlobalCommunityRegistry(Entity<CommunityRegistry>);
impl Global for GlobalCommunityRegistry {}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Signal {
Event(CommunityId),
}
impl EventEmitter<CommunityEvent> for CommunityRegistry {}
pub struct CommunityRegistry {
communities: Vec<Entity<Community>>,
index: HashMap<CommunityId, Entity<Community>>,
/// The plane set each community was last subscribed with
synced: HashMap<CommunityId, SubscriptionKey>,
/// One observer per tracked community, dropped on reset
observers: Vec<Subscription>,
signal_tx: flume::Sender<Signal>,
signal_rx: flume::Receiver<Signal>,
tasks: SmallVec<[Task<Result<()>>; 2]>,
/// Notification listener task (cancelled on signer change)
notification_listener: Option<Task<Result<()>>>,
/// Signal consumer task (cancelled on signer change)
signal_consumer: Option<Task<Result<()>>>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl CommunityRegistry {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalCommunityRegistry>().0.clone()
}
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalCommunityRegistry(state));
}
fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx);
let (tx, rx) = flume::bounded::<Signal>(256);
let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| {
if event.signer_changed() {
this.reset(cx);
this.handle_notifications(cx);
this.load(cx);
}
}));
cx.defer(move |cx| {
entity
.update(cx, |this, cx| {
this.handle_notifications(cx);
if nostr.read(cx).current_user().is_some() {
this.load(cx);
}
})
.ok();
});
Self {
communities: Vec::new(),
index: HashMap::new(),
synced: HashMap::new(),
observers: Vec::new(),
signal_tx: tx,
signal_rx: rx,
tasks: smallvec![],
notification_listener: None,
signal_consumer: None,
_subscriptions: subscriptions,
}
}
pub fn communities(&self) -> &[Entity<Community>] {
&self.communities
}
pub fn community(&self, id: &CommunityId) -> Option<Entity<Community>> {
self.index.get(id).cloned()
}
/// Forget the current account and cancel everything in flight.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.notification_listener = None;
self.signal_consumer = None;
self.tasks.clear();
self.observers.clear();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let ids: Vec<CommunityId> = self.index.keys().copied().collect();
for id in ids {
let client = client.clone();
let subscription = sync::subscription_id(&id);
self.tasks.push(cx.background_spawn(async move {
client.unsubscribe(&subscription).await?;
Ok(())
}));
}
self.communities.clear();
self.index.clear();
self.synced.clear();
cx.notify();
}
/// Discover the account's communities in the local database.
fn load(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let client = nostr.read(cx).client();
let task = cx.background_spawn(async move {
let self_pk = signer.get_public_key_async().await?;
sync::load(&client, &signer, self_pk).await
});
self.tasks.push(cx.spawn(async move |this, cx| {
match task.await {
Ok(states) => {
this.update(cx, |this, cx| this.track(states, cx))?;
}
Err(error) => {
this.update(cx, |_this, cx| {
cx.emit(CommunityEvent::Error(error.to_string()));
})?;
}
}
Ok(())
}));
}
/// Replace the tracked communities with a freshly loaded set.
fn track(&mut self, states: Vec<CommunityState>, cx: &mut Context<Self>) {
self.observers.clear();
self.communities.clear();
self.index.clear();
self.synced.clear();
for state in states {
let id = state.id;
let community = cx.new(|_| Community::new(state));
self.observers
.push(cx.observe(&community, |this, _community, cx| {
this.sync_subscriptions(cx);
}));
self.index.insert(id, community.clone());
self.communities.push(community);
}
self.sync_subscriptions(cx);
// A backlog already in the database produces no notification, so fold it once.
for community in self.communities.clone() {
community.update(cx, |community, cx| community.refresh(cx));
}
cx.notify();
}
fn refresh(&mut self, id: CommunityId, cx: &mut Context<Self>) {
let Some(community) = self.index.get(&id).cloned() else {
return;
};
community.update(cx, |community, cx| community.refresh(cx));
}
/// Re-subscribe every community whose held planes moved.
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
for community in self.communities.clone() {
let (id, key, state) = {
let community = community.read(cx);
(
community.id(),
community.subscription_key(),
community.state().clone(),
)
};
if self.synced.get(&id) == Some(&key) {
continue;
}
let planes = match sync::planes(&state) {
Ok(planes) => planes,
Err(error) => {
cx.emit(CommunityEvent::Error(error.to_string()));
continue;
}
};
let subscription = sync::subscription_id(&id);
let filter = sync::subscription_filter(&planes);
let relays = key.relays().to_vec();
self.synced.insert(id, key);
let client = client.clone();
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(error) = subscribe(&client, &subscription, &relays, filter).await {
this.update(cx, |_this, cx| {
cx.emit(CommunityEvent::Error(error.to_string()));
})?;
}
Ok(())
}));
}
}
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
self.notification_listener = None;
self.signal_consumer = None;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone();
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
while let Some(notification) = notifications.next().await {
let ClientNotification::Event {
subscription_id,
event,
..
} = notification
else {
continue;
};
if event.kind != Kind::from(KIND_WRAP) {
continue;
}
let Some(id) = sync::community_of(&subscription_id) else {
continue;
};
tx.send_async(Signal::Event(id)).await?;
}
Ok(())
}));
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
while let Ok(Signal::Event(id)) = rx.recv_async().await {
this.update(cx, |this, cx| this.refresh(id, cx))?;
}
Ok(())
}));
}
}
async fn subscribe(
client: &Client,
id: &SubscriptionId,
relays: &[RelayUrl],
filter: Filter,
) -> Result<()> {
client.unsubscribe(id).await?;
for url in relays {
if let Err(error) = client.add_relay(url).and_connect().await {
log::warn!("community {id}: failed to add relay {url}: {error}");
}
}
// Concord wraps share kind 1059 with NIP-59 gift wraps, so an automatic
// target sends gossip after the plane authors as if they were DM peers.
// The community's own relays are the routing relays, so target them.
let target = if relays.is_empty() {
ReqTarget::auto(vec![filter])
} else {
ReqTarget::manual(
relays
.iter()
.map(|url| (url.clone(), vec![filter.clone()]))
.collect::<Vec<_>>(),
)
};
let output = client.subscribe(target).with_id(id.clone()).await?;
if !output.failed.is_empty() {
log::warn!(
"community {id}: {} relay(s) rejected the subscription",
output.failed.len()
);
}
Ok(())
}
+261
View File
@@ -0,0 +1,261 @@
use std::collections::{BTreeMap, BTreeSet};
use anyhow::Result;
use concord::cord01::KIND_WRAP;
use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST};
use concord::cord02::{self, ControlFold};
use concord::cord04::AuthorityCitation;
use concord::cord04::roles::{Permissions, citation_ok};
use concord::derive::{channel_group_key, control_group_key, guestbook_group_key};
use concord::store::{self, CommunityState};
use concord::{ChannelId, CommunityId, Epoch, GroupKey};
use nostr_sdk::prelude::*;
use state::UniversalSigner;
const SUBSCRIPTION_PREFIX: &str = "concord/";
const STATE_PREFIX: &str = "concord/";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PlaneKind {
Control(Epoch),
Guestbook,
Channel(ChannelId, Epoch),
}
#[derive(Debug, Clone)]
pub struct Plane {
pub kind: PlaneKind,
/// The wrap's author: the control signer for Control, the group's own key otherwise.
pub address: PublicKey,
pub group: GroupKey,
}
pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
let mut planes = Vec::new();
for (epoch, address) in &state.control_pks {
let epoch = Epoch(*epoch);
let group = control_group_key(&state.community_root, &state.id, epoch)?;
planes.push(Plane {
kind: PlaneKind::Control(epoch),
address: *address,
group,
});
}
let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)?;
planes.push(Plane {
kind: PlaneKind::Guestbook,
address: group.pk(),
group,
});
for channel in &state.channels {
if channel.private {
continue;
}
let group = channel_group_key(&state.community_root, &channel.id, channel.epoch)?;
planes.push(Plane {
kind: PlaneKind::Channel(channel.id, channel.epoch),
address: group.pk(),
group,
});
}
Ok(planes)
}
/// One `Filter` covering every held plane. The address is the event author,
/// not a `p` tag: a Concord wrap's `p` tag carries a random ephemeral key.
pub fn subscription_filter(planes: &[Plane]) -> Filter {
Filter::new()
.kinds([Kind::from(KIND_WRAP)])
.authors(planes.iter().map(|plane| plane.address))
}
pub fn subscription_id(id: &CommunityId) -> SubscriptionId {
SubscriptionId::new(format!("{SUBSCRIPTION_PREFIX}{}", id.to_hex()))
}
pub fn community_of(subscription_id: &SubscriptionId) -> Option<CommunityId> {
subscription_id
.as_str()
.strip_prefix(SUBSCRIPTION_PREFIX)?
.parse()
.ok()
}
#[derive(Debug, Clone)]
pub struct Snapshot {
pub state: CommunityState,
pub control: ControlFold,
pub members: BTreeSet<PublicKey>,
}
/// Discovers the current account's communities from the local database.
pub async fn load(
client: &Client,
signer: &UniversalSigner,
self_pk: PublicKey,
) -> Result<Vec<CommunityState>> {
let filter = Filter::new().kind(Kind::ApplicationSpecificData);
let mut newest: BTreeMap<CommunityId, Event> = BTreeMap::new();
for event in client.database().query(filter).await? {
let Some(id) = state_document_of(&event) else {
continue;
};
match newest.get(&id) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(id, event);
}
}
}
let mut states = Vec::with_capacity(newest.len());
for event in newest.into_values() {
match serde_json::from_str::<CommunityState>(&event.content) {
Ok(state) => states.push(state),
Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id),
}
}
if let Some(list) = load_list(client, signer, self_pk).await? {
states.retain(|state| list.is_live(&state.id));
}
Ok(states)
}
fn state_document_of(event: &Event) -> Option<CommunityId> {
let identifier = event.tags.identifier()?;
let hex = identifier.strip_prefix(STATE_PREFIX)?;
hex.parse().ok()
}
async fn load_list(
client: &Client,
signer: &UniversalSigner,
self_pk: PublicKey,
) -> Result<Option<CommunityList>> {
let filter = Filter::new()
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
.author(self_pk)
.limit(1);
let Some(event) = client.database().query(filter).await?.into_iter().next() else {
return Ok(None);
};
let json = signer.nip44_decrypt_async(&self_pk, &event.content).await?;
Ok(Some(serde_json::from_str(&json)?))
}
/// Rebuilds a community from the wraps already in the local database.
pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snapshot>> {
let planes = planes(state)?;
if planes.is_empty() {
return Ok(None);
}
let wraps = client
.database()
.query(subscription_filter(&planes))
.await?;
let mut editions = Vec::new();
let mut observed: BTreeMap<PublicKey, u64> = BTreeMap::new();
let mut guestbook_rumors = Vec::new();
for wrap in &wraps {
let Some(plane) = planes.iter().find(|plane| plane.address == wrap.pubkey) else {
continue;
};
match plane.kind {
PlaneKind::Control(_) => {
if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true)
{
editions.push(edition);
}
}
PlaneKind::Guestbook => {
if let Ok((_, rumor)) = cord02::guestbook::open(wrap, &plane.group) {
observe(&mut observed, rumor.author, rumor.at_ms);
guestbook_rumors.push(rumor);
}
}
PlaneKind::Channel(channel, epoch) => {
if let Ok((opened, rumor)) =
concord::cord03::open(wrap, &plane.group, &channel, epoch)
{
store::cache_rumor(client, &channel, &opened).await?;
observe(&mut observed, rumor.author, rumor.at_ms);
}
}
}
}
if editions.is_empty() {
return Ok(None);
}
let control = cord02::fold_control(
&state.owner,
&state.id,
&editions,
&state.floors(),
&state.banned,
);
let granted: BTreeSet<PublicKey> = control
.roles
.grants()
.filter(|grant| !grant.role_ids.is_empty())
.map(|grant| grant.member)
.collect();
let floors = state.floors();
let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| {
citation_ok(&state.owner, &state.id, actor, citation, &floors)
&& control
.roles
.can_act_on_member(actor, &state.owner, target, Permissions::KICK)
};
let now_ms = Timestamp::now().as_secs().saturating_mul(1000);
let coalesced = cord02::guestbook::coalesce(&guestbook_rumors, now_ms, None, can_kick);
let mut members = cord02::guestbook::complete_memberlist(
&coalesced,
&observed,
&granted,
&control.banned,
&BTreeMap::new(),
);
// The roster has no grant for the owner, so membership is stated here.
members.insert(state.owner);
let mut state = state.clone();
state.apply_fold(&control);
store::save_state(client, &state).await?;
Ok(Some(Snapshot {
state,
control,
members,
}))
}
fn observe(observed: &mut BTreeMap<PublicKey, u64>, author: PublicKey, at_ms: u64) {
observed
.entry(author)
.and_modify(|seen| *seen = (*seen).max(at_ms))
.or_insert(at_ms);
}
+1 -64
View File
@@ -731,15 +731,12 @@ fn seal_edition(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use nostr_memory::MemoryDatabase;
use super::*; use super::*;
use crate::cord03::{self, build_message, seal_rumor}; use crate::cord03::{self, build_message, seal_rumor};
use crate::cord04::fold;
use crate::cord04::pins; use crate::cord04::pins;
use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope}; use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
use crate::derive::{channel_group_key, grant_locator}; use crate::derive::{channel_group_key, grant_locator};
use crate::store::{CommunityState, load_state, save_state}; use crate::store::CommunityState;
use crate::{Extra, RoleId}; use crate::{Extra, RoleId};
const AT: u64 = 1_700_000_000; const AT: u64 = 1_700_000_000;
@@ -768,66 +765,6 @@ mod tests {
} }
} }
#[test]
fn genesis_reopens_for_a_second_holder() {
let owner = Keys::generate();
let community_metadata = CommunityMetadata {
name: "coop".to_owned(),
relays: vec!["wss://relay.example".to_owned()],
..CommunityMetadata::default()
};
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
assert!(minted.identity.verify(), "identity is self-certifying");
// Only what an invite hands over: the roots, the community id and the owner salt.
let (read, signer) = holder(&minted);
let editions = open_all(&minted.wraps, &read, &signer.pk());
assert_eq!(editions.len(), 2);
let community = &editions[0];
assert_eq!(community.subkind, vsk::COMMUNITY_METADATA);
assert_eq!(community.entity, *minted.identity.community_id.as_bytes());
assert_eq!(community.author, owner.public_key());
assert_eq!((community.version, community.prev), (1, None));
assert_eq!(
serde_json::from_str::<CommunityMetadata>(&community.content)
.expect("parses")
.name,
"coop"
);
let channel = &editions[1];
assert_eq!(channel.subkind, vsk::CHANNEL_METADATA);
assert_eq!(channel.entity, *minted.channel_id.as_bytes());
for edition in &editions {
let folded = fold(&[EditionMeta::from(edition)], 0, None);
assert_eq!(folded.head, Some(0));
assert!(
folded.anchored && !folded.gap,
"genesis anchors at its floor"
);
}
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
smol::block_on(async {
let database = MemoryDatabase::unbounded();
save_state(&database, &state).await.expect("saves");
let loaded = load_state(&database, &minted.identity.community_id)
.await
.expect("loads")
.expect("present");
assert_eq!(loaded.community_root, minted.community_root);
assert_eq!(loaded.control_root, Some(minted.control_root));
assert_eq!(loaded.channels.len(), 1);
assert_eq!(loaded.heads.len(), 2);
});
}
#[test] #[test]
fn metadata_and_channel_edits_reach_a_second_client() { fn metadata_and_channel_edits_reach_a_second_client() {
let owner = Keys::generate(); let owner = Keys::generate();
+12 -169
View File
@@ -27,10 +27,12 @@ const STATE_PREFIX: &str = "concord/";
/// An already-expired rumor is refused at ingest. Returns whether it was kept. /// An already-expired rumor is refused at ingest. Returns whether it was kept.
pub async fn cache_rumor( pub async fn cache_rumor(
database: &dyn NostrDatabase, client: &Client,
channel: &ChannelId, channel: &ChannelId,
opened: &OpenedStream, opened: &OpenedStream,
) -> Result<bool> { ) -> Result<bool> {
let at = Timestamp::from_secs(opened.at_ms / 1000);
if cord03::expiration_of(&opened.rumor)? if cord03::expiration_of(&opened.rumor)?
.is_some_and(|expiration| expiration <= Timestamp::now()) .is_some_and(|expiration| expiration <= Timestamp::now())
{ {
@@ -45,23 +47,19 @@ pub async fn cache_rumor(
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]), Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
Tag::public_key(opened.author), Tag::public_key(opened.author),
]; ];
let at = Timestamp::from_secs(opened.at_ms / 1000);
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json()) let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
.tags(tags) .tags(tags)
.custom_created_at(at) .custom_created_at(at)
.finalize_async(&*LOCAL_KEYS) .finalize_async(&*LOCAL_KEYS)
.await?; .await?;
database.save_event(&event).await?; client.database().save_event(&event).await?;
Ok(true) Ok(true)
} }
pub async fn purge_expired( pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result<usize> {
database: &dyn NostrDatabase,
channel: &ChannelId,
now: Timestamp,
) -> Result<usize> {
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE) .custom_tag(MARK_TAG, MARK_VALUE)
@@ -69,7 +67,7 @@ pub async fn purge_expired(
let mut expired = Vec::new(); let mut expired = Vec::new();
for event in database.query(filter).await? { for event in client.database().query(filter).await? {
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else { let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
continue; continue;
}; };
@@ -86,7 +84,7 @@ pub async fn purge_expired(
let purged = expired.len(); let purged = expired.len();
if purged > 0 { if purged > 0 {
database.delete(Filter::new().ids(expired)).await?; client.database().delete(Filter::new().ids(expired)).await?;
} }
Ok(purged) Ok(purged)
@@ -288,23 +286,20 @@ fn state_identifier(id: &CommunityId) -> String {
format!("{STATE_PREFIX}{}", id.to_hex()) format!("{STATE_PREFIX}{}", id.to_hex())
} }
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()> pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
where
D: NostrDatabase,
{
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(state.identifier())]) .tags([Tag::identifier(state.identifier())])
.finalize_async(&*LOCAL_KEYS) .finalize_async(&*LOCAL_KEYS)
.await?; .await?;
database.save_event(&event).await?; client.database().save_event(&event).await?;
Ok(()) Ok(())
} }
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>> pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
where where
D: NostrDatabase, D: NostrDatabase + ?Sized,
{ {
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
@@ -319,7 +314,6 @@ where
pub async fn backfill( pub async fn backfill(
client: &Client, client: &Client,
database: &dyn NostrDatabase,
channel: &ChannelId, channel: &ChannelId,
held: &[(Epoch, [u8; 32])], held: &[(Epoch, [u8; 32])],
until: Option<Timestamp>, until: Option<Timestamp>,
@@ -342,7 +336,7 @@ pub async fn backfill(
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen); let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
for (opened, rumor) in fresh { for (opened, rumor) in fresh {
if cache_rumor(database, channel, &opened).await? { if cache_rumor(client, channel, &opened).await? {
found.push(rumor); found.push(rumor);
} }
} }
@@ -416,13 +410,8 @@ async fn fetch_page(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use nostr_memory::MemoryDatabase;
use super::*; use super::*;
use crate::Epoch; use crate::Epoch;
use crate::cord01::{
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
};
use crate::cord03::{build_message, seal_rumor}; use crate::cord03::{build_message, seal_rumor};
use crate::derive::channel_group_key; use crate::derive::channel_group_key;
@@ -499,150 +488,4 @@ mod tests {
["after the rekey", "still before", "before the rekey"] ["after the rekey", "still before", "before the rekey"]
); );
} }
#[test]
fn rumors_read_back_after_a_restart() {
let database = MemoryDatabase::unbounded();
let channel = ChannelId::from_bytes([0xabu8; 32]);
let author = Keys::generate();
smol::block_on(async {
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] {
let rumor = build_rumor_ms(
9,
author.public_key(),
content,
channel_binding_tags(&channel, Epoch(0)),
at_ms,
);
let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals");
let (wrap, _) = wrap_seal(
&seal,
&group,
KIND_WRAP,
Timestamp::from_secs(at_ms / 1000),
&[],
)
.expect("wraps");
let opened = open_wrap(&wrap, &group).expect("opens");
cache_rumor(&database, &channel, &opened)
.await
.expect("caches");
}
// The group key is gone; only the local cache stands in for it.
let rumors = query_rumors(&database, &channel, None, 10)
.await
.expect("queries");
assert_eq!(rumors.len(), 2, "both messages come back");
assert_eq!(rumors[0].content, "second", "newest first");
assert_eq!(rumors[1].content, "first");
// A page boundary in message time, not in cache time.
let until = Timestamp::from_secs(1_500);
let page = query_rumors(&database, &channel, Some(until), 10)
.await
.expect("queries");
assert_eq!(page.len(), 1);
assert_eq!(page[0].content, "first");
let capped = query_rumors(&database, &channel, None, 1)
.await
.expect("queries");
assert_eq!(capped.len(), 1);
assert_eq!(capped[0].content, "second");
});
}
#[test]
fn an_expired_rumor_is_refused_at_ingest_and_purged_by_the_sweep() {
let database = MemoryDatabase::unbounded();
let channel = ChannelId::from_bytes([0x77u8; 32]);
let author = Keys::generate();
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
let now = Timestamp::now().as_secs();
smol::block_on(async {
// A live timer is stored; one that already elapsed is refused at ingest.
assert!(
cache(
&database,
&group,
&channel,
&author,
"live",
Some(3_600),
now
)
.await
);
assert!(
!cache(
&database,
&group,
&channel,
&author,
"gone",
Some(1),
now - 120
)
.await
);
let stored = query_rumors(&database, &channel, None, 10)
.await
.expect("queries");
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].content, "live");
// Hiding is not disappearing: the sweep removes the row itself,
// judged on the rumor's own signed tag.
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 7_200))
.await
.expect("sweeps");
assert_eq!(purged, 1);
assert!(
query_rumors(&database, &channel, None, 10)
.await
.expect("queries")
.is_empty()
);
// An untimed rumor is never swept, whatever the clock says.
assert!(cache(&database, &group, &channel, &author, "timeless", None, now).await);
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 86_400))
.await
.expect("sweeps");
assert_eq!(purged, 0);
});
}
async fn cache(
database: &MemoryDatabase,
group: &GroupKey,
channel: &ChannelId,
author: &Keys,
content: &str,
timer: Option<u64>,
at_secs: u64,
) -> bool {
let rumor = build_message(
author.public_key(),
channel,
Epoch(0),
content,
None,
at_secs * 1_000,
timer,
);
let (wrap, _) = seal_rumor(&rumor, group, author, false).expect("seals");
let opened = open_wrap(&wrap, group).expect("opens");
cache_rumor(database, channel, &opened)
.await
.expect("caches")
}
} }
+30 -12
View File
@@ -24,8 +24,8 @@ use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
const IDENTIFIER: &str = "coop:device"; const IDENTIFIER: &str = "coop:device";
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx); DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
} }
struct GlobalDeviceRegistry(Entity<DeviceRegistry>); struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
@@ -89,7 +89,8 @@ impl DeviceRegistry {
} }
/// Create a new device registry instance /// Create a new device registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx); let settings = AppSettings::global(cx);
@@ -114,8 +115,10 @@ impl DeviceRegistry {
}), }),
); );
cx.defer_in(window, |this, window, cx| { cx.defer(move |cx| {
this.handle_notifications(window, cx); entity
.update(cx, |this, cx| this.handle_notifications(cx))
.ok();
}); });
Self { Self {
@@ -127,7 +130,7 @@ impl DeviceRegistry {
} }
} }
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn handle_notifications(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
@@ -168,18 +171,18 @@ impl DeviceRegistry {
Ok(()) Ok(())
})); }));
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await { while let Ok(event) = rx.recv_async().await {
match event.kind { match event.kind {
Kind::Custom(10044) => { Kind::Custom(10044) => {
this.update_in(cx, |this, _window, cx| { this.update(cx, |this, cx| {
this.set_encryption(&event, cx); this.set_encryption(&event, cx);
})?; })?;
} }
// New request event from other device // New request event from other device
Kind::Custom(4454) => { Kind::Custom(4454) => {
this.update_in(cx, |this, window, cx| { this.update(cx, |this, cx| {
this.ask_for_approval(event, window, cx); this.ask_for_approval(event, cx);
})?; })?;
} }
// New response event from the master device // New response event from the master device
@@ -591,7 +594,7 @@ impl DeviceRegistry {
} }
/// Handle encryption request /// Handle encryption request
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) { fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
// Ignore if there is already a pending request // Ignore if there is already a pending request
if self.pending_request { if self.pending_request {
return; return;
@@ -600,7 +603,18 @@ impl DeviceRegistry {
// Show notification // Show notification
let notification = self.notification(event, cx); let notification = self.notification(event, cx);
// The registry is global and not bound to a window, so surface the
// request in an open window.
if let Some(window) = cx.windows().first().copied() {
if let Err(error) = window.update(cx, |_view, window, cx| {
window.push_notification(notification, cx); window.push_notification(notification, cx);
}) {
log::warn!("Failed to show encryption key request: {error}");
}
} else {
log::warn!("Failed to show encryption key request: no open window");
}
} }
/// Build a notification for the encryption request. /// Build a notification for the encryption request.
@@ -655,7 +669,11 @@ impl DeviceRegistry {
.child( .child(
h_flex() h_flex()
.gap_2() .gap_2()
.child(Avatar::new(profile.avatar()).xsmall()) .child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.xsmall(),
)
.child(profile.name()), .child(profile.name()),
), ),
), ),
+7 -6
View File
@@ -3,7 +3,7 @@ use std::sync::RwLock;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use common::EventExt; use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window}; use gpui::{App, AppContext, Context, Entity, Global, Task};
use instant::Duration; use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
@@ -13,8 +13,8 @@ mod person;
pub use person::*; pub use person::*;
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx); PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
} }
struct GlobalPersonRegistry(Entity<PersonRegistry>); struct GlobalPersonRegistry(Entity<PersonRegistry>);
@@ -56,7 +56,8 @@ impl PersonRegistry {
} }
/// Create a new person registry instance /// Create a new person registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
@@ -96,8 +97,8 @@ impl PersonRegistry {
})); }));
// Load all user profiles from the database // Load all user profiles from the database
cx.defer_in(window, |this, _window, cx| { cx.defer(move |cx| {
this.load(cx); entity.update(cx, |this, cx| this.load(cx)).ok();
}); });
Self { Self {
+8 -4
View File
@@ -103,14 +103,18 @@ impl Person {
self.messaging_relays.first().cloned() self.messaging_relays.first().cloned()
} }
/// Get profile avatar /// Get profile picture, if the profile has one
pub fn avatar(&self) -> SharedString { pub fn avatar(&self) -> Option<SharedString> {
self.metadata() self.metadata()
.picture .picture
.as_ref() .as_ref()
.filter(|picture| !picture.is_empty()) .filter(|picture| !picture.is_empty())
.map(|picture| picture.into()) .map(SharedString::from)
.unwrap_or_else(|| "brand/avatar.png".into()) }
/// A stable seed for this profile's generated avatar
pub fn avatar_seed(&self) -> SharedString {
SharedString::from(self.public_key().to_hex())
} }
/// Get profile name /// Get profile name
+36 -15
View File
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use theme::{Theme, ThemeFamily, ThemeMode}; use theme::{Theme, ThemeFamily, ThemeMode};
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx) AppSettings::set_global(cx.new(AppSettings::new), cx)
} }
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/"; const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
@@ -46,6 +46,8 @@ setting_accessors! {
pub nip4e: bool, pub nip4e: bool,
pub trusted_relays: Vec<String>, pub trusted_relays: Vec<String>,
pub file_server: Url, pub file_server: Url,
pub pinned_rooms: Vec<u64>,
pub expanded_sections: Option<Vec<String>>,
} }
/// Signer kind /// Signer kind
@@ -130,6 +132,14 @@ pub struct Settings {
/// Server for blossom media attachments /// Server for blossom media attachments
pub file_server: Url, pub file_server: Url,
/// Pinned sidebar room ids, in pin order
#[serde(default)]
pub pinned_rooms: Vec<u64>,
/// Expanded sidebar tree sections; `None` means the default sections
#[serde(default)]
pub expanded_sections: Option<Vec<String>>,
} }
impl Default for Settings { impl Default for Settings {
@@ -142,6 +152,8 @@ impl Default for Settings {
nip4e: false, nip4e: false,
trusted_relays: vec![], trusted_relays: vec![],
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
pinned_rooms: vec![],
expanded_sections: None,
} }
} }
} }
@@ -171,12 +183,20 @@ impl AppSettings {
cx.global::<GlobalAppSettings>().0.clone() cx.global::<GlobalAppSettings>().0.clone()
} }
/// The underlying settings entity, which notifies whenever any field changes.
/// Settings load asynchronously, so observers can watch it to pick up values
/// that arrive after construction.
pub fn entity(&self) -> &Entity<Settings> {
&self.inner
}
/// Set the global settings instance /// Set the global settings instance
fn set_global(state: Entity<Self>, cx: &mut App) { fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAppSettings(state)); cx.set_global(GlobalAppSettings(state));
} }
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let inner = cx.new(|_| Settings::default()); let inner = cx.new(|_| Settings::default());
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
@@ -188,8 +208,8 @@ impl AppSettings {
); );
// Run at the end of current cycle // Run at the end of current cycle
cx.defer_in(window, |this, window, cx| { cx.defer(move |cx| {
this.load(window, cx); entity.update(cx, |this, cx| this.load(cx)).ok();
}); });
Self { Self {
@@ -207,7 +227,7 @@ impl AppSettings {
} }
/// Load settings /// Load settings
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move { let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
@@ -219,7 +239,7 @@ impl AppSettings {
Err(anyhow!("Not found")) Err(anyhow!("Not found"))
}); });
cx.spawn_in(window, async move |this, cx| { cx.spawn(async move |this, cx| {
let mut settings = task.await.unwrap_or(Settings::default()); let mut settings = task.await.unwrap_or(Settings::default());
// Move settings still pointed at the old default file server over to the new one // Move settings still pointed at the old default file server over to the new one
@@ -228,9 +248,10 @@ impl AppSettings {
} }
// Update settings // Update settings
this.update_in(cx, |this, window, cx| { this.update(cx, |this, cx| {
this.set_settings(settings, cx); this.set_settings(settings, cx);
this.apply_theme(window, cx); this.apply_theme(None, cx);
cx.refresh_windows();
}) })
.ok(); .ok();
}) })
@@ -262,7 +283,7 @@ impl AppSettings {
}); });
// Apply the new theme // Apply the new theme
self.apply_theme(window, cx); self.apply_theme(Some(window), cx);
} }
/// Reset theme /// Reset theme
@@ -271,22 +292,22 @@ impl AppSettings {
this.theme = None; this.theme = None;
cx.notify(); cx.notify();
}); });
self.apply_theme(window, cx); self.apply_theme(Some(window), cx);
} }
/// Apply theme /// Apply theme
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn apply_theme(&mut self, mut window: Option<&mut Window>, cx: &mut Context<Self>) {
if let Some(name) = self.inner.read(cx).theme.as_ref() { if let Some(name) = self.inner.read(cx).theme.as_ref() {
let mode = self.inner.read(cx).theme_mode; let mode = self.inner.read(cx).theme_mode;
if let Ok(new_theme) = ThemeFamily::from_assets(name) { if let Ok(new_theme) = ThemeFamily::from_assets(name) {
Theme::apply_theme(Rc::new(new_theme), Some(window), cx); Theme::apply_theme(Rc::new(new_theme), window.as_deref_mut(), cx);
Theme::change(mode, Some(window), cx); Theme::change(mode, window, cx);
} else { } else {
log::info!("Failed to load theme: {name}"); log::info!("Failed to load theme: {name}");
} }
} else { } else {
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx); Theme::apply_theme(Rc::new(ThemeFamily::default()), window, cx);
} }
} }
+11 -6
View File
@@ -4,7 +4,7 @@ use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use browser_signer_proxy::prelude::*; use browser_signer_proxy::prelude::*;
use common::config_dir; use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
use gpui_tokio::Tokio; use gpui_tokio::Tokio;
use instant::Duration; use instant::Duration;
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
@@ -29,7 +29,7 @@ pub use nip4e::*;
pub use nip05::*; pub use nip05::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner}; pub use signer::{CoopAuthUrlHandler, UniversalSigner};
pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) { pub fn init(cx: &mut App, cli_key: Option<SecretKey>) {
// rustls uses the `aws_lc_rs` provider by default // rustls uses the `aws_lc_rs` provider by default
// This only errors if the default provider has already // This only errors if the default provider has already
// been installed. We can ignore this `Result`. // been installed. We can ignore this `Result`.
@@ -42,7 +42,7 @@ pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
gpui_tokio::init(cx); gpui_tokio::init(cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx); NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(cx, cli_key)), cx);
} }
struct GlobalNostrRegistry(Entity<NostrRegistry>); struct GlobalNostrRegistry(Entity<NostrRegistry>);
@@ -105,7 +105,8 @@ impl NostrRegistry {
} }
/// Create a new nostr instance /// Create a new nostr instance
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self { fn new(cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
let entity = cx.entity().downgrade();
let signer = UniversalSigner::new(Keys::generate()); let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone()); let authenticator = SignerAuthenticator::new(signer.clone());
@@ -132,8 +133,10 @@ impl NostrRegistry {
}) })
.build(); .build();
// Connect to bootstrap relays after the window is ready // Connect to bootstrap relays once the registry has been returned to the app
cx.defer_in(window, |this, _window, cx| { cx.defer(move |cx| {
entity
.update(cx, |this, cx| {
this.connect_bootstrap_relays(cx); this.connect_bootstrap_relays(cx);
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
@@ -145,6 +148,8 @@ impl NostrRegistry {
} else { } else {
this.get_user_credential(cx); this.get_user_credential(cx);
} }
})
.ok();
}); });
Self { Self {
+464 -29
View File
@@ -1,12 +1,25 @@
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity, AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, InteractiveElement, Interactivity,
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, SharedString,
Window, div, img, px, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::{Selectable, Sizable, Size}; use crate::{Selectable, Sizable, Size, StyledExt};
/// Number of rows and columns in the generated pixel grid.
const PIXEL_GRID: usize = 8;
/// Probability that a cell in the left half of the grid is filled.
const FILL_PROBABILITY: f32 = 0.42;
/// Probability that a filled cell uses the accent shade instead of the main color.
const ACCENT_PROBABILITY: f32 = 0.25;
/// Minimum number of filled left-half cells, so a pattern never reads as empty.
const MIN_FILLED: usize = 5;
/// Fallback seed for an avatar that has neither a picture nor a seed of its own.
const FALLBACK_SEED: &str = "coop";
/// Number of segments used to approximate the avatar circle.
const CIRCLE_SEGMENTS: usize = 32;
/// Returns the size of the avatar based on the given [`Size`]. /// Returns the size of the avatar based on the given [`Size`].
pub(super) fn avatar_size(size: Size) -> AbsoluteLength { pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
@@ -19,19 +32,350 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
} }
} }
/// An element that renders a user avatar with customizable appearance options. /// A deterministic, offline pixel-art avatar derived from a seed.
///
/// Use it for entities that have no profile picture: the same seed always
/// renders the same pattern, so identities stay recognizable without a
/// network round trip. The pattern is painted as geometry and cropped to a
/// circle, at the same sizes as [`Avatar`].
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use ui::{Avatar}; /// use ui::avatar::PixelAvatar;
/// ///
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red()); /// PixelAvatar::new("alice");
/// ```
#[derive(IntoElement)]
pub struct PixelAvatar {
seed: u64,
size: Size,
style: StyleRefinement,
}
impl PixelAvatar {
/// Creates a pixel avatar from `seed`.
pub fn new(seed: impl AsRef<str>) -> Self {
Self {
seed: fnv1a(seed.as_ref().as_bytes()),
size: Size::Medium,
style: StyleRefinement::default(),
}
}
}
impl Sizable for PixelAvatar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Styled for PixelAvatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for PixelAvatar {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let side = avatar_size(self.size).to_pixels(window.rem_size());
let seed = self.seed;
canvas(
move |_bounds, _window, _cx| seed,
move |bounds, seed, window, cx| {
let theme = cx.theme();
let main = Hsla {
h: (theme.icon_accent.h + seed as f32 / u64::MAX as f32) % 1.,
s: 0.6,
l: if theme.is_dark() { 0.6 } else { 0.45 },
a: 1.,
};
let shade = if theme.is_dark() {
Hsla {
l: (main.l * 1.6).min(0.95),
..main
}
} else {
Hsla {
l: (main.l * 0.45).max(0.18),
..main
}
};
let circle = circle_polygon(bounds.center(), bounds.size.width.as_f32() / 2.);
paint_polygons(window, std::iter::once(&circle), main.opacity(0.16));
let pattern = pixel_pattern(seed);
let mut cells = Vec::new();
for (value, color) in [(1u8, main), (2u8, shade)] {
cells.clear();
for row in 0..PIXEL_GRID {
for col in 0..PIXEL_GRID {
if pattern[row * PIXEL_GRID + col] != value {
continue;
}
let cell = clip_polygon(&cell_polygon(&bounds, row, col), &circle);
if cell.len() >= 3 {
cells.push(cell);
}
}
}
paint_polygons(window, cells.iter(), color);
}
},
)
.refine_style(&self.style)
.size(side)
.flex_shrink_0()
}
}
/// Builds the mirrored fill pattern for `seed`.
fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
let mut rng = PixelRng::new(seed);
let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID];
let mut filled = 0usize;
for row in 0..PIXEL_GRID {
for col in 0..PIXEL_GRID / 2 {
if rng.chance(FILL_PROBABILITY) {
let accent = rng.chance(ACCENT_PROBABILITY);
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
filled += 1;
}
}
}
if filled < MIN_FILLED {
let half = PIXEL_GRID * PIXEL_GRID / 2;
let start = (rng.next() % half as u64) as usize;
for offset in 0..half {
if filled >= MIN_FILLED {
break;
}
let ix = (start + offset) % half;
let row = ix / (PIXEL_GRID / 2);
let col = ix % (PIXEL_GRID / 2);
if pattern[row * PIXEL_GRID + col] == 0 {
set_cell(&mut pattern, row, col, 1);
filled += 1;
}
}
}
pattern
}
/// Paints `polygons` as a single anti-aliased filled path in `color`.
fn paint_polygons<'a>(
window: &mut Window,
polygons: impl IntoIterator<Item = &'a Vec<Point<Pixels>>>,
color: Hsla,
) {
let mut builder = PathBuilder::fill();
let mut painted = false;
for polygon in polygons {
if polygon.len() >= 3 {
builder.add_polygon(polygon, true);
painted = true;
}
}
if painted && let Ok(path) = builder.build() {
window.paint_path(path, color);
}
}
/// Approximates the circle of `radius` around `center` as a convex polygon,
/// wound so that its interior is on the left of every directed edge.
fn circle_polygon(center: Point<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
let center_x = center.x.as_f32();
let center_y = center.y.as_f32();
(0..CIRCLE_SEGMENTS)
.map(|index| {
let angle = std::f32::consts::TAU * index as f32 / CIRCLE_SEGMENTS as f32;
point(
px(center_x + radius * angle.cos()),
px(center_y + radius * angle.sin()),
)
})
.collect()
}
/// The four corners of cell `(row, col)` of the grid laid out in `bounds`.
fn cell_polygon(bounds: &Bounds<Pixels>, row: usize, col: usize) -> [Point<Pixels>; 4] {
let cell = bounds.size.width.as_f32() / PIXEL_GRID as f32;
let left = bounds.origin.x.as_f32() + col as f32 * cell;
let top = bounds.origin.y.as_f32() + row as f32 * cell;
[
point(px(left), px(top)),
point(px(left + cell), px(top)),
point(px(left + cell), px(top + cell)),
point(px(left), px(top + cell)),
]
}
/// Clips `subject` to the convex `clip` polygon, keeping the part inside it.
fn clip_polygon(subject: &[Point<Pixels>], clip: &[Point<Pixels>]) -> Vec<Point<Pixels>> {
let mut current = subject.to_vec();
let mut next = Vec::with_capacity(subject.len() + 4);
for (&start, &end) in clip.iter().zip(clip.iter().cycle().skip(1)) {
if current.is_empty() {
break;
}
next.clear();
let mut previous = match current.last() {
Some(&vertex) => vertex,
None => break,
};
for &vertex in current.iter() {
let previous_inside = is_inside(start, end, previous);
let vertex_inside = is_inside(start, end, vertex);
if vertex_inside {
if !previous_inside
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
{
next.push(crossing);
}
next.push(vertex);
} else if previous_inside
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
{
next.push(crossing);
}
previous = vertex;
}
std::mem::swap(&mut current, &mut next);
}
current
}
/// Whether `vertex` lies on the interior side of the directed edge `start -> end`.
fn is_inside(start: Point<Pixels>, end: Point<Pixels>, vertex: Point<Pixels>) -> bool {
let start_x = start.x.as_f32();
let start_y = start.y.as_f32();
let edge_x = end.x.as_f32() - start_x;
let edge_y = end.y.as_f32() - start_y;
let to_vertex_x = vertex.x.as_f32() - start_x;
let to_vertex_y = vertex.y.as_f32() - start_y;
edge_x * to_vertex_y - edge_y * to_vertex_x >= 0.
}
/// The intersection of segment `from -> to` with the infinite line `start -> end`.
fn line_intersection(
start: Point<Pixels>,
end: Point<Pixels>,
from: Point<Pixels>,
to: Point<Pixels>,
) -> Option<Point<Pixels>> {
let start_x = start.x.as_f32();
let start_y = start.y.as_f32();
let edge_x = end.x.as_f32() - start_x;
let edge_y = end.y.as_f32() - start_y;
let from_x = from.x.as_f32();
let from_y = from.y.as_f32();
let segment_x = to.x.as_f32() - from_x;
let segment_y = to.y.as_f32() - from_y;
let denominator = edge_x * segment_y - edge_y * segment_x;
if denominator.abs() < f32::EPSILON {
return None;
}
let offset_x = from_x - start_x;
let offset_y = from_y - start_y;
let t = (edge_y * offset_x - edge_x * offset_y) / denominator;
Some(point(
px(from_x + segment_x * t),
px(from_y + segment_y * t),
))
}
/// Fills `cell (row, col)` and its horizontal mirror.
fn set_cell(pattern: &mut [u8; PIXEL_GRID * PIXEL_GRID], row: usize, col: usize, value: u8) {
pattern[row * PIXEL_GRID + col] = value;
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)] = value;
}
/// FNV-1a 64-bit hash, stable across platforms and runs.
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for &byte in bytes {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
struct PixelRng(u64);
impl PixelRng {
fn new(seed: u64) -> Self {
Self(seed.max(1))
}
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
fn chance(&mut self, probability: f32) -> bool {
self.next() as f32 / (u64::MAX as f32) < probability
}
}
/// Renders the generated pixel avatar shown in place of a missing picture.
fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
PixelAvatar::new(seed.unwrap_or(FALLBACK_SEED))
.with_size(size)
.into_any_element()
}
/// An element that renders a user avatar with customizable appearance options.
///
/// Entities without a picture still get a stable identity: the avatar falls
/// back to a [`PixelAvatar`] seeded through [`Avatar::seed`], both when there
/// is no picture and when the picture fails to load.
///
/// # Examples
///
/// ```
/// use ui::avatar::Avatar;
///
/// Avatar::new(None).seed("alice");
/// ``` /// ```
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct Avatar { pub struct Avatar {
base: Div, base: Div,
image: Img, picture: Option<SharedString>,
grayscale: bool,
seed: Option<SharedString>,
style: StyleRefinement, style: StyleRefinement,
size: Size, size: Size,
border_color: Option<Hsla>, border_color: Option<Hsla>,
@@ -39,11 +383,16 @@ pub struct Avatar {
} }
impl Avatar { impl Avatar {
/// Creates a new avatar element with the specified image source. /// Creates an avatar for an entity whose profile picture may be missing.
pub fn new(src: impl Into<ImageSource>) -> Self { ///
/// Use [`Avatar::seed`] to choose the generated pixel avatar rendered when
/// `picture` is `None`.
pub fn new(picture: Option<SharedString>) -> Self {
Avatar { Avatar {
base: div(), base: div(),
image: img(src), picture,
grayscale: false,
seed: None,
style: StyleRefinement::default(), style: StyleRefinement::default(),
size: Size::Medium, size: Size::Medium,
border_color: None, border_color: None,
@@ -51,17 +400,26 @@ impl Avatar {
} }
} }
/// Sets the seed for the generated pixel avatar.
///
/// The seed should be a stable identifier of the entity the avatar
/// represents, such as a public key.
pub fn seed(mut self, seed: impl Into<SharedString>) -> Self {
self.seed = Some(seed.into());
self
}
/// Applies a grayscale filter to the avatar image. /// Applies a grayscale filter to the avatar image.
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use ui::{Avatar, AvatarShape}; /// use ui::avatar::Avatar;
/// ///
/// let avatar = Avatar::new("path/to/image.png").grayscale(true); /// Avatar::new(None).grayscale(true);
/// ``` /// ```
pub fn grayscale(mut self, grayscale: bool) -> Self { pub fn grayscale(mut self, grayscale: bool) -> Self {
self.image = self.image.grayscale(grayscale); self.grayscale = grayscale;
self self
} }
@@ -113,8 +471,24 @@ impl RenderOnce for Avatar {
} else { } else {
px(0.) px(0.)
}; };
let image_size = avatar_size(self.size); let image_size = avatar_size(self.size).to_pixels(window.rem_size());
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.; let container_size = image_size + border_width * 2.;
let content = match self.picture {
Some(picture) => {
let seed = self.seed;
let grayscale = self.grayscale;
img(picture)
.size(image_size)
.rounded_full()
.object_fit(ObjectFit::Cover)
.grayscale(grayscale)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
.into_any_element()
}
None => generated_avatar(self.seed.as_deref(), image_size),
};
div() div()
.flex_shrink_0() .flex_shrink_0()
@@ -124,18 +498,79 @@ impl RenderOnce for Avatar {
.when_some(self.border_color, |this, color| { .when_some(self.border_color, |this, color| {
this.border(border_width).border_color(color) this.border(border_width).border_color(color)
}) })
.child( .child(content)
self.image }
.size(image_size) }
.rounded_full()
.object_fit(ObjectFit::Cover) #[cfg(test)]
.bg(cx.theme().ghost_element_background) mod tests {
.with_fallback(move || { use super::*;
img("brand/avatar.png")
.size(image_size) #[test]
.rounded_full() fn pixel_patterns_are_symmetric_and_stable() {
.into_any_element() for seed in 0..50 {
}), let pattern = pixel_pattern(seed);
) let filled = pattern.iter().filter(|&&cell| cell != 0).count();
assert!(
filled >= MIN_FILLED * 2,
"pattern too sparse for seed {seed}"
);
for row in 0..PIXEL_GRID {
for col in 0..PIXEL_GRID {
assert_eq!(
pattern[row * PIXEL_GRID + col],
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)],
"asymmetric pattern for seed {seed} at ({row}, {col})"
);
}
}
}
for seed in [0, 1, 42, u64::MAX] {
assert_eq!(pixel_pattern(seed), pixel_pattern(seed));
}
assert_ne!(pixel_pattern(42), pixel_pattern(43));
}
fn area(polygon: &[Point<Pixels>]) -> f32 {
let mut sum: f32 = 0.;
for (&a, &b) in polygon.iter().zip(polygon.iter().cycle().skip(1)) {
sum += a.x.as_f32() * b.y.as_f32() - b.x.as_f32() * a.y.as_f32();
}
(sum / 2.).abs()
}
#[test]
fn clipping_keeps_only_the_part_inside_the_circle() {
let circle = circle_polygon(point(px(10.), px(10.)), 10.);
let square = |left: f32, top: f32| {
[
point(px(left), px(top)),
point(px(left + 4.), px(top)),
point(px(left + 4.), px(top + 4.)),
point(px(left), px(top + 4.)),
]
};
let inside = clip_polygon(&square(8., 8.), &circle);
assert!((area(&inside) - 16.).abs() < 0.05, "area {}", area(&inside));
assert!(clip_polygon(&square(20., 20.), &circle).is_empty());
let straddling = clip_polygon(&square(0., 0.), &circle);
for vertex in &straddling {
let delta_x = vertex.x.as_f32() - 10.;
let delta_y = vertex.y.as_f32() - 10.;
assert!(
delta_x.hypot(delta_y) <= 10. + 0.1,
"clipped vertex outside the circle"
);
}
let area = area(&straddling);
assert!(area > 0. && area < 16., "area {area}");
} }
} }
+108 -43
View File
@@ -24,6 +24,7 @@ use crate::menu::DropdownMenu as _;
use crate::resizable::{resize_handle, resize_handle_appearance}; use crate::resizable::{resize_handle, resize_handle_appearance};
use crate::tab::Tab; use crate::tab::Tab;
use crate::tab::tab_bar::TabBar; use crate::tab::tab_bar::TabBar;
use crate::title_bar::{title_bar_drag_handlers, window_controls};
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex}; use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
mod panel; mod panel;
@@ -31,12 +32,34 @@ pub use panel::*;
actions!(dock, [ToggleZoom, ClosePanel]); actions!(dock, [ToggleZoom, ClosePanel]);
pub type TitleBarRenderer = fn(&mut Window, &mut App) -> AnyElement;
#[derive(Default)]
pub struct TitleBarChrome {
trailing: Cell<Option<TitleBarRenderer>>,
}
impl TitleBarChrome {
pub fn set_trailing(&self, renderer: TitleBarRenderer) {
self.trailing.set(Some(renderer));
}
fn trailing(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
self.trailing.get().map(|render| render(window, cx))
}
}
pub fn dock_area( pub fn dock_area(
id: impl Into<SharedString>, id: impl Into<SharedString>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Entity<DockArea> { ) -> (Entity<DockArea>, Rc<TitleBarChrome>) {
let shared = Rc::new(SkinShared::default()); let chrome = Rc::new(TitleBarChrome::default());
let shared = Rc::new(SkinShared {
area: RefCell::new(None),
resizing: Cell::new(None),
chrome: chrome.clone(),
});
let area = cx.new(|cx| { let area = cx.new(|cx| {
DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin { DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin {
shared: shared.clone(), shared: shared.clone(),
@@ -44,7 +67,7 @@ pub fn dock_area(
}); });
*shared.area.borrow_mut() = Some(area.downgrade()); *shared.area.borrow_mut() = Some(area.downgrade());
area (area, chrome)
} }
pub fn add_panel( pub fn add_panel(
@@ -167,6 +190,7 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
struct SkinShared { struct SkinShared {
area: RefCell<Option<WeakEntity<DockArea>>>, area: RefCell<Option<WeakEntity<DockArea>>>,
resizing: Cell<Option<DockPlacement>>, resizing: Cell<Option<DockPlacement>>,
chrome: Rc<TitleBarChrome>,
} }
impl SkinShared { impl SkinShared {
@@ -394,6 +418,17 @@ impl TabGroupSkin {
} }
} }
fn is_title_bar_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
};
area.read(cx)
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root()))
== Some(group.node())
}
fn render_toolbar( fn render_toolbar(
&self, &self,
group: &TabGroupContext, group: &TabGroupContext,
@@ -473,16 +508,17 @@ impl TabGroupSkin {
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx); let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let has_leading = left_button.is_some() || bottom_button.is_some(); let has_leading = left_button.is_some() || bottom_button.is_some();
let drag = tab_drag(group, ix, cx); let drag = tab_drag(group, ix, cx);
let is_title_bar = self.is_title_bar_group(group, cx);
let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx))
.flatten();
h_flex() let bar = h_flex()
.id("tab-title-bar")
.justify_between() .justify_between()
.items_center() .items_center()
.line_height(rems(1.0)) .line_height(rems(1.0))
.h(TABBAR_HEIGHT) .h(TABBAR_HEIGHT)
.py_2()
.pl_3()
.pr_2()
.rounded_t(cx.theme().radius_lg)
.bg(cx.theme().panel_background) .bg(cx.theme().panel_background)
.when(left_button.is_some(), |this| this.pl_2()) .when(left_button.is_some(), |this| this.pl_2())
.when(right_button.is_some(), |this| this.pr_2()) .when(right_button.is_some(), |this| this.pr_2())
@@ -499,9 +535,9 @@ impl TabGroupSkin {
.child( .child(
div() div()
.id("tab") .id("tab")
.flex_1() .flex_initial()
.min_w_0()
.px_2() .px_2()
.min_w_16()
.overflow_hidden() .overflow_hidden()
.whitespace_nowrap() .whitespace_nowrap()
.child( .child(
@@ -524,6 +560,14 @@ impl TabGroupSkin {
}) })
}), }),
) )
.child({
let space = div().id("tab-title-space").flex_1().h_full();
if is_title_bar {
title_bar_drag_handlers(space, window, cx).into_any_element()
} else {
space.into_any_element()
}
})
.child( .child(
h_flex() h_flex()
.flex_shrink_0() .flex_shrink_0()
@@ -532,7 +576,18 @@ impl TabGroupSkin {
.child(self.render_toolbar(group, window, cx)) .child(self.render_toolbar(group, window, cx))
.children(right_button), .children(right_button),
) )
.when_some(trailing_chrome, |this, chrome| this.child(chrome));
if is_title_bar {
h_flex()
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.child(bar.flex_1())
.child(window_controls())
.into_any_element() .into_any_element()
} else {
bar.into_any_element()
}
} }
fn render_tabs( fn render_tabs(
@@ -556,13 +611,36 @@ impl TabGroupSkin {
.iter() .iter()
.position(|panel| panel.panel_id(cx) == displayed) .position(|panel| panel.panel_id(cx) == displayed)
}); });
let is_title_bar = self.is_title_bar_group(group, cx);
let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx))
.flatten();
let empty_space = div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().surface_background))
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
let ix = (drag.source() == group.node()).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx);
}
})
});
let empty_space = if is_title_bar {
title_bar_drag_handlers(empty_space, window, cx).into_any_element()
} else {
empty_space.into_any_element()
};
TabBar::new("tab-bar") let bar = TabBar::new("tab-bar")
.track_scroll(&self.scroll_handle) .track_scroll(&self.scroll_handle)
.h(TABBAR_HEIGHT) .h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background) .bg(cx.theme().panel_background)
.rounded_t(cx.theme().radius_lg) .when(is_title_bar || has_leading, |this| {
.when(has_leading, |this| {
this.prefix( this.prefix(
h_flex() h_flex()
.items_center() .items_center()
@@ -639,26 +717,7 @@ impl TabGroupSkin {
}) })
}) })
})) }))
.last_empty_space( .last_empty_space(empty_space)
// Empty space so a panel can be moved past the last tab.
div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| {
this.bg(cx.theme().surface_background)
})
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
let ix = (drag.source() == group.node()).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx);
}
})
}),
)
.when(!collapsed, |this| { .when(!collapsed, |this| {
this.suffix( this.suffix(
h_flex() h_flex()
@@ -669,10 +728,22 @@ impl TabGroupSkin {
.px_0p5() .px_0p5()
.gap_1() .gap_1()
.child(self.render_toolbar(group, window, cx)) .child(self.render_toolbar(group, window, cx))
.children(right_button), .children(right_button)
.children(trailing_chrome),
) )
}) });
if is_title_bar {
h_flex()
.h(TABBAR_HEIGHT)
.w_full()
.bg(cx.theme().panel_background)
.child(bar.flex_1())
.child(window_controls())
.into_any_element() .into_any_element()
} else {
bar.into_any_element()
}
} }
fn dock_toggle_button( fn dock_toggle_button(
@@ -738,13 +809,8 @@ impl TabGroupSkin {
} }
impl TabGroupRenderer for TabGroupSkin { impl TabGroupRenderer for TabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> { fn frame(&self, group: &TabGroupContext, _: &mut Window, _cx: &mut App) -> Stateful<Div> {
div() div().id("tab-panel").when(!group.is_collapsed(), |this| {
.id("tab-panel")
.p_1()
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_xs())
.when(!group.is_collapsed(), |this| {
this.on_action({ this.on_action({
let group = TabGroupContext::clone(group); let group = TabGroupContext::clone(group);
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx) move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
@@ -811,7 +877,6 @@ impl TabGroupRenderer for TabGroupSkin {
.child( .child(
div() div()
.size_full() .size_full()
.rounded_b(cx.theme().radius_lg)
.bg(cx.theme().panel_background) .bg(cx.theme().panel_background)
.overflow_hidden() .overflow_hidden()
.child(panel.cached(StyleRefinement::default().v_flex().size_full())), .child(panel.cached(StyleRefinement::default().v_flex().size_full())),
+6
View File
@@ -33,12 +33,14 @@ pub enum IconName {
Close, Close,
CloseCircle, CloseCircle,
CloseCircleFill, CloseCircleFill,
Compass,
Copy, Copy,
Device, Device,
Door, Door,
Ellipsis, Ellipsis,
Emoji, Emoji,
Eye, Eye,
Folder,
Input, Input,
Info, Info,
Invite, Invite,
@@ -47,6 +49,7 @@ pub enum IconName {
Link, Link,
Loader, Loader,
Lock, Lock,
Message,
Moon, Moon,
Plus, Plus,
PlusCircle, PlusCircle,
@@ -106,12 +109,14 @@ impl IconNamed for IconName {
Self::Close => "icons/close.svg", Self::Close => "icons/close.svg",
Self::CloseCircle => "icons/close-circle.svg", Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg", Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Compass => "icons/compass.svg",
Self::Copy => "icons/copy.svg", Self::Copy => "icons/copy.svg",
Self::Device => "icons/device.svg", Self::Device => "icons/device.svg",
Self::Door => "icons/door.svg", Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg", Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg", Self::Emoji => "icons/emoji.svg",
Self::Eye => "icons/eye.svg", Self::Eye => "icons/eye.svg",
Self::Folder => "icons/folder.svg",
Self::Input => "icons/input.svg", Self::Input => "icons/input.svg",
Self::Info => "icons/info.svg", Self::Info => "icons/info.svg",
Self::Invite => "icons/invite.svg", Self::Invite => "icons/invite.svg",
@@ -120,6 +125,7 @@ impl IconNamed for IconName {
Self::Link => "icons/link.svg", Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg", Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg", Self::Lock => "icons/lock.svg",
Self::Message => "icons/message.svg",
Self::Moon => "icons/moon.svg", Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg", Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg", Self::PlusCircle => "icons/plus-circle.svg",
+1
View File
@@ -18,6 +18,7 @@ pub mod indicator;
pub mod input; pub mod input;
pub mod menu; pub mod menu;
pub mod modal; pub mod modal;
pub mod nav_item;
pub mod notification; pub mod notification;
pub mod popover; pub mod popover;
pub mod resizable; pub mod resizable;
+92 -39
View File
@@ -1,15 +1,18 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement, Anchor, AnyElement, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement,
RenderOnce, SharedString, StyleRefinement, Styled, Window, IntoElement, MouseButton, RenderOnce, SharedString, StyleRefinement, Styled, Window,
}; };
use crate::Selectable; use crate::Selectable;
use crate::avatar::Avatar; use crate::avatar::Avatar;
use crate::button::Button; use crate::button::Button;
use crate::menu::PopupMenu; use crate::menu::PopupMenu;
use crate::popover::Popover; use crate::popover::{Popover, PopoverState};
/// Builds the items of a popup menu on each render.
type MenuBuilder = dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu;
/// A dropdown menu trait for buttons and other interactive elements /// A dropdown menu trait for buttons and other interactive elements
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
@@ -44,8 +47,7 @@ pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
style: StyleRefinement, style: StyleRefinement,
anchor: Anchor, anchor: Anchor,
trigger: T, trigger: T,
#[allow(clippy::type_complexity)] builder: Rc<MenuBuilder>,
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
} }
impl<T> DropdownMenuPopover<T> impl<T> DropdownMenuPopover<T>
@@ -80,37 +82,64 @@ where
} }
} }
/// Opens a [`PopupMenu`] when its child is clicked with a mouse button
/// (right by default), keeping the child's own click handler intact.
#[derive(IntoElement)]
pub struct ContextMenu {
id: ElementId,
anchor: Anchor,
mouse_button: MouseButton,
child: AnyElement,
builder: Rc<MenuBuilder>,
}
impl ContextMenu {
pub fn new(
id: impl Into<ElementId>,
child: impl IntoElement,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
Self {
id: id.into(),
anchor: Anchor::TopLeft,
mouse_button: MouseButton::Right,
child: child.into_any_element(),
builder: Rc::new(builder),
}
}
/// Set the anchor corner of the menu, default is `Anchor::TopLeft`.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the mouse button that opens the menu, default is `MouseButton::Right`.
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
self.mouse_button = mouse_button;
self
}
}
#[derive(Default)] #[derive(Default)]
struct DropdownMenuState { struct MenuState {
menu: Option<Entity<PopupMenu>>, menu: Option<Entity<PopupMenu>>,
} }
impl<T> RenderOnce for DropdownMenuPopover<T> /// Builds the menu once and reuses it until it is dismissed.
where ///
T: Selectable + IntoElement + 'static, /// The popover content closure runs on every render, so rebuilding the menu
{ /// entity each time would drop its focus and selection state.
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement { fn cached_menu(
let builder = self.builder.clone(); menu_state: &Entity<MenuState>,
let menu_state = builder: Rc<MenuBuilder>,
window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default()); window: &mut Window,
cx: &mut Context<PopoverState>,
) -> Entity<PopupMenu> {
if let Some(menu) = menu_state.read(cx).menu.clone() {
return menu;
}
Popover::new(SharedString::from(format!("popover:{}", self.id)))
.appearance(false)
.overlay_closable(false)
.trigger(self.trigger)
.trigger_style(self.style)
.anchor(self.anchor)
.content(move |_, window, cx| {
// Here is special logic to only create the PopupMenu once and reuse it.
// Because this `content` will called in every time render, so we need to store the menu
// in state to avoid recreating at every render.
//
// And we also need to rebuild the menu when it is dismissed, to rebuild menu items
// dynamically for support `dropdown_menu` method, so we listen for DismissEvent below.
let menu = match menu_state.read(cx).menu.clone() {
Some(menu) => menu,
None => {
let builder = builder.clone();
let menu = PopupMenu::build(window, cx, move |menu, window, cx| { let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
builder(menu, window, cx) builder(menu, window, cx)
}); });
@@ -119,15 +148,12 @@ where
}); });
menu.focus_handle(cx).focus(window, cx); menu.focus_handle(cx).focus(window, cx);
// Listen for dismiss events from the PopupMenu to close the popover.
let popover_state = cx.entity(); let popover_state = cx.entity();
window window
.subscribe(&menu, cx, { .subscribe(&menu, cx, {
let menu_state = menu_state.clone(); let menu_state = menu_state.clone();
move |_, _: &DismissEvent, window, cx| { move |_, _: &DismissEvent, window, cx| {
popover_state.update(cx, |state, cx| { popover_state.update(cx, |state, cx| state.dismiss(window, cx));
state.dismiss(window, cx);
});
menu_state.update(cx, |state, _| { menu_state.update(cx, |state, _| {
state.menu = None; state.menu = None;
}); });
@@ -135,11 +161,38 @@ where
}) })
.detach(); .detach();
menu.clone() menu
} }
};
menu.clone() impl<T> RenderOnce for DropdownMenuPopover<T>
}) where
T: Selectable + IntoElement + 'static,
{
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let builder = self.builder.clone();
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
Popover::new(SharedString::from(format!("popover:{}", self.id)))
.appearance(false)
.overlay_closable(false)
.trigger(self.trigger)
.trigger_style(self.style)
.anchor(self.anchor)
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
}
}
impl RenderOnce for ContextMenu {
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let builder = self.builder.clone();
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
Popover::new(SharedString::from(format!("context-menu:{}", self.id)))
.appearance(false)
.overlay_closable(false)
.anchor(self.anchor)
.mouse_button(self.mouse_button)
.trigger_with(move |_open, _window, _cx| self.child)
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@ mod dropdown_menu;
mod menu_item; mod menu_item;
mod popup_menu; mod popup_menu;
pub use dropdown_menu::DropdownMenu; pub use dropdown_menu::{ContextMenu, DropdownMenu};
pub use popup_menu::{PopupMenu, PopupMenuItem}; pub use popup_menu::{PopupMenu, PopupMenuItem};
pub(crate) fn init(cx: &mut App) { pub(crate) fn init(cx: &mut App) {
+100
View File
@@ -0,0 +1,100 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
div,
};
use theme::ActiveTheme;
use crate::{StyledExt, h_flex};
/// A single navigation entry in a sidebar.
///
/// It has an arbitrary leading element, such as an icon or avatar, and a text
/// label. It can carry an optional trailing suffix, such as a status icon, and
/// an optional click handler. Rows with a click handler are highlighted on
/// hover and show a pointer cursor.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
pub struct NavItem {
id: ElementId,
style: StyleRefinement,
icon: AnyElement,
label: SharedString,
/// Trailing element at the right edge of the row, after the ellipsized label.
suffix: Option<AnyElement>,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl NavItem {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
icon: impl IntoElement,
) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
icon: icon.into_any_element(),
label: label.into(),
suffix: None,
on_click: None,
}
}
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl Styled for NavItem {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for NavItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some();
h_flex()
.id(self.id)
.refine_style(&self.style)
.px_2()
.py_1()
.w_full()
.gap_2()
.rounded(cx.theme().radius)
.text_color(cx.theme().text)
.child(self.icon)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_sm()
.child(self.label),
)
.when_some(self.suffix, |this, suffix| {
this.child(div().flex_shrink_0().child(suffix))
})
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().ghost_element_hover))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
})
}
}
+13
View File
@@ -87,6 +87,19 @@ impl Popover {
self self
} }
/// Set the trigger from a builder, for elements that have no selected state.
///
/// [`Self::trigger`] marks the trigger as selected while the popover is
/// open, so it cannot be used with elements whose selection carries a
/// different meaning, such as a row that indicates the current room.
pub fn trigger_with<F>(mut self, trigger: F) -> Self
where
F: FnOnce(bool, &Window, &App) -> AnyElement + 'static,
{
self.trigger = Some(Box::new(trigger));
self
}
/// Set the default open state of the popover, default is `false`. /// Set the default open state of the popover, default is `false`.
/// ///
/// This is only used to initialize the open state of the popover. /// This is only used to initialize the open state of the popover.
+56 -4
View File
@@ -2,9 +2,10 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AnyElement, App, ClickEvent, Context, Decorations, Hsla, InteractiveElement, IntoElement, AnyElement, App, ClickEvent, Context, Decorations, Div, Hsla, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement as _, MouseButton, ParentElement, Pixels, Render, RenderOnce, Stateful,
StyleRefinement, Styled, TitlebarOptions, Window, WindowControlArea, div, px, StatefulInteractiveElement as _, StyleRefinement, Styled, TitlebarOptions, Window,
WindowControlArea, div, px,
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use theme::ActiveTheme; use theme::ActiveTheme;
@@ -210,10 +211,61 @@ impl RenderOnce for ControlIcon {
#[derive(IntoElement)] #[derive(IntoElement)]
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
struct WindowControls { pub(crate) struct WindowControls {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>, on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
} }
pub(crate) fn window_controls() -> WindowControls {
WindowControls {
on_close_window: None,
}
}
pub fn title_bar_drag_handlers(
this: Stateful<Div>,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let state = window.use_state(cx, |_, _| TitleBarState { should_move: false });
let this = if cfg!(target_family = "wasm") {
this
} else {
this.window_control_area(WindowControlArea::Drag)
};
this.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = true;
}),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}),
)
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
if state.should_move {
state.should_move = false;
window.start_window_move();
}
}))
.on_click(|event, window, _| {
if event.click_count() == 2 {
if cfg!(target_os = "macos") {
window.titlebar_double_click();
} else {
window.zoom_window();
}
}
})
}
impl RenderOnce for WindowControls { impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") { if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
+10 -2
View File
@@ -295,7 +295,11 @@ impl Screening {
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.text_sm() .text_sm()
.hover(|this| this.bg(cx.theme().elevated_surface_background)) .hover(|this| this.bg(cx.theme().elevated_surface_background))
.child(Avatar::new(profile.avatar()).small()) .child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
.child(profile.name()), .child(profile.name()),
); );
} }
@@ -335,7 +339,11 @@ impl Render for Screening {
.items_center() .items_center()
.justify_center() .justify_center()
.text_center() .text_center()
.child(Avatar::new(profile.avatar()).large()) .child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.large(),
)
.child( .child(
div() div()
.font_semibold() .font_semibold()
+29 -102
View File
@@ -1,3 +1,4 @@
use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use ::settings::AppSettings; use ::settings::AppSettings;
@@ -8,8 +9,8 @@ use common::download_dir;
use device::{DeviceEvent, DeviceRegistry}; use device::{DeviceEvent, DeviceRegistry};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Action, AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
Render, SharedString, Styled, Subscription, Task, Window, div, px, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{PersonRegistry, shorten_pubkey}; use person::{PersonRegistry, shorten_pubkey};
@@ -17,17 +18,18 @@ use serde::Deserialize;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent}; use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry}; use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle}; use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::{Notification, NotificationKind}; use ui::notification::{Notification, NotificationKind};
use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_flex}; use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
use crate::dialogs::import::ImportIdentity; use crate::dialogs::import::ImportIdentity;
use crate::dialogs::restore::RestoreEncryption; use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::settings; use crate::dialogs::settings;
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list}; use crate::panels::{
backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, search,
};
use crate::sidebar::Sidebar; use crate::sidebar::Sidebar;
mod dialogs; mod dialogs;
@@ -57,12 +59,16 @@ enum Command {
ShowSettings, ShowSettings,
ShowBackup, ShowBackup,
ShowContactList, ShowContactList,
ShowInbox,
ShowBrowse,
ShowSearch,
} }
pub struct Workspace { pub struct Workspace {
sidebar: Entity<Sidebar>, sidebar: Entity<Sidebar>,
/// App's Dock Area /// App's Dock Area
dock: Entity<DockArea>, dock: Entity<DockArea>,
title_bar_chrome: Rc<dock::TitleBarChrome>,
/// Async tasks /// Async tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
@@ -78,7 +84,7 @@ impl Workspace {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let sidebar = cx.new(|cx| Sidebar::new(window, cx)); let sidebar = cx.new(|cx| Sidebar::new(window, cx));
let dock = dock::dock_area("coop", window, cx); let (dock, title_bar_chrome) = dock::dock_area("coop", window, cx);
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
@@ -225,6 +231,7 @@ impl Workspace {
Self { Self {
sidebar, sidebar,
dock, dock,
title_bar_chrome,
tasks: vec![], tasks: vec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
@@ -294,6 +301,15 @@ impl Workspace {
cx, cx,
); );
} }
Command::ShowInbox => {
self.add_panel_to_dock(inbox::init(window, cx), DockPlacement::Center, window, cx);
}
Command::ShowBrowse => {
self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx);
}
Command::ShowSearch => {
self.add_panel_to_dock(search::init(window, cx), DockPlacement::Center, window, cx);
}
Command::ShowBackup => { Command::ShowBackup => {
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx); self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
} }
@@ -518,95 +534,14 @@ impl Workspace {
}); });
} }
fn titlebar_left(&mut self, cx: &mut Context<Self>) -> impl IntoElement { fn titlebar_right(_window: &mut Window, cx: &mut App) -> AnyElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
h_flex()
.flex_shrink_0()
.gap_2()
.when_none(&current_user, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Import your identity to continue")),
)
})
.when_some(current_user.as_ref(), |this, public_key| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(public_key, cx);
let avatar = profile.avatar();
let name = profile.name();
this.child(
Button::new("current-user")
.child(Avatar::new(avatar.clone()).xsmall())
.small()
.caret()
.compact()
.transparent()
.dropdown_menu(move |this, _window, cx| {
let avatar = avatar.clone();
let name = name.clone();
this.min_w(px(256.))
.item(PopupMenuItem::element(move |_window, cx| {
h_flex()
.gap_1p5()
.text_xs()
.text_color(cx.theme().text_muted)
.child(Avatar::new(avatar.clone()).xsmall())
.child(name.clone())
}))
.separator()
.menu_with_icon(
"Profile",
IconName::Profile,
Box::new(Command::ShowProfile),
)
.menu_with_icon(
"Contact List",
IconName::Book,
Box::new(Command::ShowContactList),
)
.menu_with_icon(
"Backup",
IconName::UserKey,
Box::new(Command::ShowBackup),
)
.menu_with_icon(
"Themes",
IconName::Sun,
Box::new(Command::ToggleTheme),
)
// Only offer in-app updates when auto-update is
// enabled (managed channels update themselves).
.when(AutoUpdater::is_available(cx), |this| {
this.separator().menu_with_icon(
"Check for Updates",
IconName::Device,
Box::new(Command::Update),
)
})
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Command::ShowSettings),
)
}),
)
})
}
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let auto_updater = AutoUpdater::try_global(cx); let auto_updater = AutoUpdater::try_global(cx);
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let nip4e_enabled = AppSettings::get_nip4e(cx); let nip4e_enabled = AppSettings::get_nip4e(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else { let Some(public_key) = nostr.read(cx).current_user() else {
return div(); return div().into_any_element();
}; };
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
@@ -635,11 +570,11 @@ impl Workspace {
.tooltip("Quit and relaunch into the installed update") .tooltip("Quit and relaunch into the installed update")
.small() .small()
.ghost() .ghost()
.on_click(cx.listener(|_this, _event, _window, cx| { .on_click(|_event, _window, cx| {
if let Some(auto_updater) = AutoUpdater::try_global(cx) { if let Some(auto_updater) = AutoUpdater::try_global(cx) {
auto_updater.update(cx, |this, cx| this.restart(cx)); auto_updater.update(cx, |this, cx| this.restart(cx));
} }
})), }),
) )
}) })
.when(nip4e_enabled, |this| { .when(nip4e_enabled, |this| {
@@ -764,6 +699,7 @@ impl Workspace {
) )
}), }),
) )
.into_any_element()
} }
} }
@@ -772,21 +708,13 @@ impl Render for Workspace {
let modal_layer = Root::render_modal_layer(window, cx); let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx); let notification_layer = Root::render_notification_layer(window, cx);
self.title_bar_chrome.set_trailing(Self::titlebar_right);
div() div()
.id("workspace") .id("workspace")
.on_action(cx.listener(Self::on_command)) .on_action(cx.listener(Self::on_command))
.relative() .relative()
.size_full() .size_full()
.child(
v_flex()
.size_full()
// Title Bar
.child(
TitleBar::new()
.child(self.titlebar_left(cx))
.child(self.titlebar_right(cx)),
)
// Main
.child( .child(
h_flex() h_flex()
.size_full() .size_full()
@@ -798,7 +726,6 @@ impl Render for Workspace {
.child(self.sidebar.clone()), .child(self.sidebar.clone()),
) )
.child(self.dock.clone()), .child(self.dock.clone()),
),
) )
// Notifications // Notifications
.children(notification_layer) .children(notification_layer)
+62
View File
@@ -0,0 +1,62 @@
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use theme::ActiveTheme;
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
cx.new(|cx| BrowsePanel::new(window, cx))
}
pub struct BrowsePanel {
name: SharedString,
focus_handle: FocusHandle,
}
impl BrowsePanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
name: "Browse".into(),
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for BrowsePanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Compass)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for BrowsePanel {}
impl Focusable for BrowsePanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for BrowsePanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(self.name.clone())
}
}
+5 -1
View File
@@ -239,7 +239,11 @@ impl ContactListPanel {
h_flex() h_flex()
.gap_2() .gap_2()
.text_sm() .text_sm()
.child(Avatar::new(profile.avatar()).small()) .child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
.child(profile.name()), .child(profile.name()),
) )
.child( .child(
+62
View File
@@ -0,0 +1,62 @@
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use theme::ActiveTheme;
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<InboxPanel> {
cx.new(|cx| InboxPanel::new(window, cx))
}
pub struct InboxPanel {
name: SharedString,
focus_handle: FocusHandle,
}
impl InboxPanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
name: "Inbox".into(),
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for InboxPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Inbox)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for InboxPanel {}
impl Focusable for InboxPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for InboxPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(self.name.clone())
}
}
+3
View File
@@ -1,6 +1,9 @@
pub mod backup; pub mod backup;
pub mod browse;
pub mod contact_list; pub mod contact_list;
pub mod greeter; pub mod greeter;
pub mod inbox;
pub mod messaging_relays; pub mod messaging_relays;
pub mod profile; pub mod profile;
pub mod relay_list; pub mod relay_list;
pub mod search;
+2 -7
View File
@@ -309,12 +309,7 @@ impl Render for ProfilePanel {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar_input = self.avatar_input.read(cx).value(); let avatar_input = self.avatar_input.read(cx).value();
// Get the avatar let picture = (!avatar_input.is_empty()).then_some(avatar_input);
let avatar = if avatar_input.is_empty() {
"brand/avatar.png"
} else {
avatar_input.as_str()
};
// Get the public key as short string // Get the public key as short string
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8)); let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
@@ -331,7 +326,7 @@ impl Render for ProfilePanel {
.items_center() .items_center()
.justify_center() .justify_center()
.gap_4() .gap_4()
.child(Avatar::new(avatar).large()) .child(Avatar::new(picture).seed(self.public_key.to_hex()).large())
.child( .child(
Button::new("upload") Button::new("upload")
.icon(IconName::PlusCircle) .icon(IconName::PlusCircle)
+541
View File
@@ -0,0 +1,541 @@
use std::collections::HashSet;
use std::ops::Range;
use anyhow::Error;
use chat::{ChatRegistry, Room, RoomKind};
use common::DebouncedDelay;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div,
uniform_list,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputEvent, InputState};
use ui::notification::Notification;
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use crate::sidebar::RoomEntry;
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
cx.new(|cx| SearchPanel::new(window, cx))
}
pub struct SearchPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Find input state
find_input: Entity<InputState>,
/// Debounced delay for find input
find_debouncer: DebouncedDelay<Self>,
/// Whether a search is in progress
finding: bool,
/// Find results
find_results: Entity<Option<Vec<PublicKey>>>,
/// Async find operation
find_task: Option<Task<Result<(), Error>>>,
/// Selected public keys
selected_pkeys: Entity<HashSet<PublicKey>>,
/// User's contacts
contact_list: Entity<Option<Vec<PublicKey>>>,
/// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl SearchPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let contact_list = cx.new(|_| None);
let selected_pkeys = cx.new(|_| HashSet::new());
let find_results = cx.new(|_| None);
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(INPUT_PLACEHOLDER)
.clean_on_escape()
});
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to find input events
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
let delay = Duration::from_millis(FIND_DELAY);
match event {
InputEvent::PressEnter { .. } => {
this.search(window, cx);
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
// Clear results when input is empty
this.reset(window, cx);
} else {
// Run debounced search
this.find_debouncer
.fire_new(delay, window, cx, |this, window, cx| {
this.debounced_search(window, cx)
});
}
}
InputEvent::Focus => {
this.get_contact_list(window, cx);
}
_ => {}
};
}),
);
Self {
name: "Search".into(),
focus_handle: cx.focus_handle(),
find_input,
find_debouncer: DebouncedDelay::new(),
find_results,
find_task: None,
finding: false,
contact_list,
selected_pkeys,
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
/// Get the contact list.
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
Ok(contacts)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.set_contact_list(contacts, cx);
})?;
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
})?;
}
};
Ok(())
}));
}
/// Set the contact list with new contacts.
fn set_contact_list<I>(&mut self, contacts: I, cx: &mut Context<Self>)
where
I: IntoIterator<Item = PublicKey>,
{
self.contact_list.update(cx, |this, cx| {
*this = Some(contacts.into_iter().collect());
cx.notify();
});
}
/// Trigger the debounced search
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
this.search(window, cx);
})
.ok();
})
}
/// Search
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get query
let query = self.find_input.read(cx).value();
// Return if the query is empty
if query.is_empty() {
return;
}
// Block the input until the search completes
self.set_finding(true, window, cx);
// Create the search task
let nostr = NostrRegistry::global(cx);
let find_users = nostr.read(cx).search(&query, cx);
// Run task in the main thread
self.find_task = Some(cx.spawn_in(window, async move |this, cx| {
let rooms = find_users.await?;
// Update the UI with the search results
this.update_in(cx, |this, window, cx| {
this.set_results(rooms, cx);
this.set_finding(false, window, cx);
})?;
Ok(())
}));
}
/// Set the results of the search
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
self.find_results.update(cx, |this, cx| {
*this = Some(results);
cx.notify();
});
}
/// Set the finding status
fn set_finding(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
// Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_loading(status, window, cx);
});
// Set the search status
self.finding = status;
cx.notify();
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Clear all search results
self.find_results.update(cx, |this, cx| {
*this = None;
cx.notify();
});
// Clear all selected public keys
self.selected_pkeys.update(cx, |this, cx| {
this.clear();
cx.notify();
});
// Reset the search status
self.set_finding(false, window, cx);
// Cancel the current search task
self.find_task = None;
cx.notify();
}
/// Select a public key in the search panel.
fn select(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
self.selected_pkeys.update(cx, |this, cx| {
if this.contains(public_key) {
this.remove(public_key);
} else {
this.insert(public_key.to_owned());
}
cx.notify();
});
}
/// Check if a public key is selected in the search panel.
fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool {
self.selected_pkeys.read(cx).contains(public_key)
}
/// Get all selected public keys in the search panel.
fn get_selected(&self, cx: &Context<Self>) -> HashSet<PublicKey> {
self.selected_pkeys.read(cx).clone()
}
/// Create a new room
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
let async_chat = chat.downgrade();
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
// Get all selected public keys
let receivers = self.get_selected(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Create a new room and emit it
async_chat.update_in(cx, |this, _window, cx| {
let room = cx.new(|_| {
Room::new(public_key, receivers)
.organize(&public_key)
.kind(RoomKind::Ongoing)
});
this.emit_room(&room, _window, cx);
})?;
// Reset the find panel
this.update_in(cx, |this, window, cx| {
this.reset(window, cx);
})?;
Ok(())
}));
}
/// Render the search results
fn render_results(
&self,
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
let persons = PersonRegistry::global(cx);
// Get the results
let Some(results) = self.find_results.read(cx) else {
return vec![];
};
// Map the results to a list of elements
results
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, public_key)| {
let selected = self.is_selected(public_key, cx);
let profile = persons.read(cx).get(public_key, cx);
let pkey_clone = public_key.to_owned();
let handler = cx.listener(move |this, _ev, _window, cx| {
this.select(&pkey_clone, cx);
});
RoomEntry::new(range.start + ix)
.name(profile.name())
.avatar(profile.avatar())
.seed(profile.avatar_seed())
.on_click(handler)
.selected(selected)
.into_any_element()
})
.collect()
}
/// Render the contact list
fn render_contacts(
&self,
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
let persons = PersonRegistry::global(cx);
// Get the contact list
let Some(contacts) = self.contact_list.read(cx) else {
return vec![];
};
// Map the contact list to a list of elements
contacts
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, public_key)| {
let selected = self.is_selected(public_key, cx);
let profile = persons.read(cx).get(public_key, cx);
let pkey_clone = public_key.to_owned();
let handler = cx.listener(move |this, _ev, _window, cx| {
this.select(&pkey_clone, cx);
});
RoomEntry::new(range.start + ix)
.name(profile.name().trim())
.avatar(profile.avatar())
.seed(profile.avatar_seed())
.on_click(handler)
.selected(selected)
.into_any_element()
})
.collect()
}
}
impl Panel for SearchPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Search)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for SearchPanel {}
impl Focusable for SearchPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for SearchPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let chat = ChatRegistry::global(cx);
let logged_in = nostr.read(cx).current_user().is_some();
let loading = chat.read(cx).loading() && logged_in;
// Set button label based on total selected users
let button_label = if self.selected_pkeys.read(cx).len() > 1 {
"Create Group DM"
} else {
"Create DM"
};
v_flex()
.size_full()
.gap_3()
.p_2()
.child(
h_flex().child(
Input::new(&self.find_input)
.small()
.text_xs()
.disabled(loading)
.when(
!self.find_input.read(cx).presentation().is_loading(),
|this| {
this.suffix(
Button::new("find-icon")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.transparent()
.small(),
)
},
),
),
)
.child(
v_flex()
.flex_1()
.gap_3()
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
this.child(
v_flex()
.gap_1()
.flex_1()
.border_b_1()
.border_color(cx.theme().border_variant)
.child(
h_flex()
.gap_0p5()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown))
.child("Results"),
)
.child(
uniform_list(
"rooms",
results.len(),
cx.processor(move |this, range, _window, cx| {
this.render_results(range, cx)
}),
)
.flex_1()
.h_full(),
),
)
})
.when_some(self.contact_list.read(cx).as_ref(), |this, contacts| {
this.child(
v_flex()
.gap_1()
.flex_1()
.child(
h_flex()
.gap_0p5()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown).small())
.child("Contacts"),
)
.child(
uniform_list(
"contacts",
contacts.len(),
cx.processor(|this, range, _window, cx| {
this.render_contacts(range, cx)
}),
)
.flex_1()
.h_full(),
),
)
}),
)
.when(!self.selected_pkeys.read(cx).is_empty(), |this| {
this.child(
div()
.absolute()
.bottom_2()
.left_0()
.h_9()
.w_full()
.px_4()
.child(
Button::new("create")
.label(button_label)
.primary()
.rounded()
.shadow_md()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.create_room(window, cx);
})),
),
)
})
}
}
+30 -10
View File
@@ -4,7 +4,7 @@ use chat::RoomKind;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div, StatefulInteractiveElement, Styled, Window, div, px,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings; use settings::AppSettings;
@@ -22,8 +22,10 @@ pub struct RoomEntry {
public_key: Option<PublicKey>, public_key: Option<PublicKey>,
name: Option<SharedString>, name: Option<SharedString>,
avatar: Option<SharedString>, avatar: Option<SharedString>,
seed: Option<SharedString>,
created_at: Option<SharedString>, created_at: Option<SharedString>,
kind: Option<RoomKind>, kind: Option<RoomKind>,
depth: u8,
selected: bool, selected: bool,
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>, handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
@@ -36,8 +38,10 @@ impl RoomEntry {
public_key: None, public_key: None,
name: None, name: None,
avatar: None, avatar: None,
seed: None,
created_at: None, created_at: None,
kind: None, kind: None,
depth: 0,
handler: None, handler: None,
selected: false, selected: false,
} }
@@ -53,8 +57,13 @@ impl RoomEntry {
self self
} }
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self { pub fn avatar(mut self, picture: Option<SharedString>) -> Self {
self.avatar = Some(avatar.into()); self.avatar = picture;
self
}
pub fn seed(mut self, seed: impl Into<SharedString>) -> Self {
self.seed = Some(seed.into());
self self
} }
@@ -68,6 +77,11 @@ impl RoomEntry {
self self
} }
pub fn depth(mut self, depth: u8) -> Self {
self.depth = depth;
self
}
pub fn on_click( pub fn on_click(
mut self, mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -95,20 +109,26 @@ impl RenderOnce for RoomEntry {
let public_key = self.public_key; let public_key = self.public_key;
let is_selected = self.is_selected(); let is_selected = self.is_selected();
let avatar = match (self.avatar, self.seed) {
(None, None) => None,
(picture, seed) => Some(
Avatar::new(picture)
.when_some(seed, |avatar, seed| avatar.seed(seed))
.xsmall()
.flex_shrink_0(),
),
};
h_flex() h_flex()
.id(self.ix) .id(self.ix)
.h_9() .h_8()
.w_full() .w_full()
.px_1p5() .pl(px(6. + self.depth as f32 * 10.))
.pr_1p5()
.gap_2() .gap_2()
.text_sm() .text_sm()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| this.children(avatar))
this.when_some(self.avatar, |this, avatar| {
this.child(Avatar::new(avatar).small().flex_shrink_0())
})
})
.child( .child(
div() div()
.flex_1() .flex_1()
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
use std::rc::Rc;
use chat::Room;
use gpui::prelude::FluentBuilder;
use gpui::{
App, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, div, px,
};
use theme::ActiveTheme;
use ui::avatar::PixelAvatar;
use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TreeSection {
Pins,
Requests,
Community,
Messages,
}
impl TreeSection {
pub fn label(self) -> &'static str {
match self {
Self::Pins => "Pinned",
Self::Requests => "Requests",
Self::Community => "Community",
Self::Messages => "Messages",
}
}
pub fn icon(self) -> IconName {
match self {
Self::Pins | Self::Requests | Self::Community => IconName::Folder,
Self::Messages => IconName::Message,
}
}
pub fn key(self) -> &'static str {
match self {
Self::Pins => "pins",
Self::Requests => "requests",
Self::Community => "community",
Self::Messages => "messages",
}
}
pub fn from_key(key: &str) -> Option<Self> {
match key {
"pins" => Some(Self::Pins),
"requests" => Some(Self::Requests),
"community" => Some(Self::Community),
"messages" => Some(Self::Messages),
_ => None,
}
}
}
pub enum SidebarRow {
Section {
section: TreeSection,
count: usize,
},
Room {
room: Entity<Room>,
depth: u8,
pinned: bool,
},
Community {
entry: &'static CommunityEntry,
depth: u8,
},
Hint {
text: SharedString,
depth: u8,
},
}
pub struct CommunityEntry {
pub name: &'static str,
}
pub fn dummy_communities() -> &'static [CommunityEntry] {
// TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md.
&[
CommunityEntry {
name: "Coop Contributors",
},
CommunityEntry {
name: "Nostr Design",
},
]
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TreeRowKind {
Section,
Community,
Hint,
}
#[derive(IntoElement)]
pub struct TreeRow {
id: ElementId,
kind: TreeRowKind,
depth: u8,
caret: Option<IconName>,
icon: Option<IconName>,
avatar: Option<SharedString>,
label: SharedString,
count: Option<usize>,
dot: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl TreeRow {
pub fn new(
id: impl Into<ElementId>,
kind: TreeRowKind,
label: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
kind,
depth: 0,
caret: None,
icon: None,
avatar: None,
label: label.into(),
count: None,
dot: false,
on_click: None,
}
}
pub fn depth(mut self, depth: u8) -> Self {
self.depth = depth;
self
}
pub fn caret(mut self, caret: IconName) -> Self {
self.caret = Some(caret);
self
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
/// Sets the seed for the row's generated avatar.
pub fn avatar(mut self, seed: impl Into<SharedString>) -> Self {
self.avatar = Some(seed.into());
self
}
pub fn count(mut self, count: usize) -> Self {
self.count = Some(count);
self
}
pub fn dot(mut self) -> Self {
self.dot = true;
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TreeRow {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let indent = px(6. + self.depth as f32 * 14.);
let avatar_seed = self.avatar;
let is_section = self.kind == TreeRowKind::Section;
let is_community = self.kind == TreeRowKind::Community;
let is_hint = self.kind == TreeRowKind::Hint;
h_flex()
.id(self.id)
.h_8()
.w_full()
.pl(indent)
.pr_1p5()
.gap_2()
.rounded(cx.theme().radius)
.when(is_section, |this| {
this.text_xs().text_color(cx.theme().text_muted)
})
.when(is_community, |this| this.text_sm())
.when(is_hint, |this| {
this.text_xs()
.font_normal()
.text_color(cx.theme().text_placeholder)
})
.when_some(self.icon, |this, icon| {
this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted))
})
.when_some(avatar_seed, |this, seed| {
this.child(PixelAvatar::new(seed).xsmall())
})
.child(
h_flex()
.gap_1()
.flex_1()
.child(div().truncate().min_w_0().child(self.label))
.when_some(self.count, |this, count| {
this.child(
div()
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(count.to_string()),
)
}),
)
.when_some(self.caret, |this, caret| {
this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted))
})
.when(self.dot, |this| {
this.child(
div()
.flex_shrink_0()
.size_1()
.rounded_full()
.bg(cx.theme().cursor),
)
})
.when_some(self.on_click, |this, handler| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.on_click(move |event, window, cx| handler(event, window, cx))
})
}
}
+1
View File
@@ -35,6 +35,7 @@ common = { path = "../crates/common" }
state = { path = "../crates/state" } state = { path = "../crates/state" }
device = { path = "../crates/device" } device = { path = "../crates/device" }
chat = { path = "../crates/chat" } chat = { path = "../crates/chat" }
community = { path = "../crates/community" }
settings = { path = "../crates/settings" } settings = { path = "../crates/settings" }
auto_update = { path = "../crates/auto_update" } auto_update = { path = "../crates/auto_update" }
person = { path = "../crates/person" } person = { path = "../crates/person" }
+35 -30
View File
@@ -9,6 +9,7 @@ use gpui::{
use gpui_platform::application; use gpui_platform::application;
use nostr_sdk::prelude::SecretKey; use nostr_sdk::prelude::SecretKey;
use state::{APP_ID, CLIENT_NAME}; use state::{APP_ID, CLIENT_NAME};
use theme::TABBAR_HEIGHT;
use ui::Root; use ui::Root;
actions!(coop, [Quit]); actions!(coop, [Quit]);
@@ -30,6 +31,12 @@ fn main() {
.with_assets(Assets) .with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new())) .with_http_client(Arc::new(reqwest_client::ReqwestClient::new()))
.run(move |cx| { .run(move |cx| {
// Initialize components
ui::init(cx);
// Initialize theme registry
theme::init(cx);
// Load embedded fonts in assets/fonts // Load embedded fonts in assets/fonts
load_embedded_fonts(cx); load_embedded_fonts(cx);
@@ -54,6 +61,29 @@ fn main() {
disabled: false, disabled: false,
}]); }]);
// Initialize settings
settings::init(cx);
// Initialize the nostr client
state::init(cx, cli_key);
// Initialize person registry
person::init(cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(cx);
// Initialize app registry
chat::init(cx);
// Initialize community registry
community::init(cx);
// Initialize auto update
auto_update::init(cx);
// Set up the window bounds // Set up the window bounds
let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx); let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx);
@@ -66,46 +96,21 @@ fn main() {
app_id: Some(APP_ID.to_owned()), app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions { titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(CLIENT_NAME)), title: Some(SharedString::new_static(CLIENT_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))), traffic_light_position: Some(point(
px(9.0),
px(TABBAR_HEIGHT / px(2.) - 14. / 2.),
)),
appears_transparent: true, appears_transparent: true,
}), }),
app_owns_titlebar_drag: true,
..Default::default() ..Default::default()
}; };
// Open a window with default options
cx.open_window(opts, |window, cx| { cx.open_window(opts, |window, cx| {
// Initialize components
ui::init(cx);
// Initialize theme registry
theme::init(cx);
// Initialize settings
settings::init(window, cx);
// Initialize the nostr client
state::init(window, cx, cli_key);
// Initialize person registry
person::init(window, cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(window, cx);
// Initialize app registry
chat::init(window, cx);
// Initialize auto update
auto_update::init(window, cx);
// Root view
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx)) cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
}) })
.expect("Failed to open window. Please restart the application."); .expect("Failed to open window. Please restart the application.");
// Bring the app to the foreground
cx.activate(true); cx.activate(true);
}); });
} }
+1
View File
@@ -16,6 +16,7 @@ common = { path = "../crates/common" }
state = { path = "../crates/state" } state = { path = "../crates/state" }
device = { path = "../crates/device" } device = { path = "../crates/device" }
chat = { path = "../crates/chat" } chat = { path = "../crates/chat" }
community = { path = "../crates/community" }
settings = { path = "../crates/settings" } settings = { path = "../crates/settings" }
person = { path = "../crates/person" } person = { path = "../crates/person" }
+9 -8
View File
@@ -47,8 +47,6 @@ pub fn run() -> Result<(), JsValue> {
}; };
app.run(|cx| { app.run(|cx| {
// Open the root window
cx.open_window(WindowOptions::default(), |window, cx| {
// Initialize components // Initialize components
ui::init(cx); ui::init(cx);
@@ -56,23 +54,26 @@ pub fn run() -> Result<(), JsValue> {
theme::init(cx); theme::init(cx);
// Initialize settings // Initialize settings
settings::init(window, cx); settings::init(cx);
// Initialize the nostr client // Initialize the nostr client
state::init(window, cx, None); state::init(cx, None);
// Initialize person registry // Initialize person registry
person::init(window, cx); person::init(cx);
// Initialize device signer // Initialize device signer
// //
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(window, cx); device::init(cx);
// Initialize app registry // Initialize app registry
chat::init(window, cx); chat::init(cx);
// Root view // Initialize community registry
community::init(cx);
cx.open_window(WindowOptions::default(), |window, cx| {
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx)) cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
}) })
.expect("Failed to open window. Please restart the application."); .expect("Failed to open window. Please restart the application.");