feat: sharpen chat experiences (#9)

* feat: add global account and refactor chat registry

* chore: improve last seen

* chore: reduce string alloc

* wip: refactor room

* chore: fix edit profile panel

* chore: refactor open window in main

* chore: refactor sidebar

* chore: refactor room
This commit is contained in:
reya
2025-02-23 08:29:05 +07:00
committed by GitHub
parent cfa628a8a6
commit bbc778d5ca
23 changed files with 1167 additions and 1150 deletions

View File

@@ -1,8 +1,7 @@
use asset::Assets;
use chats::registry::ChatRegistry;
use common::{
constants::{ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID},
profile::NostrProfile,
use common::constants::{
ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID,
};
use futures::{select, FutureExt};
use gpui::{
@@ -14,16 +13,16 @@ use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations};
use log::{error, info};
use nostr_sdk::SubscriptionId;
use nostr_sdk::{
pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, Metadata, PublicKey,
RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions,
pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, PublicKey, RelayMessage,
RelayPoolNotification, SubscribeAutoCloseOptions,
};
use nostr_sdk::{prelude::NostrEventsDatabaseExt, FromBech32, SubscriptionId};
use smol::Timer;
use state::get_client;
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
use ui::{theme::Theme, Root};
use views::{app, onboarding, startup};
use views::{app, onboarding};
mod asset;
mod views;
@@ -45,7 +44,7 @@ fn main() {
// Enable logging
tracing_subscriber::fmt::init();
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(2048);
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(1024);
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
// Initialize nostr client
@@ -176,31 +175,17 @@ fn main() {
// Handle re-open window
app.on_reopen(move |cx| {
let client = get_client();
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
let (tx, rx) = oneshot::channel::<bool>();
cx.background_spawn(async move {
if let Ok(signer) = client.signer().await {
if let Ok(public_key) = signer.get_public_key().await {
let metadata =
if let Ok(Some(metadata)) = client.database().metadata(public_key).await {
metadata
} else {
Metadata::new()
};
_ = tx.send(Some(NostrProfile::new(public_key, metadata)));
} else {
_ = tx.send(None);
}
} else {
_ = tx.send(None);
}
let is_login = client.signer().await.is_ok();
_ = tx.send(is_login);
})
.detach();
cx.spawn(|mut cx| async move {
if let Ok(result) = rx.await {
_ = restore_window(result, &mut cx).await;
if let Ok(is_login) = rx.await {
_ = restore_window(is_login, &mut cx).await;
}
})
.detach();
@@ -223,115 +208,90 @@ fn main() {
items: vec![MenuItem::action("Quit", Quit)],
}]);
// Open window with default options
cx.open_window(
WindowOptions {
#[cfg(not(target_os = "linux"))]
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(900.0), px(680.0)),
cx,
))),
#[cfg(target_os = "linux")]
window_background: WindowBackgroundAppearance::Transparent,
#[cfg(target_os = "linux")]
window_decorations: Some(WindowDecorations::Client),
kind: WindowKind::Normal,
..Default::default()
},
|window, cx| {
window.set_window_title(APP_NAME);
window.set_app_id(APP_ID);
#[cfg(not(target_os = "linux"))]
window
.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
})
.detach();
let handle = window.window_handle();
let root = cx.new(|cx| Root::new(startup::init(window, cx).into(), window, cx));
let task = cx.read_credentials(KEYRING_SERVICE);
let (tx, rx) = oneshot::channel::<Option<NostrProfile>>();
// Read credential in OS Keyring
cx.background_spawn(async {
let profile = if let Ok(Some((npub, secret))) = task.await {
let public_key = PublicKey::from_bech32(&npub).unwrap();
let secret_hex = String::from_utf8(secret).unwrap();
let keys = Keys::parse(&secret_hex).unwrap();
// Update nostr signer
_ = client.set_signer(keys).await;
// Get user's metadata
let metadata = if let Ok(Some(metadata)) =
client.database().metadata(public_key).await
{
metadata
} else {
Metadata::new()
// Spawn a task to handle events from nostr channel
cx.spawn(|cx| async move {
while let Ok(signal) = event_rx.recv().await {
cx.update(|cx| {
if let Some(chats) = ChatRegistry::global(cx) {
match signal {
Signal::Eose => chats.update(cx, |this, cx| this.load_chat_rooms(cx)),
Signal::Event(event) => {
chats.update(cx, |this, cx| this.push_message(event, cx))
}
};
Some(NostrProfile::new(public_key, metadata))
} else {
None
};
_ = tx.send(profile)
})
.detach();
// Set root view based on credential status
cx.spawn(|mut cx| async move {
if let Ok(Some(profile)) = rx.await {
_ = cx.update_window(handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(app::init(profile, window, cx).into(), window, cx)
});
});
} else {
_ = cx.update_window(handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(onboarding::init(window, cx).into(), window, cx)
});
});
}
})
.detach();
.ok();
}
})
.detach();
cx.spawn(|cx| async move {
while let Ok(signal) = event_rx.recv().await {
cx.update(|cx| {
match signal {
Signal::Eose => {
if let Some(chats) = ChatRegistry::global(cx) {
chats.update(cx, |this, cx| this.load_chat_rooms(cx))
}
}
Signal::Event(event) => {
if let Some(chats) = ChatRegistry::global(cx) {
chats.update(cx, |this, cx| this.push_message(event, cx))
}
}
};
})
.ok();
}
})
.detach();
// Set up the window options
let window_opts = WindowOptions {
#[cfg(not(target_os = "linux"))]
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
None,
size(px(900.0), px(680.0)),
cx,
))),
#[cfg(target_os = "linux")]
window_background: WindowBackgroundAppearance::Transparent,
#[cfg(target_os = "linux")]
window_decorations: Some(WindowDecorations::Client),
kind: WindowKind::Normal,
..Default::default()
};
root
},
)
.expect("System error. Please re-open the app.");
// Create a task to read credentials from the keyring service
let task = cx.read_credentials(KEYRING_SERVICE);
let (tx, rx) = oneshot::channel::<bool>();
// Read credential in OS Keyring
cx.background_spawn(async {
let is_ready = if let Ok(Some((_, secret))) = task.await {
let result = async {
let secret_hex = String::from_utf8(secret)?;
let keys = Keys::parse(&secret_hex)?;
// Update nostr signer
client.set_signer(keys).await;
Ok::<_, anyhow::Error>(true)
}
.await;
result.is_ok()
} else {
false
};
_ = tx.send(is_ready)
})
.detach();
cx.spawn(|cx| async move {
if let Ok(is_ready) = rx.await {
if is_ready {
// Open a App window
cx.open_window(window_opts, |window, cx| {
cx.new(|cx| Root::new(app::init(window, cx).into(), window, cx))
})
.expect("Failed to open window");
} else {
// Open a Onboarding window
cx.open_window(window_opts, |window, cx| {
cx.new(|cx| Root::new(onboarding::init(window, cx).into(), window, cx))
})
.expect("Failed to open window");
}
}
})
.detach();
});
}
@@ -347,7 +307,7 @@ async fn sync_metadata(client: &Client, buffer: HashSet<PublicKey>) {
}
}
async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> anyhow::Result<()> {
async fn restore_window(is_login: bool, cx: &mut AsyncApp) -> anyhow::Result<()> {
let opts = cx
.update(|cx| WindowOptions {
#[cfg(not(target_os = "linux"))]
@@ -370,7 +330,7 @@ async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> any
})
.expect("Failed to set window options.");
if let Some(profile) = profile {
if is_login {
_ = cx.open_window(opts, |window, cx| {
window.set_window_title(APP_NAME);
window.set_app_id(APP_ID);
@@ -382,7 +342,7 @@ async fn restore_window(profile: Option<NostrProfile>, cx: &mut AsyncApp) -> any
})
.detach();
cx.new(|cx| Root::new(app::init(profile, window, cx).into(), window, cx))
cx.new(|cx| Root::new(app::init(window, cx).into(), window, cx))
});
} else {
_ = cx.open_window(opts, |window, cx| {

View File

@@ -1,4 +1,4 @@
use common::profile::NostrProfile;
use account::registry::Account;
use gpui::{
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled,
@@ -38,21 +38,22 @@ impl AddPanel {
}
}
// Dock actions
impl_internal_actions!(dock, [AddPanel]);
// Account actions
actions!(account, [Logout]);
pub fn init(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<AppView> {
AppView::new(account, window, cx)
pub fn init(window: &mut Window, cx: &mut App) -> Entity<AppView> {
AppView::new(window, cx)
}
pub struct AppView {
account: NostrProfile,
relays: Entity<Option<Vec<String>>>,
dock: Entity<DockArea>,
}
impl AppView {
pub fn new(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Self> {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
// Initialize dock layout
let dock = cx.new(|cx| DockArea::new(window, cx));
let weak_dock = dock.downgrade();
@@ -83,76 +84,74 @@ impl AppView {
});
cx.new(|cx| {
let public_key = account.public_key();
let relays = cx.new(|_| None);
let async_relays = relays.downgrade();
let this = Self { relays, dock };
// Check user's messaging relays and determine user is ready for NIP17 or not.
// If not, show the setup modal and instruct user setup inbox relays
let client = get_client();
let window_handle = window.window_handle();
let (tx, rx) = oneshot::channel::<Option<Vec<String>>>();
let this = Self {
account,
relays,
dock,
};
cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let relays = if let Ok(events) = client.database().query(filter).await {
if let Some(event) = events.first_owned() {
Some(
event
.tags
.filter_standardized(TagKind::Relay)
.filter_map(|t| match t {
TagStandard::Relay(url) => Some(url.to_string()),
_ => None,
})
.collect::<Vec<_>>(),
)
} else {
None
}
} else {
None
};
_ = tx.send(relays);
})
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(result) = rx.await {
if let Some(relays) = result {
_ = cx.update(|cx| {
_ = async_relays.update(cx, |this, cx| {
*this = Some(relays);
cx.notify();
});
});
} else {
_ = cx.update_window(window_handle, |_, window, cx| {
this.update(cx, |this: &mut Self, cx| {
this.render_setup_relays(window, cx)
})
});
}
}
})
.detach();
this.verify_user_relays(window, cx);
this
})
}
fn verify_user_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let Some(account) = Account::global(cx) else {
return;
};
let public_key = account.read(cx).get().public_key();
let client = get_client();
let window_handle = window.window_handle();
let (tx, rx) = oneshot::channel::<Option<Vec<String>>>();
cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
let relays = client
.database()
.query(filter)
.await
.ok()
.and_then(|events| events.first_owned())
.map(|event| {
event
.tags
.filter_standardized(TagKind::Relay)
.filter_map(|t| match t {
TagStandard::Relay(url) => Some(url.to_string()),
_ => None,
})
.collect::<Vec<_>>()
});
_ = tx.send(relays);
})
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(Some(relays)) = rx.await {
_ = cx.update(|cx| {
_ = this.update(cx, |this, cx| {
let relays = cx.new(|_| Some(relays));
this.relays = relays;
cx.notify();
});
});
} else {
_ = cx.update_window(window_handle, |_, window, cx| {
this.update(cx, |this: &mut Self, cx| {
this.render_setup_relays(window, cx)
})
});
}
})
.detach();
}
fn render_setup_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let relays = cx.new(|cx| Relays::new(None, window, cx));
@@ -254,18 +253,22 @@ impl AppView {
}))
}
fn render_account(&self) -> impl IntoElement {
fn render_account(&self, cx: &mut Context<Self>) -> impl IntoElement {
Button::new("account")
.ghost()
.xsmall()
.reverse()
.icon(Icon::new(IconName::ChevronDownSmall))
.child(
img(self.account.avatar())
.size_5()
.rounded_full()
.object_fit(ObjectFit::Cover),
)
.when_some(Account::global(cx), |this, account| {
let profile = account.read(cx).get();
this.child(
img(profile.avatar())
.size_5()
.rounded_full()
.object_fit(ObjectFit::Cover),
)
})
.popup_menu(move |this, _, _cx| {
this.menu(
"Profile",
@@ -286,16 +289,19 @@ impl AppView {
fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
match &action.panel {
PanelKind::Room(id) => match chat::init(id, window, cx) {
Ok(panel) => {
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
PanelKind::Room(id) => {
// User must be logged in to open a room
match chat::init(id, window, cx) {
Ok(panel) => {
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
Err(e) => window.push_notification(e.to_string(), cx),
}
Err(e) => window.push_notification(e.to_string(), cx),
},
}
PanelKind::Profile => {
let panel = Arc::new(profile::init(self.account.clone(), window, cx));
let panel = profile::init(window, cx);
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
@@ -319,8 +325,13 @@ impl AppView {
}
fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context<Self>) {
cx.background_spawn(async move { get_client().reset().await })
.detach();
let client = get_client();
cx.background_spawn(async move {
// Reset nostr client
client.reset().await
})
.detach();
window.replace_root(cx, |window, cx| {
Root::new(onboarding::init(window, cx).into(), window, cx)
@@ -353,7 +364,7 @@ impl Render for AppView {
.px_2()
.child(self.render_appearance_button(window, cx))
.child(self.render_relays_button(window, cx))
.child(self.render_account()),
.child(self.render_account(cx)),
),
)
.child(self.dock.clone())

View File

@@ -1,3 +1,4 @@
use account::registry::Account;
use anyhow::anyhow;
use async_utility::task::spawn;
use chats::{registry::ChatRegistry, room::Room};
@@ -58,14 +59,12 @@ struct ParsedMessage {
impl ParsedMessage {
pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self {
let avatar = profile.avatar().into();
let display_name = profile.name().into();
let content = SharedString::new(content);
let created_at = LastSeen(created_at).human_readable();
Self {
avatar,
display_name,
avatar: profile.avatar(),
display_name: profile.name(),
created_at,
content,
}
@@ -96,13 +95,10 @@ impl Message {
pub struct Chat {
// Panel
id: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
// Chat Room
room: WeakEntity<Room>,
messages: Entity<Vec<Message>>,
new_messages: Option<WeakEntity<Vec<Event>>>,
list_state: ListState,
subscriptions: Vec<Subscription>,
// New Message
@@ -119,21 +115,16 @@ impl Chat {
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
let new_messages = room
.read_with(cx, |this, _| this.new_messages.downgrade())
.ok();
let messages = cx.new(|_| vec![Message::placeholder()]);
let attaches = cx.new(|_| None);
let input = cx.new(|cx| {
TextInput::new(window, cx)
.appearance(false)
.text_size(ui::Size::Small)
.placeholder("Message...")
});
cx.new(|cx| {
let messages = cx.new(|_| vec![Message::placeholder()]);
let attaches = cx.new(|_| None);
let input = cx.new(|cx| {
TextInput::new(window, cx)
.appearance(false)
.text_size(ui::Size::Small)
.placeholder("Message...")
});
let subscriptions = vec![cx.subscribe_in(
&input,
window,
@@ -157,13 +148,10 @@ impl Chat {
});
let mut this = Self {
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
is_uploading: false,
id: id.to_string().into(),
room,
new_messages,
messages,
list_state,
input,
@@ -189,11 +177,16 @@ impl Chat {
return;
};
let room = model.read(cx);
let pubkeys: Vec<PublicKey> = room.members.iter().map(|m| m.public_key()).collect();
let client = get_client();
let (tx, rx) = oneshot::channel::<Vec<(PublicKey, bool)>>();
let pubkeys: Vec<PublicKey> = model
.read(cx)
.members
.iter()
.map(|m| m.public_key())
.collect();
cx.background_spawn(async move {
let mut result = Vec::new();
@@ -224,7 +217,7 @@ impl Chat {
if !item.1 {
let name = this
.room
.read_with(cx, |this, _| this.name())
.read_with(cx, |this, _| this.name().unwrap_or("Unnamed".into()))
.unwrap_or("Unnamed".into());
this.push_system_message(
@@ -245,35 +238,25 @@ impl Chat {
return;
};
let room = model.read(cx);
let client = get_client();
let (tx, rx) = oneshot::channel::<Events>();
let room = model.read(cx);
let pubkeys = room
.members
.iter()
.map(|m| m.public_key())
.collect::<Vec<_>>();
let recv = Filter::new()
let filter = Filter::new()
.kind(Kind::PrivateDirectMessage)
.author(room.owner.public_key())
.pubkeys(pubkeys.iter().copied());
let send = Filter::new()
.kind(Kind::PrivateDirectMessage)
.authors(pubkeys)
.pubkey(room.owner.public_key());
.authors(pubkeys.iter().copied())
.pubkeys(pubkeys);
cx.background_spawn(async move {
let Ok(recv_events) = client.database().query(recv).await else {
let Ok(events) = client.database().query(filter).await else {
return;
};
let Ok(send_events) = client.database().query(send).await else {
return;
};
let events = recv_events.merge(send_events);
_ = tx.send(events);
})
.detach();
@@ -303,13 +286,13 @@ impl Chat {
}
fn push_message(&self, content: String, window: &mut Window, cx: &mut Context<Self>) {
let Some(model) = self.room.upgrade() else {
let Some(account) = Account::global(cx) else {
return;
};
let old_len = self.messages.read(cx).len();
let room = model.read(cx);
let message = Message::new(ParsedMessage::new(&room.owner, &content, Timestamp::now()));
let profile = account.read(cx).get();
let message = Message::new(ParsedMessage::new(profile, &content, Timestamp::now()));
// Update message list
cx.update_entity(&self.messages, |this, cx| {
@@ -333,39 +316,40 @@ impl Chat {
return;
};
let old_len = self.messages.read(cx).len();
let room = model.read(cx);
let pubkeys = room.pubkeys();
let pubkeys = room
.members
.iter()
.map(|m| m.public_key())
.collect::<Vec<_>>();
let (messages, total) = {
let old_len = self.messages.read(cx).len();
let (messages, new_len) = {
let items: Vec<Message> = events
.into_iter()
.sorted_by_key(|ev| ev.created_at)
.filter_map(|ev| {
let mut other_pubkeys: Vec<_> = ev.tags.public_keys().copied().collect();
let mut other_pubkeys = ev.tags.public_keys().copied().collect::<Vec<_>>();
other_pubkeys.push(ev.pubkey);
if compare(&other_pubkeys, &pubkeys) {
let member = if let Some(member) =
room.members.iter().find(|&m| m.public_key() == ev.pubkey)
{
member.to_owned()
} else {
room.owner.to_owned()
};
let message =
Message::new(ParsedMessage::new(&member, &ev.content, ev.created_at));
Some(message)
} else {
None
if !compare(&other_pubkeys, &pubkeys) {
return None;
}
room.members
.iter()
.find(|m| m.public_key() == ev.pubkey)
.map(|member| {
Message::new(ParsedMessage::new(member, &ev.content, ev.created_at))
})
})
.collect();
let total = items.len();
(items, total)
// Used for update list state
let new_len = items.len();
(items, new_len)
};
cx.update_entity(&self.messages, |this, cx| {
@@ -373,25 +357,27 @@ impl Chat {
cx.notify();
});
self.list_state.splice(old_len..old_len, total);
self.list_state.splice(old_len..old_len, new_len);
}
fn load_new_messages(&mut self, cx: &mut Context<Self>) {
let Some(Some(model)) = self.new_messages.as_ref().map(|state| state.upgrade()) else {
let Some(room) = self.room.upgrade() else {
return;
};
let subscription = cx.observe(&model, |view, this, cx| {
let Some(model) = view.room.upgrade() else {
let subscription = cx.observe(&room, |view, this, cx| {
let room = this.read(cx);
if room.new_messages.is_empty() {
return;
};
let room = model.read(cx);
let old_messages = view.messages.read(cx);
let old_len = old_messages.len();
let items: Vec<Message> = this
.read(cx)
.new_messages
.iter()
.filter_map(|event| {
if let Some(profile) = room.member(&event.pubkey) {
@@ -466,29 +452,33 @@ impl Chat {
this.set_disabled(true, window, cx);
});
let room = model.read(cx);
// let subject = Tag::from_standardized_without_cell(TagStandard::Subject(room.title.clone()));
let pubkeys = room.public_keys();
let async_content = content.clone().to_string();
let client = get_client();
let window_handle = window.window_handle();
let (tx, rx) = oneshot::channel::<Vec<Error>>();
let room = model.read(cx);
let pubkeys = room.pubkeys();
let async_content = content.clone().to_string();
let tags: Vec<Tag> = room
.pubkeys()
.iter()
.filter_map(|pubkey| {
if pubkey != &room.owner.public_key() {
Some(Tag::public_key(*pubkey))
} else {
None
}
})
.collect();
// Send message to all pubkeys
cx.background_spawn(async move {
let signer = client.signer().await.unwrap();
let public_key = signer.get_public_key().await.unwrap();
let mut errors = Vec::new();
let tags: Vec<Tag> = pubkeys
.iter()
.filter_map(|pubkey| {
if pubkey != &public_key {
Some(Tag::public_key(*pubkey))
} else {
None
}
})
.collect();
for pubkey in pubkeys.iter() {
if let Err(e) = client
.send_private_msg(*pubkey, &async_content, tags.clone())
@@ -709,9 +699,8 @@ impl Panel for Chat {
fn title(&self, cx: &App) -> AnyElement {
self.room
.read_with(cx, |this, _cx| {
let name = this.name();
let facepill: Vec<String> =
.read_with(cx, |this, _| {
let facepill: Vec<SharedString> =
this.members.iter().map(|member| member.avatar()).collect();
div()
@@ -733,20 +722,12 @@ impl Panel for Chat {
)
})),
)
.child(name)
.when_some(this.name(), |this, name| this.child(name))
.into_any()
})
.unwrap_or("Unnamed".into_any())
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}

View File

@@ -8,4 +8,3 @@ mod welcome;
pub mod app;
pub mod onboarding;
pub mod startup;

View File

@@ -1,11 +1,11 @@
use common::{profile::NostrProfile, qr::create_qr, utils::preload};
use account::registry::Account;
use common::qr::create_qr;
use gpui::{
div, img, prelude::FluentBuilder, relative, svg, App, AppContext, ClipboardItem, Context, Div,
Entity, IntoElement, ParentElement, Render, Styled, Subscription, Window,
};
use nostr_connect::prelude::*;
use state::get_client;
use std::{path::PathBuf, time::Duration};
use std::{path::PathBuf, sync::Arc, time::Duration};
use ui::{
button::{Button, ButtonCustomVariant, ButtonVariants},
input::{InputEvent, TextInput},
@@ -16,8 +16,12 @@ use ui::{
use super::app;
const LOGO_URL: &str = "brand/coop.svg";
const TITLE: &str = "Welcome to Coop!";
const SUBTITLE: &str = "A Nostr client for secure communication.";
const ALPHA_MESSAGE: &str =
"Coop is in the alpha stage of development; It may contain bugs, unfinished features, or unexpected behavior.";
const JOIN_URL: &str = "https://start.njump.me/";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
@@ -62,7 +66,7 @@ impl Onboarding {
window,
move |this: &mut Self, _, input_event, window, cx| {
if let InputEvent::PressEnter = input_event {
this.privkey_login(window, cx);
this.login_with_private_key(window, cx);
}
},
)];
@@ -80,68 +84,50 @@ impl Onboarding {
})
}
fn use_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn login_with_nostr_connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let uri = self.connect_uri.clone();
let app_keys = self.app_keys.clone();
let window_handle = window.window_handle();
self.use_connect = true;
cx.notify();
// Show QR Code for login with Nostr Connect
self.use_connect(window, cx);
cx.spawn(|_, mut cx| async move {
let (tx, rx) = oneshot::channel::<NostrProfile>();
// Wait for connection
let (tx, rx) = oneshot::channel::<NostrConnect>();
cx.background_spawn(async move {
if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None)
{
if let Ok(uri) = signer.bunker_uri().await {
let client = get_client();
cx.background_spawn(async move {
if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None) {
tx.send(signer).ok();
}
})
.detach();
if let Some(public_key) = uri.remote_signer_public_key() {
let metadata = client
.fetch_metadata(*public_key, Duration::from_secs(2))
.await
.ok()
.unwrap_or_default();
cx.spawn(|this, cx| async move {
if let Ok(signer) = rx.await {
cx.spawn(|mut cx| async move {
let signer = Arc::new(signer);
if tx.send(NostrProfile::new(*public_key, metadata)).is_ok() {
_ = client.set_signer(signer).await;
_ = preload(client, *public_key).await;
}
}
if Account::login(signer, &cx).await.is_ok() {
_ = cx.update_window(window_handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(app::init(window, cx).into(), window, cx)
});
})
}
}
})
.detach();
if let Ok(profile) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(app::init(profile, window, cx).into(), window, cx)
});
})
.detach();
} else {
_ = cx.update(|cx| {
_ = this.update(cx, |this, cx| {
this.set_loading(false, cx);
});
});
}
})
.detach();
}
fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.use_privkey = true;
cx.notify();
}
fn reset(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.use_privkey = false;
self.use_connect = false;
cx.notify();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn privkey_login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn login_with_private_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.nsec_input.read(cx).text().to_string();
let window_handle = window.window_handle();
@@ -160,37 +146,47 @@ impl Onboarding {
// Show loading spinner
self.set_loading(true, cx);
cx.spawn(|_, mut cx| async move {
let client = get_client();
let (tx, rx) = oneshot::channel::<NostrProfile>();
cx.spawn(|this, mut cx| async move {
let signer = Arc::new(keys);
cx.background_spawn(async move {
if let Ok(public_key) = keys.get_public_key().await {
let metadata = client
.fetch_metadata(public_key, Duration::from_secs(2))
.await
.ok()
.unwrap_or_default();
if tx.send(NostrProfile::new(public_key, metadata)).is_ok() {
_ = client.set_signer(keys).await;
_ = preload(client, public_key).await;
}
}
})
.detach();
if let Ok(profile) = rx.await {
if Account::login(signer, &cx).await.is_ok() {
_ = cx.update_window(window_handle, |_, window, cx| {
window.replace_root(cx, |window, cx| {
Root::new(app::init(profile, window, cx).into(), window, cx)
Root::new(app::init(window, cx).into(), window, cx)
});
})
} else {
_ = cx.update(|cx| {
_ = this.update(cx, |this, cx| {
this.set_loading(false, cx);
});
});
}
})
.detach();
}
fn use_connect(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.use_connect = true;
cx.notify();
}
fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.use_privkey = true;
cx.notify();
}
fn reset(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.use_privkey = false;
self.use_connect = false;
cx.notify();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn render_selection(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
div()
.w_full()
@@ -205,7 +201,7 @@ impl Onboarding {
.primary()
.w_full()
.on_click(cx.listener(move |this, _, window, cx| {
this.use_connect(window, cx);
this.login_with_nostr_connect(window, cx);
})),
)
.child(
@@ -331,7 +327,7 @@ impl Onboarding {
.w_full()
.loading(self.is_loading)
.on_click(cx.listener(move |this, _, window, cx| {
this.privkey_login(window, cx);
this.login_with_private_key(window, cx);
})),
)
.child(
@@ -368,7 +364,7 @@ impl Render for Onboarding {
.gap_4()
.child(
svg()
.path("brand/coop.svg")
.path(LOGO_URL)
.size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
@@ -380,7 +376,7 @@ impl Render for Onboarding {
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child("Welcome to Coop!"),
.child(TITLE),
)
.child(
div()
@@ -388,19 +384,19 @@ impl Render for Onboarding {
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("A Nostr client for secure communication."),
.child(SUBTITLE),
),
),
)
.child(div().w_72().map(|_| {
if self.use_privkey {
self.render_privkey_login(cx)
} else if self.use_connect {
self.render_connect_login(cx)
} else {
self.render_selection(window, cx)
}
})),
.child(
div()
.w_72()
.map(|_| match (self.use_privkey, self.use_connect) {
(true, _) => self.render_privkey_login(cx),
(_, true) => self.render_connect_login(cx),
_ => self.render_selection(window, cx),
}),
),
)
.child(
div()
@@ -411,8 +407,8 @@ impl Render for Onboarding {
.items_center()
.justify_center()
.text_xs()
.text_center()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.text_align(gpui::TextAlign::Center)
.child(ALPHA_MESSAGE),
)
}

View File

@@ -1,5 +1,5 @@
use async_utility::task::spawn;
use common::{constants::IMAGE_SERVICE, profile::NostrProfile, utils::nip96_upload};
use common::{constants::IMAGE_SERVICE, utils::nip96_upload};
use gpui::{
div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter,
Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render,
@@ -8,7 +8,7 @@ use gpui::{
use nostr_sdk::prelude::*;
use smol::fs;
use state::get_client;
use std::str::FromStr;
use std::{str::FromStr, sync::Arc, time::Duration};
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
@@ -17,12 +17,12 @@ use ui::{
ContextModal, Disableable, Sizable, Size,
};
pub fn init(profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Profile> {
Profile::new(profile, window, cx)
pub fn init(window: &mut Window, cx: &mut App) -> Arc<Entity<Profile>> {
Arc::new(Profile::new(window, cx))
}
pub struct Profile {
profile: NostrProfile,
profile: Option<Metadata>,
// Form
name_input: Entity<TextInput>,
avatar_input: Entity<TextInput>,
@@ -32,60 +32,108 @@ pub struct Profile {
is_submitting: bool,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Profile {
pub fn new(mut profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Self> {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let window_handle = window.window_handle();
let name_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall);
if let Some(name) = profile.metadata().display_name.as_ref() {
input.set_text(name, window, cx);
}
input
});
let avatar_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small();
if let Some(picture) = profile.metadata().picture.as_ref() {
input.set_text(picture, window, cx);
}
input
});
let bio_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx)
TextInput::new(window, cx)
.text_size(Size::XSmall)
.multi_line();
if let Some(about) = profile.metadata().about.as_ref() {
input.set_text(about, window, cx);
} else {
input.set_placeholder("A short introduce about you.");
}
input
});
let website_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall);
if let Some(website) = profile.metadata().website.as_ref() {
input.set_text(website, window, cx);
} else {
input.set_placeholder("https://your-website.com");
}
input
.placeholder("Alice")
});
cx.new(|cx| Self {
profile,
name_input,
avatar_input,
bio_input,
website_input,
is_loading: false,
is_submitting: false,
name: "Profile".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
let avatar_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.small()
.placeholder("https://example.com/avatar.png")
});
let website_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("https://your-website.com")
});
let bio_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.multi_line()
.placeholder("A short introduce about you.")
});
cx.new(|cx| {
let this = Self {
name_input,
avatar_input,
bio_input,
website_input,
profile: None,
is_loading: false,
is_submitting: false,
name: "Profile".into(),
focus_handle: cx.focus_handle(),
};
let client = get_client();
let (tx, rx) = oneshot::channel::<Option<Metadata>>();
cx.background_spawn(async move {
let result = async {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let metadata = client
.fetch_metadata(public_key, Duration::from_secs(2))
.await?;
Ok::<_, anyhow::Error>(metadata)
}
.await;
if let Ok(metadata) = result {
_ = tx.send(Some(metadata));
} else {
_ = tx.send(None);
};
})
.detach();
cx.spawn(|this, mut cx| async move {
if let Ok(Some(metadata)) = rx.await {
_ = cx.update_window(window_handle, |_, window, cx| {
_ = this.update(cx, |this: &mut Profile, cx| {
this.avatar_input.update(cx, |this, cx| {
if let Some(avatar) = metadata.picture.as_ref() {
this.set_text(avatar, window, cx);
}
});
this.bio_input.update(cx, |this, cx| {
if let Some(bio) = metadata.about.as_ref() {
this.set_text(bio, window, cx);
}
});
this.name_input.update(cx, |this, cx| {
if let Some(display_name) = metadata.display_name.as_ref() {
this.set_text(display_name, window, cx);
}
});
this.website_input.update(cx, |this, cx| {
if let Some(website) = metadata.website.as_ref() {
this.set_text(website, window, cx);
}
});
this.profile = Some(metadata);
cx.notify();
});
});
}
})
.detach();
this
})
}
@@ -164,12 +212,13 @@ impl Profile {
let bio = self.bio_input.read(cx).text().to_string();
let website = self.website_input.read(cx).text().to_string();
let mut new_metadata = self
.profile
.metadata()
.to_owned()
.display_name(name)
.about(bio);
let old_metadata = if let Some(metadata) = self.profile.as_ref() {
metadata.clone()
} else {
Metadata::default()
};
let mut new_metadata = old_metadata.display_name(name).about(bio);
if let Ok(url) = Url::from_str(&avatar) {
new_metadata = new_metadata.picture(url);
@@ -221,14 +270,6 @@ impl Panel for Profile {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}

View File

@@ -1,9 +1,6 @@
use async_utility::task::spawn;
use chats::{registry::ChatRegistry, room::Room};
use common::{
profile::NostrProfile,
utils::{random_name, signer_public_key},
};
use common::{profile::NostrProfile, utils::random_name};
use gpui::{
div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App,
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
@@ -86,15 +83,16 @@ impl Compose {
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
cx.background_spawn(async move {
if let Ok(public_key) = signer_public_key(client).await {
if let Ok(profiles) = client.database().contacts(public_key).await {
let members: Vec<NostrProfile> = profiles
.into_iter()
.map(|profile| NostrProfile::new(profile.public_key(), profile.metadata()))
.collect();
let signer = client.signer().await.unwrap();
let public_key = signer.get_public_key().await.unwrap();
_ = tx.send(members);
}
if let Ok(profiles) = client.database().contacts(public_key).await {
let members: Vec<NostrProfile> = profiles
.into_iter()
.map(|profile| NostrProfile::new(profile.public_key(), profile.metadata()))
.collect();
_ = tx.send(members);
}
})
.detach();
@@ -178,17 +176,19 @@ impl Compose {
});
if let Some(chats) = ChatRegistry::global(cx) {
let room = Room::parse(&event, cx);
let room = Room::new(&event, cx);
chats.update(cx, |state, cx| match state.new_room(room, cx) {
Ok(_) => {
// TODO: open chat panel
window.close_modal(cx);
}
Err(e) => {
_ = this.update(cx, |this, cx| {
this.set_error(Some(e.to_string().into()), cx);
});
chats.update(cx, |state, cx| {
match state.push_room(room, cx) {
Ok(_) => {
// TODO: open chat panel
window.close_modal(cx);
}
Err(e) => {
_ = this.update(cx, |this, cx| {
this.set_error(Some(e.to_string().into()), cx);
});
}
}
});
}

View File

@@ -1,182 +0,0 @@
use crate::views::app::{AddPanel, PanelKind};
use chats::registry::ChatRegistry;
use gpui::{
div, img, percentage, prelude::FluentBuilder, px, relative, Context, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
TextAlign, Window,
};
use ui::{
dock_area::dock::DockPlacement,
skeleton::Skeleton,
theme::{scale::ColorScaleStep, ActiveTheme},
v_flex, Collapsible, Icon, IconName, StyledExt,
};
pub struct Inbox {
label: SharedString,
is_collapsed: bool,
}
impl Inbox {
pub fn new(_window: &mut Window, _cx: &mut Context<'_, Self>) -> Self {
Self {
label: "Inbox".into(),
is_collapsed: false,
}
}
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
(0..total).map(|_| {
div()
.h_8()
.px_1()
.flex()
.items_center()
.gap_2()
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
.child(Skeleton::new().w_20().h_3().rounded_sm())
})
}
fn render_item(&self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if let Some(chats) = ChatRegistry::global(cx) {
div().map(|this| {
let state = chats.read(cx);
let rooms = state.rooms();
if state.is_loading() {
this.children(self.render_skeleton(5))
} else if rooms.is_empty() {
this.px_1()
.w_full()
.h_20()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_align(TextAlign::Center)
.rounded(px(cx.theme().radius))
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
.child(
div()
.text_xs()
.font_semibold()
.line_height(relative(1.2))
.child("No chats"),
)
.child(
div()
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child("Recent chats will appear here."),
)
} else {
this.children(rooms.iter().map(|model| {
let room = model.read(cx);
let room_id: SharedString = room.id.to_string().into();
div()
.id(room_id)
.h_8()
.px_1()
.flex()
.items_center()
.justify_between()
.text_xs()
.rounded(px(cx.theme().radius))
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
.child(div().flex_1().truncate().font_medium().map(|this| {
if room.is_group {
this.flex()
.items_center()
.gap_2()
.child(img("brand/avatar.png").size_6().rounded_full())
.child(room.name())
} else {
this.when_some(room.members.first(), |this, sender| {
this.flex()
.items_center()
.gap_2()
.child(
img(sender.avatar())
.size_6()
.rounded_full()
.flex_shrink_0(),
)
.child(sender.name())
})
}
}))
.child(
div()
.flex_shrink_0()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(room.last_seen.ago()),
)
.on_click({
let id = room.id;
cx.listener(move |this, _, window, cx| {
this.action(id, window, cx);
})
})
}))
}
})
} else {
div().children(self.render_skeleton(5))
}
}
fn action(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(
Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)),
cx,
);
}
}
impl Collapsible for Inbox {
fn collapsed(mut self, collapsed: bool) -> Self {
self.is_collapsed = collapsed;
self
}
fn is_collapsed(&self) -> bool {
self.is_collapsed
}
}
impl Render for Inbox {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.px_2()
.gap_1()
.child(
div()
.id("inbox")
.h_7()
.px_1()
.flex()
.items_center()
.rounded(px(cx.theme().radius))
.text_xs()
.font_semibold()
.child(
Icon::new(IconName::ChevronDown)
.size_6()
.when(self.is_collapsed, |this| {
this.rotate(percentage(270. / 360.))
}),
)
.child(self.label.clone())
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.on_click(cx.listener(move |view, _event, _window, cx| {
view.is_collapsed = !view.is_collapsed;
cx.notify();
})),
)
.when(!self.is_collapsed, |this| {
this.child(self.render_item(window, cx))
})
}
}

View File

@@ -1,33 +1,32 @@
use crate::views::sidebar::inbox::Inbox;
use chats::{registry::ChatRegistry, room::Room};
use compose::Compose;
use gpui::{
div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Window,
div, img, percentage, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext,
Context, Div, Empty, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Stateful, StatefulInteractiveElement, Styled,
Window,
};
use ui::{
button::{Button, ButtonRounded, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
};
use super::app::AddPanel;
mod compose;
mod inbox;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
Sidebar::new(window, cx)
}
pub struct Sidebar {
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
// Dock
inbox: Entity<Inbox>,
label: SharedString,
is_collapsed: bool,
}
impl Sidebar {
@@ -35,19 +34,19 @@ impl Sidebar {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let inbox = cx.new(|cx| Inbox::new(window, cx));
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
let label = SharedString::from("Inbox");
Self {
name: "Sidebar".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
inbox,
is_collapsed: false,
focus_handle,
label,
}
}
fn show_compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn render_compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let compose = cx.new(|cx| Compose::new(window, cx));
window.open_modal(cx, move |modal, window, cx| {
@@ -79,6 +78,73 @@ impl Sidebar {
)
})
}
fn render_room(&self, ix: usize, room: &Entity<Room>, cx: &Context<Self>) -> Stateful<Div> {
let room = room.read(cx);
div()
.id(ix)
.px_1()
.h_8()
.w_full()
.flex()
.items_center()
.justify_between()
.text_xs()
.rounded(px(cx.theme().radius))
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
.child(div().flex_1().truncate().font_medium().map(|this| {
if room.is_group() {
this.flex()
.items_center()
.gap_2()
.child(
div()
.flex()
.justify_center()
.items_center()
.size_6()
.rounded_full()
.bg(cx.theme().accent.step(cx, ColorScaleStep::THREE))
.child(Icon::new(IconName::GroupFill).size_3().text_color(
cx.theme().accent.step(cx, ColorScaleStep::TWELVE),
)),
)
.when_some(room.name(), |this, name| this.child(name))
} else {
this.when_some(room.first_member(), |this, member| {
this.flex()
.items_center()
.gap_2()
.child(img(member.avatar()).size_6().rounded_full().flex_shrink_0())
.child(member.name())
})
}
}))
.child(
div()
.flex_shrink_0()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(room.ago()),
)
.on_click({
let id = room.id;
cx.listener(move |this, _, window, cx| {
this.open(id, window, cx);
})
})
}
fn open(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(
Box::new(AddPanel::new(
super::app::PanelKind::Room(id),
ui::dock_area::dock::DockPlacement::Center,
)),
cx,
);
}
}
impl Panel for Sidebar {
@@ -90,14 +156,6 @@ impl Panel for Sidebar {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
@@ -117,41 +175,116 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.w_full()
.py_3()
.gap_3()
let entity = cx.entity();
div()
.flex()
.flex_col()
.size_full()
.child(
v_flex().px_2().gap_1().child(
div()
.id("new")
.flex()
.items_center()
.gap_2()
.px_1()
.h_7()
.text_xs()
.font_semibold()
.rounded(px(cx.theme().radius))
.child(
div()
.size_6()
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
.child(
Icon::new(IconName::ComposeFill)
.small()
.text_color(cx.theme().base.darken(cx)),
),
)
.child("New Message")
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.on_click(cx.listener(|this, _, window, cx| this.show_compose(window, cx))),
),
div()
.px_2()
.py_3()
.w_full()
.flex_shrink_0()
.flex()
.flex_col()
.gap_1()
.child(
div()
.id("new_message")
.flex()
.items_center()
.gap_2()
.px_1()
.h_7()
.text_xs()
.font_semibold()
.rounded(px(cx.theme().radius))
.child(
div()
.size_6()
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
.child(
Icon::new(IconName::ComposeFill)
.small()
.text_color(cx.theme().base.darken(cx)),
),
)
.child("New Message")
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.on_click(cx.listener(|this, _, window, cx| {
// Open compose modal
this.render_compose(window, cx);
})),
)
.child(Empty),
)
.child(
div()
.px_2()
.w_full()
.flex_1()
.flex()
.flex_col()
.gap_1()
.child(
div()
.id("inbox_header")
.px_1()
.h_7()
.flex()
.items_center()
.flex_shrink_0()
.rounded(px(cx.theme().radius))
.text_xs()
.font_semibold()
.child(
Icon::new(IconName::ChevronDown)
.size_6()
.when(self.is_collapsed, |this| {
this.rotate(percentage(270. / 360.))
}),
)
.child(self.label.clone())
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.on_click(cx.listener(move |view, _event, _window, cx| {
view.is_collapsed = !view.is_collapsed;
cx.notify();
})),
)
.when(!self.is_collapsed, |this| {
this.flex_1()
.w_full()
.when_some(ChatRegistry::global(cx), |this, state| {
let rooms = state.read(cx).rooms();
let len = rooms.len();
this.child(
uniform_list(
entity,
"rooms",
len,
move |this, range, _, cx| {
let mut items = vec![];
for ix in range {
if let Some(room) = rooms.get(ix) {
items.push(this.render_room(ix, room, cx));
}
}
items
},
)
.size_full(),
)
})
}),
)
.child(self.inbox.clone())
}
}

View File

@@ -1,32 +0,0 @@
use gpui::{
div, svg, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Window,
};
use ui::theme::{scale::ColorScaleStep, ActiveTheme};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
Startup::new(window, cx)
}
pub struct Startup {}
impl Startup {
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self {})
}
}
impl Render for Startup {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
}
}