chore: restructure

This commit is contained in:
2025-04-13 08:03:22 +07:00
parent b667dd3f1c
commit 5c5748a80c
28 changed files with 56 additions and 112 deletions

38
crates/coop/Cargo.toml Normal file
View File

@@ -0,0 +1,38 @@
[package]
name = "coop"
version.workspace = true
edition.workspace = true
publish.workspace = true
[[bin]]
name = "coop"
path = "src/main.rs"
[dependencies]
ui = { path = "../ui" }
common = { path = "../common" }
global = { path = "../global" }
chats = { path = "../chats" }
account = { path = "../account" }
auto_update = { path = "../auto_update" }
gpui.workspace = true
reqwest_client.workspace = true
nostr-connect.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
itertools.workspace = true
dirs.workspace = true
rust-embed.workspace = true
log.workspace = true
smallvec.workspace = true
smol.workspace = true
oneshot.workspace = true
rustls = "0.23.23"
futures = "0.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

27
crates/coop/src/asset.rs Normal file
View File

@@ -0,0 +1,27 @@
use anyhow::anyhow;
use gpui::{AssetSource, Result, SharedString};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "../../assets"]
pub struct Assets;
impl AssetSource for Assets {
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
Self::get(path)
.map(|f| Some(f.data))
.ok_or_else(|| anyhow!("could not find asset at path \"{}\"", path))
}
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
Ok(Self::iter()
.filter_map(|p| {
if p.starts_with(path) {
Some(p.into())
} else {
None
}
})
.collect())
}
}

View File

@@ -0,0 +1,339 @@
use account::Account;
use common::profile::SharedProfile;
use global::get_client;
use gpui::{
actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis,
Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription,
Task, Window,
};
use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
use std::sync::Arc;
use ui::{
button::{Button, ButtonRounded, ButtonVariants},
dock_area::{dock::DockPlacement, panel::PanelView, DockArea, DockItem},
popup_menu::PopupMenuExt,
theme::{scale::ColorScaleStep, ActiveTheme, Appearance, Theme},
ContextModal, Icon, IconName, Root, Sizable, TitleBar,
};
use crate::views::{chat, contacts, profile, relays, settings, welcome};
use crate::views::{onboarding, sidebar};
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub enum PanelKind {
Room(u64),
Profile,
Contacts,
Settings,
}
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub struct AddPanel {
panel: PanelKind,
position: DockPlacement,
}
impl AddPanel {
pub fn new(panel: PanelKind, position: DockPlacement) -> Self {
Self { panel, position }
}
}
// Dock actions
impl_internal_actions!(dock, [AddPanel]);
// Account actions
actions!(account, [Logout]);
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
ChatSpace::new(window, cx)
}
pub struct ChatSpace {
titlebar: bool,
dock: Entity<DockArea>,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl ChatSpace {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let account = Account::global(cx);
let dock = cx.new(|cx| DockArea::new(window, cx));
let titlebar = false;
cx.new(|cx| {
let mut this = Self {
dock,
titlebar,
subscriptions: smallvec![cx.observe_in(
&account,
window,
|this: &mut ChatSpace, account, window, cx| {
if account.read(cx).profile.is_some() {
this.open_chats(window, cx);
} else {
this.open_onboarding(window, cx);
}
},
)],
};
if Account::global(cx).read(cx).profile.is_some() {
this.open_chats(window, cx);
} else {
this.open_onboarding(window, cx);
}
this
})
}
pub fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
if let Some(Some(root)) = window.root::<Root>() {
if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() {
let panel = Arc::new(panel);
let center = DockItem::panel(panel);
chatspace.update(cx, |this, cx| {
this.dock.update(cx, |this, cx| {
this.set_center(center, window, cx);
});
});
}
}
}
fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let panel = Arc::new(onboarding::init(window, cx));
let center = DockItem::panel(panel);
self.dock.update(cx, |this, cx| {
this.set_center(center, window, cx);
});
}
fn open_chats(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.show_titlebar(cx);
let weak_dock = self.dock.downgrade();
let left = DockItem::panel(Arc::new(sidebar::init(window, cx)));
let center = DockItem::split_with_sizes(
Axis::Vertical,
vec![DockItem::tabs(
vec![Arc::new(welcome::init(window, cx))],
None,
&weak_dock,
window,
cx,
)],
vec![None],
&weak_dock,
window,
cx,
);
self.dock.update(cx, |this, cx| {
this.set_left_dock(left, Some(px(260.)), true, window, cx);
this.set_center(center, window, cx);
});
}
fn show_titlebar(&mut self, cx: &mut Context<Self>) {
self.titlebar = true;
cx.notify();
}
fn render_appearance_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
Button::new("appearance")
.xsmall()
.ghost()
.map(|this| {
if cx.theme().appearance.is_dark() {
this.icon(IconName::Sun)
} else {
this.icon(IconName::Moon)
}
})
.on_click(cx.listener(|_, _, window, cx| {
if cx.theme().appearance.is_dark() {
Theme::change(Appearance::Light, Some(window), cx);
} else {
Theme::change(Appearance::Dark, Some(window), cx);
}
}))
}
fn render_account_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
Button::new("account")
.ghost()
.xsmall()
.reverse()
.icon(Icon::new(IconName::ChevronDownSmall))
.when_some(
Account::global(cx).read(cx).profile.as_ref(),
|this, profile| this.child(img(profile.shared_avatar()).size_5()),
)
.popup_menu(move |this, _, _cx| {
this.menu(
"Profile",
Box::new(AddPanel::new(PanelKind::Profile, DockPlacement::Right)),
)
.menu(
"Contacts",
Box::new(AddPanel::new(PanelKind::Contacts, DockPlacement::Right)),
)
.menu(
"Settings",
Box::new(AddPanel::new(PanelKind::Settings, DockPlacement::Center)),
)
.separator()
.menu("Change account", Box::new(Logout))
})
}
fn render_relays_btn(&self, cx: &mut Context<Self>) -> impl IntoElement {
Button::new("relays")
.xsmall()
.ghost()
.icon(IconName::Relays)
.on_click(cx.listener(|this, _, window, cx| {
this.render_edit_relays(window, cx);
}))
}
fn render_edit_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let relays = relays::init(window, cx);
window.open_modal(cx, move |this, window, cx| {
let is_loading = relays.read(cx).loading();
this.width(px(420.))
.title("Edit your Messaging Relays")
.child(relays.clone())
.footer(
div()
.p_2()
.border_t_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
.child(
Button::new("update_inbox_relays_btn")
.label("Update")
.primary()
.bold()
.rounded(ButtonRounded::Large)
.w_full()
.loading(is_loading)
.on_click(window.listener_for(&relays, |this, _, window, cx| {
this.update(window, cx);
})),
),
)
});
}
fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
match &action.panel {
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),
}
}
PanelKind::Profile => {
let panel = profile::init(window, cx);
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
PanelKind::Contacts => {
let panel = Arc::new(contacts::init(window, cx));
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
PanelKind::Settings => {
let panel = Arc::new(settings::init(window, cx));
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
};
}
fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context<Self>) {
let client = get_client();
let reset: Task<Result<(), anyhow::Error>> = cx.background_spawn(async move {
client.reset().await;
Ok(())
});
cx.spawn_in(window, async move |_, cx| {
if reset.await.is_ok() {
cx.update(|_, cx| {
Account::global(cx).update(cx, |this, cx| {
this.profile = None;
cx.notify();
});
})
.ok();
};
})
.detach();
}
}
impl Render for ChatSpace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
div()
.relative()
.size_full()
.child(
div()
.flex()
.flex_col()
.size_full()
// Title Bar
.when(self.titlebar, |this| {
this.child(
TitleBar::new()
// Left side
.child(div())
// Right side
.child(
div()
.flex()
.items_center()
.justify_end()
.gap_2()
.px_2()
.child(self.render_appearance_btn(cx))
.child(self.render_relays_btn(cx))
.child(self.render_account_btn(cx)),
),
)
})
// Dock
.child(self.dock.clone()),
)
// Notifications
.child(div().absolute().top_8().children(notification_layer))
// Modals
.children(modal_layer)
// Actions
.on_action(cx.listener(Self::on_panel_action))
.on_action(cx.listener(Self::on_logout_action))
}
}

399
crates/coop/src/main.rs Normal file
View File

@@ -0,0 +1,399 @@
use anyhow::{anyhow, Error};
use asset::Assets;
use auto_update::AutoUpdater;
use chats::ChatRegistry;
use futures::{select, FutureExt};
#[cfg(not(target_os = "linux"))]
use global::constants::APP_NAME;
use global::{
constants::{ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, NEW_MESSAGE_SUB_ID},
get_client,
};
use gpui::{
actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem,
WindowBounds, WindowKind, WindowOptions,
};
#[cfg(not(target_os = "linux"))]
use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations};
use nostr_sdk::{
nips::nip01::Coordinate, pool::prelude::ReqExitPolicy, Client, Event, EventBuilder, EventId,
Filter, JsonUtil, Keys, Kind, Metadata, PublicKey, RelayMessage, RelayPoolNotification,
SubscribeAutoCloseOptions, SubscriptionId, Tag,
};
use smol::Timer;
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
use ui::{theme::Theme, Root};
pub(crate) mod asset;
pub(crate) mod chat_space;
pub(crate) mod views;
actions!(coop, [Quit]);
#[derive(Debug)]
enum Signal {
/// Receive event
Event(Event),
/// Receive metadata
Metadata(Box<(PublicKey, Option<Metadata>)>),
/// Receive eose
Eose,
/// Receive app updates
AppUpdates(Event),
}
fn main() {
// Enable logging
tracing_subscriber::fmt::init();
// Fix crash on startup
_ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(2048);
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(500);
// Initialize nostr client
let client = get_client();
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
// Initialize application
let app = Application::new()
.with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()));
// Connect to default relays
app.background_executor()
.spawn(async move {
for relay in BOOTSTRAP_RELAYS.into_iter() {
if let Err(e) = client.add_relay(relay).await {
log::error!("Failed to add relay {}: {}", relay, e);
}
}
// Establish connection to bootstrap relays
client.connect().await;
log::info!("Connected to bootstrap relays");
log::info!("Subscribing to app updates...");
let coordinate = Coordinate {
kind: Kind::Custom(32267),
public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"),
identifier: APP_ID.into(),
};
let filter = Filter::new()
.kind(Kind::ArticlesCurationSet)
.coordinate(&coordinate)
.limit(1);
if let Err(e) = client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
{
log::error!("Failed to subscribe for app updates: {}", e);
}
})
.detach();
// Handle batch metadata
app.background_executor()
.spawn(async move {
const BATCH_SIZE: usize = 500;
const BATCH_TIMEOUT: Duration = Duration::from_millis(300);
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
let mut timeout = Box::pin(Timer::after(BATCH_TIMEOUT).fuse());
select! {
pubkeys = batch_rx.recv().fuse() => {
match pubkeys {
Ok(keys) => {
batch.extend(keys);
if batch.len() >= BATCH_SIZE {
sync_metadata(mem::take(&mut batch), client, opts).await;
}
}
Err(_) => break,
}
}
_ = timeout => {
if !batch.is_empty() {
sync_metadata(mem::take(&mut batch), client, opts).await;
}
}
}
}
})
.detach();
// Handle notifications
app.background_executor()
.spawn(async move {
let rng_keys = Keys::generate();
let all_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
let mut notifications = client.notifications();
while let Ok(notification) = notifications.recv().await {
if let RelayPoolNotification::Message { message, .. } = notification {
match message {
RelayMessage::Event {
event,
subscription_id,
} => {
match event.kind {
Kind::GiftWrap => {
let event = match get_unwrapped(event.id).await {
Ok(event) => event,
Err(_) => match client.unwrap_gift_wrap(&event).await {
Ok(unwrap) => {
match unwrap.rumor.sign_with_keys(&rng_keys) {
Ok(ev) => {
set_unwrapped(event.id, &ev, &rng_keys)
.await
.ok();
ev
}
Err(_) => continue,
}
}
Err(_) => continue,
},
};
let mut pubkeys = vec![];
pubkeys.extend(event.tags.public_keys());
pubkeys.push(event.pubkey);
// Send all pubkeys to the batch to sync metadata
batch_tx.send(pubkeys).await.ok();
// Save the event to the database, use for query directly.
client.database().save_event(&event).await.ok();
// Send this event to the GPUI
if new_id == *subscription_id {
event_tx.send(Signal::Event(event)).await.ok();
}
}
Kind::Metadata => {
let metadata = Metadata::from_json(&event.content).ok();
event_tx
.send(Signal::Metadata(Box::new((event.pubkey, metadata))))
.await
.ok();
}
Kind::ContactList => {
if let Ok(signer) = client.signer().await {
if let Ok(public_key) = signer.get_public_key().await {
if public_key == event.pubkey {
let pubkeys = event
.tags
.public_keys()
.copied()
.collect::<Vec<_>>();
batch_tx.send(pubkeys).await.ok();
}
}
}
}
Kind::ReleaseArtifactSet => {
let filter = Filter::new()
.ids(event.tags.event_ids().copied())
.kind(Kind::FileMetadata);
if let Err(e) = client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
{
log::error!("Failed to subscribe for file metadata: {}", e);
} else {
event_tx
.send(Signal::AppUpdates(event.into_owned()))
.await
.ok();
}
}
_ => {}
}
}
RelayMessage::EndOfStoredEvents(subscription_id) => {
if all_id == *subscription_id {
event_tx.send(Signal::Eose).await.ok();
}
}
_ => {}
}
}
}
})
.detach();
app.run(move |cx| {
// Bring the app to the foreground
cx.activate(true);
// Register the `quit` function
cx.on_action(quit);
// Register the `quit` function with CMD+Q
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
// Set menu items
cx.set_menus(vec![Menu {
name: "Coop".into(),
items: vec![MenuItem::action("Quit", Quit)],
}]);
// Set up the window options
let 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,
app_id: Some(APP_ID.to_owned()),
..Default::default()
};
// Open a window with default options
cx.open_window(opts, |window, cx| {
// Automatically sync theme with system appearance
window
.observe_window_appearance(|window, cx| {
Theme::sync_system_appearance(Some(window), cx);
})
.detach();
// Root Entity
cx.new(|cx| {
// Initialize components
ui::init(cx);
// Initialize auto update
auto_update::init(cx);
// Initialize chat state
chats::init(cx);
// Initialize account state
account::init(cx);
// Spawn a task to handle events from nostr channel
cx.spawn_in(window, async move |_, cx| {
let chats = cx.update(|_, cx| ChatRegistry::global(cx)).unwrap();
let auto_updater = cx.update(|_, cx| AutoUpdater::global(cx)).unwrap();
while let Ok(signal) = event_rx.recv().await {
cx.update(|window, cx| {
match signal {
Signal::Event(event) => {
chats.update(cx, |this, cx| {
this.push_message(event, window, cx)
});
}
Signal::Metadata(data) => {
chats.update(cx, |this, cx| {
this.add_profile(data.0, data.1, cx)
});
}
Signal::Eose => {
chats.update(cx, |this, cx| {
// This function maybe called multiple times
// TODO: only handle the last EOSE signal
this.load_rooms(window, cx)
});
}
Signal::AppUpdates(event) => {
// TODO: add settings for auto updates
auto_updater.update(cx, |this, cx| {
this.update(event, cx);
})
}
};
})
.ok();
}
})
.detach();
Root::new(chat_space::init(window, cx).into(), window, cx)
})
})
.expect("Failed to open window. Please restart the application.");
});
}
async fn set_unwrapped(root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> {
let client = get_client();
let event = EventBuilder::new(Kind::Custom(9001), event.as_json())
.tags(vec![Tag::event(root)])
.sign(keys)
.await?;
client.database().save_event(&event).await?;
Ok(())
}
async fn get_unwrapped(gift_wrap: EventId) -> Result<Event, Error> {
let client = get_client();
let filter = Filter::new()
.kind(Kind::Custom(9001))
.event(gift_wrap)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
let parsed = Event::from_json(event.content)?;
Ok(parsed)
} else {
Err(anyhow!("Event not found"))
}
}
async fn sync_metadata(
buffer: HashSet<PublicKey>,
client: &Client,
opts: SubscribeAutoCloseOptions,
) {
let kinds = vec![
Kind::Metadata,
Kind::ContactList,
Kind::InboxRelays,
Kind::UserStatus,
];
let filter = Filter::new()
.authors(buffer.iter().cloned())
.limit(buffer.len() * kinds.len())
.kinds(kinds);
if let Err(e) = client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))
.await
{
log::error!("Failed to sync metadata: {e}");
}
}
fn quit(_: &Quit, cx: &mut App) {
log::info!("Gracefully quitting the application . . .");
cx.quit();
}

View File

@@ -0,0 +1,581 @@
use anyhow::{anyhow, Error};
use async_utility::task::spawn;
use chats::{message::RoomMessage, room::Room, ChatRegistry};
use common::{nip96_upload, profile::SharedProfile};
use global::{constants::IMAGE_SERVICE, get_client};
use gpui::{
div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext,
Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement,
IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Render,
SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, Window,
};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use smol::fs;
use std::{collections::HashMap, sync::Arc};
use ui::{
button::{Button, ButtonRounded, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::{InputEvent, TextInput},
notification::Notification,
popup_menu::PopupMenu,
text::RichText,
theme::{scale::ColorScaleStep, ActiveTheme},
v_flex, ContextModal, Icon, IconName, Sizable, StyledExt,
};
const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages.";
pub fn init(id: &u64, window: &mut Window, cx: &mut App) -> Result<Arc<Entity<Chat>>, Error> {
if let Some(room) = ChatRegistry::global(cx).read(cx).room(id, cx) {
Ok(Arc::new(Chat::new(id, room, window, cx)))
} else {
Err(anyhow!("Chat Room not found."))
}
}
pub struct Chat {
// Panel
id: SharedString,
focus_handle: FocusHandle,
// Chat Room
room: Entity<Room>,
messages: Entity<Vec<RoomMessage>>,
text_data: HashMap<EventId, RichText>,
list_state: ListState,
// New Message
input: Entity<TextInput>,
// Media Attachment
attaches: Entity<Option<Vec<Url>>>,
is_uploading: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
}
impl Chat {
pub fn new(id: &u64, room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
let messages = cx.new(|_| vec![RoomMessage::announcement()]);
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 mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe_in(
&input,
window,
move |this: &mut Self, _, event, window, cx| {
if let InputEvent::PressEnter = event {
this.send_message(window, cx);
}
},
));
subscriptions.push(cx.subscribe_in(
&room,
window,
move |this, _, event, _window, cx| {
let old_len = this.messages.read(cx).len();
let message = event.event.clone();
cx.update_entity(&this.messages, |this, cx| {
this.extend(vec![message]);
cx.notify();
});
this.list_state.splice(old_len..old_len, 1);
},
));
// Initialize list state
// [item_count] always equal to 1 at the beginning
let list_state = ListState::new(1, ListAlignment::Bottom, px(1024.), {
let this = cx.entity().downgrade();
move |ix, window, cx| {
this.update(cx, |this, cx| {
this.render_message(ix, window, cx).into_any_element()
})
.unwrap()
}
});
let this = Self {
focus_handle: cx.focus_handle(),
is_uploading: false,
id: id.to_string().into(),
text_data: HashMap::new(),
room,
messages,
list_state,
input,
attaches,
subscriptions,
};
// Verify messaging relays of all members
this.verify_messaging_relays(window, cx);
// Load all messages from database
this.load_messages(window, cx);
this
})
}
fn verify_messaging_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let room = self.room.read(cx);
let task = room.messaging_relays(cx);
cx.spawn_in(window, async move |this, cx| {
if let Ok(result) = task.await {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
result.into_iter().for_each(|item| {
if !item.1 {
let profile = this
.room
.read_with(cx, |this, _| this.profile_by_pubkey(&item.0, cx));
this.push_system_message(
format!("{} {}", profile.shared_name(), ALERT),
cx,
);
}
});
})
.ok();
})
.ok();
}
})
.detach();
}
fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
let room = self.room.read(cx);
let task = room.load_messages(cx);
cx.spawn_in(window, async move |this, cx| {
if let Ok(events) = task.await {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
let old_len = this.messages.read(cx).len();
let new_len = events.len();
// Extend the messages list with the new events
this.messages.update(cx, |this, cx| {
this.extend(events);
cx.notify();
});
// Update list state with the new messages
this.list_state.splice(old_len..old_len, new_len);
cx.notify();
})
.ok();
})
.ok();
}
})
.detach();
}
fn push_system_message(&self, content: String, cx: &mut Context<Self>) {
let old_len = self.messages.read(cx).len();
let message = RoomMessage::system(content.into());
cx.update_entity(&self.messages, |this, cx| {
this.extend(vec![message]);
cx.notify();
});
self.list_state.splice(old_len..old_len, 1);
}
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let mut content = self.input.read(cx).text().to_string();
// Get all attaches and merge its with message
if let Some(attaches) = self.attaches.read(cx).as_ref() {
let merged = attaches
.iter()
.map(|url| url.to_string())
.collect::<Vec<_>>()
.join("\n");
content = format!("{}\n{}", content, merged)
}
if content.is_empty() {
window.push_notification("Cannot send an empty message", cx);
return;
}
// Disable input when sending message
self.input.update(cx, |this, cx| {
this.set_loading(true, window, cx);
this.set_disabled(true, window, cx);
});
let room = self.room.read(cx);
let task = room.send_message(content, cx);
cx.spawn_in(window, async move |this, cx| {
if let Ok(msgs) = task.await {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
// Reset message input
cx.update_entity(&this.input, |this, cx| {
this.set_loading(false, window, cx);
this.set_disabled(false, window, cx);
this.set_text("", window, cx);
cx.notify();
});
})
.ok();
for item in msgs.into_iter() {
window.push_notification(
Notification::error(item).title("Message Failed to Send"),
cx,
);
}
})
.ok();
}
})
.detach();
}
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
// Show loading spinner
self.set_loading(true, cx);
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let path = paths.pop().unwrap();
if let Ok(file_data) = fs::read(path).await {
let client = get_client();
let (tx, rx) = oneshot::channel::<Url>();
spawn(async move {
if let Ok(url) = nip96_upload(client, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
// Stop loading spinner
this.set_loading(false, cx);
this.attaches.update(cx, |this, cx| {
if let Some(model) = this.as_mut() {
model.push(url);
} else {
*this = Some(vec![url]);
}
cx.notify();
});
})
.ok();
})
.ok();
}
}
}
Ok(None) => {
// Stop loading spinner
if let Some(view) = this.upgrade() {
cx.update_entity(&view, |this, cx| {
this.set_loading(false, cx);
})
.unwrap();
}
}
Err(_) => {}
}
})
.detach();
}
fn remove_media(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
self.attaches.update(cx, |model, cx| {
if let Some(urls) = model.as_mut() {
let ix = urls.iter().position(|x| x == url).unwrap();
urls.remove(ix);
cx.notify();
}
});
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_uploading = status;
cx.notify();
}
fn render_message(
&mut self,
ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
const ROOM_DESCRIPTION: &str =
"This conversation is private. Only members of this chat can see each other's messages.";
let message = self.messages.read(cx).get(ix).unwrap();
let text_data = &mut self.text_data;
div()
.group("")
.relative()
.flex()
.gap_3()
.w_full()
.p_2()
.map(|this| match message {
RoomMessage::User(item) => {
let text = text_data
.entry(item.id)
.or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions));
this.hover(|this| this.bg(cx.theme().accent.step(cx, ColorScaleStep::ONE)))
.child(
div()
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().transparent)
.group_hover("", |this| {
this.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
}),
)
.child(img(item.author.shared_avatar()).size_8().flex_shrink_0())
.child(
div()
.flex()
.flex_col()
.flex_initial()
.overflow_hidden()
.child(
div()
.flex()
.items_baseline()
.gap_2()
.text_xs()
.child(
div().font_semibold().child(item.author.shared_name()),
)
.child(div().child(item.ago()).text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)),
)
.child(div().text_sm().child(text.element(
"body".into(),
window,
cx,
))),
)
}
RoomMessage::System(content) => this
.items_center()
.child(
div()
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().transparent)
.group_hover("", |this| this.bg(cx.theme().danger)),
)
.child(img("brand/avatar.png").size_8().flex_shrink_0())
.text_xs()
.text_color(cx.theme().danger)
.child(content.clone()),
RoomMessage::Announcement => this
.w_full()
.h_32()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_center()
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.line_height(relative(1.3))
.child(
svg()
.path("brand/coop.svg")
.size_8()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(ROOM_DESCRIPTION),
})
}
}
impl Panel for Chat {
fn panel_id(&self) -> SharedString {
self.id.clone()
}
fn title(&self, cx: &App) -> AnyElement {
self.room.read_with(cx, |this, _| {
let facepill: Vec<SharedString> = this.avatars(cx);
div()
.flex()
.items_center()
.gap_1()
.child(
div()
.flex()
.flex_row_reverse()
.items_center()
.justify_start()
.children(
facepill
.into_iter()
.enumerate()
.rev()
.map(|(ix, facepill)| {
div()
.when(ix > 0, |div| div.ml_neg_1())
.child(img(facepill).size_4())
}),
),
)
.child(this.display_name(cx))
.into_any()
})
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Chat {}
impl Focusable for Chat {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Chat {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.size_full()
.child(list(self.list_state.clone()).flex_1())
.child(
div().flex_shrink_0().p_2().child(
div()
.flex()
.flex_col()
.when_some(self.attaches.read(cx).as_ref(), |this, attaches| {
this.gap_1p5().children(attaches.iter().map(|url| {
let url = url.clone();
let path: SharedString = url.to_string().into();
div()
.id(path.clone())
.relative()
.w_16()
.child(
img(format!(
"{}/?url={}&w=128&h=128&fit=cover&n=-1",
IMAGE_SERVICE, path
))
.size_16()
.shadow_lg()
.rounded(px(cx.theme().radius))
.object_fit(ObjectFit::ScaleDown),
)
.child(
div()
.absolute()
.top_neg_2()
.right_neg_2()
.size_4()
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(cx.theme().danger)
.child(
Icon::new(IconName::Close)
.size_2()
.text_color(white()),
),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.remove_media(&url, window, cx);
}))
}))
})
.child(
div()
.w_full()
.h_9()
.flex()
.items_center()
.gap_2()
.child(
Button::new("upload")
.icon(Icon::new(IconName::Upload))
.ghost()
.on_click(cx.listener(move |this, _, window, cx| {
this.upload_media(window, cx);
}))
.loading(self.is_uploading),
)
.child(
div()
.flex_1()
.flex()
.items_center()
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
.rounded(px(cx.theme().radius))
.pl_2()
.pr_1()
.child(self.input.clone())
.child(
Button::new("send")
.ghost()
.xsmall()
.bold()
.rounded(ButtonRounded::Medium)
.label("SEND")
.on_click(cx.listener(|this, _, window, cx| {
this.send_message(window, cx)
})),
),
),
),
),
)
}
}

View File

@@ -0,0 +1,166 @@
use std::collections::BTreeSet;
use anyhow::Error;
use common::profile::SharedProfile;
use global::get_client;
use gpui::{
div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Task, Window,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use ui::{
button::Button,
dock_area::panel::{Panel, PanelEvent},
indicator::Indicator,
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
Sizable,
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Contacts> {
Contacts::new(window, cx)
}
pub struct Contacts {
contacts: Option<Vec<Profile>>,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Contacts {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
cx.spawn(async move |this, cx| {
let client = get_client();
let task: Task<Result<BTreeSet<Profile>, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let profiles = client.database().contacts(public_key).await?;
Ok(profiles)
});
if let Ok(contacts) = task.await {
cx.update(|cx| {
this.update(cx, |this, cx| {
this.contacts = Some(contacts.into_iter().collect_vec());
cx.notify();
})
.ok();
})
.ok();
}
})
.detach();
Self {
contacts: None,
name: "Contacts".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for Contacts {
fn panel_id(&self) -> SharedString {
"ContactPanel".into()
}
fn title(&self, _cx: &App) -> AnyElement {
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)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Contacts {}
impl Focusable for Contacts {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Contacts {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().pt_2().px_2().map(|this| {
if let Some(contacts) = self.contacts.clone() {
this.child(
uniform_list(
cx.entity().clone(),
"contacts",
contacts.len(),
move |_, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
let item = contacts.get(ix).unwrap().clone();
items.push(
div()
.w_full()
.h_9()
.px_2()
.flex()
.items_center()
.justify_between()
.rounded(px(cx.theme().radius))
.child(
div()
.flex()
.items_center()
.gap_2()
.text_xs()
.child(
div()
.flex_shrink_0()
.child(img(item.shared_avatar()).size_6()),
)
.child(item.shared_name()),
)
.hover(|this| {
this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
}),
);
}
items
},
)
.h_full(),
)
} else {
this.flex()
.items_center()
.justify_center()
.h_16()
.child(Indicator::new().small())
}
})
}
}

View File

@@ -0,0 +1,440 @@
use std::{sync::Arc, time::Duration};
use account::Account;
use common::create_qr;
use global::get_client_keys;
use gpui::{
div, img, prelude::FluentBuilder, relative, AnyElement, App, AppContext, Context, Entity,
EventEmitter, FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Window,
};
use nostr_connect::prelude::*;
use smallvec::{smallvec, SmallVec};
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::{InputEvent, TextInput},
notification::Notification,
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Disableable, Icon, IconName, Sizable, Size, StyledExt,
};
use crate::chat_space::ChatSpace;
use super::onboarding;
const INPUT_INVALID: &str = "You must provide a valid Private Key or Bunker.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
Login::new(window, cx)
}
pub struct Login {
// Inputs
key_input: Entity<TextInput>,
error_message: Entity<Option<SharedString>>,
is_logging_in: bool,
// Nostr Connect
qr: Option<Arc<Image>>,
connect_relay: Entity<TextInput>,
connect_client: Entity<Option<NostrConnectURI>>,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 3]>,
}
impl Login {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
let error_message = cx.new(|_| None);
let connect_client = cx.new(|_: &mut Context<'_, Option<NostrConnectURI>>| None);
let key_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("nsec... or bunker://...")
});
let connect_relay = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small();
input.set_text("wss://relay.nsec.app", window, cx);
input
});
subscriptions.push(cx.subscribe_in(
&key_input,
window,
move |this, _, event, window, cx| {
if let InputEvent::PressEnter = event {
this.login(window, cx);
}
},
));
subscriptions.push(cx.subscribe_in(
&connect_relay,
window,
move |this, _, event, window, cx| {
if let InputEvent::PressEnter = event {
this.change_relay(window, cx);
}
},
));
subscriptions.push(
cx.observe_in(&connect_client, window, |this, uri, window, cx| {
let keys = get_client_keys().to_owned();
let account = Account::global(cx);
if let Some(uri) = uri.read(cx).clone() {
if let Ok(qr) = create_qr(uri.to_string().as_str()) {
this.qr = Some(qr);
cx.notify();
}
match NostrConnect::new(uri, keys, Duration::from_secs(300), None) {
Ok(signer) => {
account.update(cx, |this, cx| {
this.login(signer, window, cx);
});
}
Err(e) => {
window.push_notification(Notification::error(e.to_string()), cx);
}
}
}
}),
);
cx.spawn(async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
cx.update(|cx| {
this.update(cx, |this, cx| {
let Ok(relay_url) =
RelayUrl::parse(this.connect_relay.read(cx).text().to_string().as_str())
else {
return;
};
let client_pubkey = get_client_keys().public_key();
let uri = NostrConnectURI::client(client_pubkey, vec![relay_url], "Coop");
this.connect_client.update(cx, |this, cx| {
*this = Some(uri);
cx.notify();
});
})
})
.ok();
})
.detach();
Self {
key_input,
connect_relay,
connect_client,
subscriptions,
error_message,
qr: None,
is_logging_in: false,
name: "Login".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_logging_in {
return;
};
self.set_logging_in(true, cx);
let content = self.key_input.read(cx).text();
let account = Account::global(cx);
if content.starts_with("nsec1") {
match SecretKey::parse(content.as_ref()) {
Ok(secret) => {
let keys = Keys::new(secret);
account.update(cx, |this, cx| {
this.login(keys, window, cx);
});
}
Err(e) => {
self.set_error_message(e.to_string(), cx);
self.set_logging_in(false, cx);
}
}
} else if content.starts_with("bunker://") {
let keys = get_client_keys().to_owned();
let Ok(uri) = NostrConnectURI::parse(content.as_ref()) else {
self.set_error_message("Bunker URL is not valid".to_owned(), cx);
self.set_logging_in(false, cx);
return;
};
match NostrConnect::new(uri, keys, Duration::from_secs(120), None) {
Ok(signer) => {
account.update(cx, |this, cx| {
this.login(signer, window, cx);
});
}
Err(e) => {
self.set_error_message(e.to_string(), cx);
self.set_logging_in(false, cx);
}
}
} else {
self.set_logging_in(false, cx);
window.push_notification(Notification::error(INPUT_INVALID), cx);
};
}
fn change_relay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(relay_url) =
RelayUrl::parse(self.connect_relay.read(cx).text().to_string().as_str())
else {
window.push_notification(Notification::error("Relay URL is not valid."), cx);
return;
};
let client_pubkey = get_client_keys().public_key();
let uri = NostrConnectURI::client(client_pubkey, vec![relay_url], "Coop");
self.connect_client.update(cx, |this, cx| {
*this = Some(uri);
cx.notify();
});
}
fn set_error_message(&mut self, message: String, cx: &mut Context<Self>) {
self.error_message.update(cx, |this, cx| {
*this = Some(SharedString::new(message));
cx.notify();
});
}
fn set_logging_in(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_logging_in = status;
cx.notify();
}
fn back(&self, window: &mut Window, cx: &mut Context<Self>) {
let panel = onboarding::init(window, cx);
ChatSpace::set_center_panel(panel, window, cx);
}
}
impl Panel for Login {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
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)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Login {}
impl Focusable for Login {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Login {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.relative()
.flex()
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
.child(
div()
.w_80()
.flex()
.flex_col()
.gap_8()
.child(
div()
.text_center()
.child(
div()
.text_center()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child("Welcome Back!"),
)
.child(
div()
.text_sm()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("Continue with Private Key or Bunker"),
),
)
.child(
div()
.flex()
.flex_col()
.gap_3()
.child(self.key_input.clone())
.child(
Button::new("login")
.label("Continue")
.primary()
.loading(self.is_logging_in)
.disabled(self.is_logging_in)
.on_click(cx.listener(move |this, _, window, cx| {
this.login(window, cx);
})),
)
.when_some(
self.error_message.read(cx).clone(),
|this, error| {
this.child(
div()
.text_xs()
.text_center()
.text_color(cx.theme().danger)
.child(error),
)
},
),
),
),
)
.child(
div()
.h_full()
.flex_1()
.flex()
.items_center()
.justify_center()
.bg(cx.theme().base.step(cx, ColorScaleStep::TWO))
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_3()
.text_center()
.child(
div()
.text_center()
.child(
div()
.text_sm()
.font_semibold()
.line_height(relative(1.2))
.text_color(
cx.theme().base.step(cx, ColorScaleStep::TWELVE),
)
.child("Continue with Nostr Connect"),
)
.child(
div()
.text_xs()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("Use Nostr Connect apps to scan the code"),
),
)
.when_some(self.qr.clone(), |this, qr| {
this.child(
div()
.mb_2()
.p_2()
.size_64()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.rounded_2xl()
.shadow_md()
.when(cx.theme().appearance.is_dark(), |this| {
this.shadow_none().border_1().border_color(
cx.theme().base.step(cx, ColorScaleStep::SIX),
)
})
.bg(cx.theme().background)
.child(img(qr).h_56()),
)
})
.child(
div()
.w_full()
.flex()
.items_center()
.justify_center()
.gap_1()
.child(self.connect_relay.clone())
.child(
Button::new("change")
.label("Change")
.ghost()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.change_relay(window, cx);
})),
),
),
),
)
.child(
div().absolute().left_2().top_10().w_16().child(
Button::new("back")
.label("Back")
.icon(Icon::new(IconName::ArrowLeft))
.ghost()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.back(window, cx);
})),
),
)
}
}

View File

@@ -0,0 +1,10 @@
pub mod chat;
pub mod contacts;
pub mod login;
pub mod new_account;
pub mod onboarding;
pub mod profile;
pub mod relays;
pub mod settings;
pub mod sidebar;
pub mod welcome;

View File

@@ -0,0 +1,357 @@
use account::Account;
use async_utility::task::spawn;
use common::nip96_upload;
use global::{constants::IMAGE_SERVICE, get_client};
use gpui::{
div, img, prelude::FluentBuilder, px, relative, AnyElement, App, AppContext, Context, Entity,
EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions,
Render, SharedString, Styled, Window,
};
use nostr_sdk::prelude::*;
use smol::fs;
use std::str::FromStr;
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::TextInput,
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
Disableable, Icon, IconName, Sizable, Size, StyledExt,
};
use crate::chat_space::ChatSpace;
use super::onboarding;
const STEAM_ID_DESCRIPTION: &str =
"Steam ID is used to get your currently playing game and update your status.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
NewAccount::new(window, cx)
}
pub struct NewAccount {
name_input: Entity<TextInput>,
avatar_input: Entity<TextInput>,
bio_input: Entity<TextInput>,
steam_input: Entity<TextInput>,
is_uploading: bool,
is_submitting: bool,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl NewAccount {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let name_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("Alice")
});
let avatar_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.small()
.placeholder("https://example.com/avatar.jpg")
});
let steam_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("76561199810385277")
});
let bio_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.multi_line()
.placeholder("A short introduce about you.")
});
Self {
name_input,
avatar_input,
steam_input,
bio_input,
is_uploading: false,
is_submitting: false,
name: "New Account".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_submitting(true, cx);
let avatar = self.avatar_input.read(cx).text().to_string();
let name = self.name_input.read(cx).text().to_string();
let bio = self.bio_input.read(cx).text().to_string();
let steam = self.steam_input.read(cx).text().to_string();
let mut metadata = Metadata::new()
.display_name(name)
.about(bio)
.custom_field("steam", steam);
if let Ok(url) = Url::from_str(&avatar) {
metadata = metadata.picture(url);
};
Account::global(cx).update(cx, |this, cx| {
this.new_account(metadata, window, cx);
});
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
self.set_uploading(true, cx);
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let Some(path) = paths.pop() else {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
})
.ok();
return;
};
if let Ok(file_data) = fs::read(path).await {
let client = get_client();
let (tx, rx) = oneshot::channel::<Url>();
spawn(async move {
if let Ok(url) = nip96_upload(client, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
cx.update(|window, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
.ok();
// Set avatar input
avatar_input
.update(cx, |this, cx| {
this.set_text(url.to_string(), window, cx);
})
.ok();
})
.ok();
}
}
}
Ok(None) => {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
})
})
.ok();
}
Err(_) => {}
}
})
.detach();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_uploading = status;
cx.notify();
}
fn back(&self, window: &mut Window, cx: &mut Context<Self>) {
let panel = onboarding::init(window, cx);
ChatSpace::set_center_panel(panel, window, cx);
}
}
impl Panel for NewAccount {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
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)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for NewAccount {}
impl Focusable for NewAccount {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for NewAccount {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.relative()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_8()
.child(
div()
.text_center()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child("Create New Account"),
)
.child(
div()
.w_72()
.flex()
.flex_col()
.gap_3()
.child(
div()
.w_full()
.h_32()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.map(|this| {
if self.avatar_input.read(cx).text().is_empty() {
this.child(img("brand/avatar.png").size_10().flex_shrink_0())
} else {
this.child(
img(format!(
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
IMAGE_SERVICE,
self.avatar_input.read(cx).text()
))
.size_10()
.flex_shrink_0(),
)
}
})
.child(
Button::new("upload")
.label("Set Profile Picture")
.icon(Icon::new(IconName::Plus))
.ghost()
.small()
.disabled(self.is_submitting)
.loading(self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Name *:")
.child(self.name_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Bio:")
.child(self.bio_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Steam ID:")
.child(self.steam_input.clone())
.child(
div()
.text_size(px(10.))
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(STEAM_ID_DESCRIPTION),
),
)
.child(
div()
.my_2()
.w_full()
.h_px()
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
Button::new("submit")
.label("Continue")
.primary()
.loading(self.is_submitting)
.disabled(self.is_submitting || self.is_uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.submit(window, cx);
})),
),
)
.child(
div().absolute().left_2().top_10().w_16().child(
Button::new("back")
.label("Back")
.icon(Icon::new(IconName::ArrowLeft))
.ghost()
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.back(window, cx);
})),
),
)
}
}

View File

@@ -0,0 +1,158 @@
use gpui::{
div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
Icon, IconName, StyledExt,
};
use crate::chat_space::ChatSpace;
use super::{login, new_account};
const LOGO_URL: &str = "brand/coop.svg";
const TITLE: &str = "Welcome to Coop!";
const SUBTITLE: &str = "a Nostr client for secure communication.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
Onboarding::new(window, cx)
}
pub struct Onboarding {
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Onboarding {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
name: "Onboarding".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
fn open_new_account(&self, window: &mut Window, cx: &mut Context<Self>) {
let new_account = new_account::init(window, cx);
ChatSpace::set_center_panel(new_account, window, cx);
}
fn open_login(&self, window: &mut Window, cx: &mut Context<Self>) {
let login = login::init(window, cx);
ChatSpace::set_center_panel(login, window, cx);
}
}
impl Panel for Onboarding {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
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)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Onboarding {}
impl Focusable for Onboarding {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Onboarding {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.py_4()
.size_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_8()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_4()
.child(
svg()
.path(LOGO_URL)
.size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
div()
.text_center()
.child(
div()
.text_lg()
.font_semibold()
.line_height(relative(1.2))
.child(TITLE),
)
.child(
div()
.text_sm()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(SUBTITLE),
),
),
)
.child(
div()
.w_72()
.flex()
.flex_col()
.gap_2()
.child(
Button::new("continue_btn")
.icon(Icon::new(IconName::ArrowRight))
.label("Start Messaging")
.primary()
.reverse()
.on_click(cx.listener(move |this, _, window, cx| {
this.open_new_account(window, cx);
})),
)
.child(
Button::new("login_btn")
.label("Already have an account? Log in.")
.ghost()
.underline()
.on_click(cx.listener(move |this, _, window, cx| {
this.open_login(window, cx);
})),
),
)
}
}

View File

@@ -0,0 +1,373 @@
use async_utility::task::spawn;
use common::nip96_upload;
use global::{constants::IMAGE_SERVICE, get_client};
use gpui::{
div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter,
Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render,
SharedString, Styled, Task, Window,
};
use nostr_sdk::prelude::*;
use smol::fs;
use std::{str::FromStr, sync::Arc, time::Duration};
use ui::{
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::TextInput,
popup_menu::PopupMenu,
ContextModal, Disableable, Sizable, Size,
};
pub fn init(window: &mut Window, cx: &mut App) -> Arc<Entity<Profile>> {
Arc::new(Profile::new(window, cx))
}
pub struct Profile {
profile: Option<Metadata>,
// Form
name_input: Entity<TextInput>,
avatar_input: Entity<TextInput>,
bio_input: Entity<TextInput>,
website_input: Entity<TextInput>,
is_loading: bool,
is_submitting: bool,
// Panel
name: SharedString,
focus_handle: FocusHandle,
}
impl Profile {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let window_handle = window.window_handle();
let name_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.placeholder("Alice")
});
let avatar_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(Size::XSmall)
.small()
.placeholder("https://example.com/avatar.jpg")
});
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 task: Task<Result<Option<Metadata>, Error>> = cx.background_spawn(async move {
let client = get_client();
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(metadata)
});
cx.spawn(async move |this, cx| {
if let Ok(Some(metadata)) = task.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
})
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let avatar_input = self.avatar_input.downgrade();
let window_handle = window.window_handle();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
// Show loading spinner
self.set_loading(true, cx);
cx.spawn(async move |this, cx| {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let path = paths.pop().unwrap();
if let Ok(file_data) = fs::read(path).await {
let (tx, rx) = oneshot::channel::<Url>();
spawn(async move {
let client = get_client();
if let Ok(url) = nip96_upload(client, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
cx.update_window(window_handle, |_, window, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_loading(false, cx);
})
.unwrap();
// Set avatar input
avatar_input
.update(cx, |this, cx| {
this.set_text(url.to_string(), window, cx);
})
.unwrap();
})
.unwrap();
}
}
}
Ok(None) => {
// Stop loading spinner
if let Some(view) = this.upgrade() {
cx.update_entity(&view, |this, cx| {
this.set_loading(false, cx);
})
.unwrap();
}
}
Err(_) => {}
}
})
.detach();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Show loading spinner
self.set_submitting(true, cx);
let avatar = self.avatar_input.read(cx).text().to_string();
let name = self.name_input.read(cx).text().to_string();
let bio = self.bio_input.read(cx).text().to_string();
let website = self.website_input.read(cx).text().to_string();
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);
};
if let Ok(url) = Url::from_str(&website) {
new_metadata = new_metadata.website(url);
}
let window_handle = window.window_handle();
cx.spawn(async move |this, cx| {
let client = get_client();
let (tx, rx) = oneshot::channel::<EventId>();
cx.background_spawn(async move {
if let Ok(output) = client.set_metadata(&new_metadata).await {
_ = tx.send(output.val);
}
})
.detach();
if rx.await.is_ok() {
cx.update_window(window_handle, |_, window, cx| {
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
window.push_notification("Your profile has been updated successfully", cx);
})
.unwrap()
})
.unwrap();
}
})
.detach();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
}
impl Panel for Profile {
fn panel_id(&self) -> SharedString {
"ProfilePanel".into()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Profile {}
impl Focusable for Profile {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Profile {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.px_2()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_end()
.gap_2()
.w_full()
.h_24()
.map(|this| {
let picture = self.avatar_input.read(cx).text();
if picture.is_empty() {
this.child(img("brand/avatar.png").size_10().flex_shrink_0())
} else {
this.child(
img(format!(
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
IMAGE_SERVICE,
self.avatar_input.read(cx).text()
))
.size_10()
.flex_shrink_0(),
)
}
})
.child(
div()
.flex()
.gap_1()
.items_center()
.w_full()
.child(self.avatar_input.clone())
.child(
Button::new("upload")
.label("Upload")
.ghost()
.small()
.disabled(self.is_submitting)
.loading(self.is_loading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Name:")
.child(self.name_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Bio:")
.child(self.bio_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Website:")
.child(self.website_input.clone()),
)
.child(
div().flex().items_center().justify_end().child(
Button::new("submit")
.label("Update")
.primary()
.small()
.disabled(self.is_loading)
.loading(self.is_submitting)
.on_click(cx.listener(move |this, _, window, cx| {
this.submit(window, cx);
})),
),
)
}
}

View File

@@ -0,0 +1,340 @@
use anyhow::{anyhow, Error};
use global::{constants::NEW_MESSAGE_SUB_ID, get_client};
use gpui::{
div, prelude::FluentBuilder, px, uniform_list, App, AppContext, Context, Entity, FocusHandle,
InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription, Task, TextAlign,
Window,
};
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use ui::{
button::{Button, ButtonVariants},
input::{InputEvent, TextInput},
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, IconName, Sizable,
};
const MESSAGE: &str = "In order to receive messages from others, you need to setup Messaging Relays. You can use the recommend relays or add more.";
const HELP_TEXT: &str = "Please add some relays.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Relays> {
Relays::new(window, cx)
}
pub struct Relays {
relays: Entity<Vec<RelayUrl>>,
input: Entity<TextInput>,
focus_handle: FocusHandle,
is_loading: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl Relays {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let client = get_client();
let relays = cx.new(|cx| {
let relays = vec![
RelayUrl::parse("wss://auth.nostr1.com").unwrap(),
RelayUrl::parse("wss://relay.0xchat.com").unwrap(),
];
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
let relays = event
.tags
.filter_standardized(TagKind::Relay)
.filter_map(|t| match t {
TagStandard::Relay(url) => Some(url.to_owned()),
_ => None,
})
.collect::<Vec<_>>();
Ok(relays)
} else {
Err(anyhow!("Messaging Relays not found."))
}
});
cx.spawn(async move |this, cx| {
if let Ok(relays) = task.await {
cx.update(|cx| {
this.update(cx, |this: &mut Vec<RelayUrl>, cx| {
*this = relays;
cx.notify();
})
.ok();
})
.ok();
}
})
.detach();
relays
});
let input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(ui::Size::XSmall)
.small()
.placeholder("wss://example.com")
});
cx.new(|cx| {
let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe_in(
&input,
window,
move |this: &mut Relays, _, input_event, window, cx| {
if let InputEvent::PressEnter = input_event {
this.add(window, cx);
}
},
));
Self {
relays,
input,
subscriptions,
is_loading: false,
focus_handle: cx.focus_handle(),
}
})
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Show loading spinner
self.set_loading(true, cx);
let relays = self.relays.read(cx).clone();
let task: Task<Result<EventId, Error>> = cx.background_spawn(async move {
let client = get_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
// If user didn't have any NIP-65 relays, add default ones
if client.database().relay_list(public_key).await?.is_empty() {
let builder = EventBuilder::relay_list(vec![
(RelayUrl::parse("wss://relay.damus.io/").unwrap(), None),
(RelayUrl::parse("wss://relay.primal.net/").unwrap(), None),
]);
if let Err(e) = client.send_event_builder(builder).await {
log::error!("Failed to send relay list event: {}", e);
}
}
let tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::custom(TagKind::Relay, vec![relay.to_string()]))
.collect();
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
let output = client.send_event_builder(builder).await?;
// Connect to messaging relays
for relay in relays.into_iter() {
_ = client.add_relay(&relay).await;
_ = client.connect_relay(&relay).await;
}
let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
// Close old subscription
client.unsubscribe(&sub_id).await;
// Subscribe to new messages
if let Err(e) = client
.subscribe_with_id(
sub_id,
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(public_key)
.limit(0),
None,
)
.await
{
log::error!("Failed to subscribe to new messages: {}", e);
}
Ok(output.val)
});
cx.spawn_in(window, async move |this, cx| {
if task.await.is_ok() {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_loading(false, cx);
cx.notify();
})
.ok();
window.close_modal(cx);
})
.ok();
}
})
.detach();
}
pub fn loading(&self) -> bool {
self.is_loading
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.input.read(cx).text().to_string();
if !value.starts_with("ws") {
return;
}
if let Ok(url) = RelayUrl::parse(&value) {
self.relays.update(cx, |this, cx| {
if !this.contains(&url) {
this.push(url);
cx.notify();
}
});
self.input.update(cx, |this, cx| {
this.set_text("", window, cx);
});
}
}
fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context<Self>) {
self.relays.update(cx, |this, cx| {
this.remove(ix);
cx.notify();
});
}
}
impl Render for Relays {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.track_focus(&self.focus_handle)
.flex()
.flex_col()
.gap_2()
.w_full()
.child(
div()
.px_2()
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(MESSAGE),
)
.child(
div()
.px_2()
.w_full()
.flex()
.flex_col()
.gap_2()
.child(
div()
.flex()
.items_center()
.w_full()
.gap_2()
.child(self.input.clone())
.child(
Button::new("add_relay_btn")
.icon(IconName::Plus)
.small()
.rounded(px(cx.theme().radius))
.on_click(
cx.listener(|this, _, window, cx| this.add(window, cx)),
),
),
)
.map(|this| {
let view = cx.entity();
let relays = self.relays.read(cx).clone();
let total = relays.len();
if !relays.is_empty() {
this.child(
uniform_list(
view,
"relays",
total,
move |_, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
let item = relays.get(ix).unwrap().clone().to_string();
items.push(
div().group("").w_full().h_9().py_0p5().child(
div()
.px_2()
.h_full()
.w_full()
.flex()
.items_center()
.justify_between()
.rounded(px(cx.theme().radius))
.bg(cx
.theme()
.base
.step(cx, ColorScaleStep::THREE))
.text_xs()
.child(item)
.child(
Button::new("remove_{ix}")
.icon(IconName::Close)
.xsmall()
.ghost()
.invisible()
.group_hover("", |this| {
this.visible()
})
.on_click(cx.listener(
move |this, _, window, cx| {
this.remove(ix, window, cx)
},
)),
),
),
)
}
items
},
)
.w_full()
.min_h(px(120.)),
)
} else {
this.h_20()
.mb_2()
.flex()
.items_center()
.justify_center()
.text_xs()
.text_align(TextAlign::Center)
.child(HELP_TEXT)
}
}),
)
}
}

View File

@@ -0,0 +1,76 @@
use gpui::{
div, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use ui::{
button::Button,
dock_area::panel::{Panel, PanelEvent},
popup_menu::PopupMenu,
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Settings> {
Settings::new(window, cx)
}
pub struct Settings {
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Settings {
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self {
name: "Settings".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
})
}
}
impl Panel for Settings {
fn panel_id(&self) -> SharedString {
"SettingsPanel".into()
}
fn title(&self, _cx: &App) -> AnyElement {
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)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Settings {}
impl Focusable for Settings {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Settings {
fn render(&mut self, _window: &mut gpui::Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child("Settings")
}
}

View File

@@ -0,0 +1,560 @@
use anyhow::Error;
use chats::{
room::{Room, RoomKind},
ChatRegistry,
};
use common::{profile::SharedProfile, random_name};
use global::get_client;
use gpui::{
div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App,
AppContext, ClickEvent, Context, Div, Entity, FocusHandle, InteractiveElement, IntoElement,
ParentElement, Render, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
Subscription, Task, TextAlign, Window,
};
use nostr_sdk::prelude::*;
use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
use smol::Timer;
use std::{
collections::{BTreeSet, HashSet},
rc::Rc,
time::Duration,
};
use ui::{
button::{Button, ButtonRounded},
input::{InputEvent, TextInput},
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Icon, IconName, Sizable, Size, StyledExt,
};
const DESCRIPTION: &str =
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com).";
#[derive(Clone, PartialEq, Eq, Deserialize)]
struct SelectContact(PublicKey);
impl_internal_actions!(contacts, [SelectContact]);
pub struct Compose {
title_input: Entity<TextInput>,
user_input: Entity<TextInput>,
contacts: Entity<Vec<Profile>>,
selected: Entity<HashSet<PublicKey>>,
focus_handle: FocusHandle,
is_loading: bool,
is_submitting: bool,
error_message: Entity<Option<SharedString>>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl Compose {
pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
let contacts = cx.new(|_| Vec::new());
let selected = cx.new(|_| HashSet::new());
let error_message = cx.new(|_| None);
let title_input = cx.new(|cx| {
let name = random_name(2);
let mut input = TextInput::new(window, cx)
.appearance(false)
.text_size(Size::XSmall);
input.set_placeholder("Family... . (Optional)");
input.set_text(name, window, cx);
input
});
let user_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(ui::Size::Small)
.small()
.placeholder("npub1...")
});
let mut subscriptions = smallvec![];
// Handle Enter event for user input
subscriptions.push(cx.subscribe_in(
&user_input,
window,
move |this, _, input_event, window, cx| {
if let InputEvent::PressEnter = input_event {
this.add(window, cx);
}
},
));
cx.spawn(async move |this, cx| {
let task: Task<Result<BTreeSet<Profile>, Error>> = cx.background_spawn(async move {
let client = get_client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let profiles = client.database().contacts(public_key).await?;
Ok(profiles)
});
if let Ok(contacts) = task.await {
cx.update(|cx| {
this.update(cx, |this, cx| {
this.contacts.update(cx, |this, cx| {
this.extend(contacts);
cx.notify();
});
})
.ok()
})
.ok();
}
})
.detach();
Self {
title_input,
user_input,
contacts,
selected,
error_message,
is_loading: false,
is_submitting: false,
focus_handle: cx.focus_handle(),
subscriptions,
}
}
pub fn compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.selected.read(cx).is_empty() {
self.set_error(Some("You need to add at least 1 receiver".into()), cx);
return;
}
// Show loading spinner
self.set_submitting(true, cx);
// Get all pubkeys
let pubkeys: Vec<PublicKey> = self.selected.read(cx).iter().copied().collect();
// Convert selected pubkeys into Nostr tags
let mut tag_list: Vec<Tag> = pubkeys.iter().map(|pk| Tag::public_key(*pk)).collect();
// Add subject if it is present
if !self.title_input.read(cx).text().is_empty() {
tag_list.push(Tag::custom(
TagKind::Subject,
vec![self.title_input.read(cx).text().to_string()],
));
}
let tags = Tags::from_list(tag_list);
let event: Task<Result<Event, anyhow::Error>> = cx.background_spawn(async move {
let client = get_client();
let signer = client.signer().await?;
// [IMPORTANT]
// Make sure this event is never send,
// this event existed just use for convert to Coop's Room later.
let event = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "")
.tags(tags)
.sign(&signer)
.await?;
Ok(event)
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(event) = event.await {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
})
.ok();
let chats = ChatRegistry::global(cx);
let room = Room::new(&event).kind(RoomKind::Ongoing);
chats.update(cx, |chats, cx| {
match chats.push(room, cx) {
Ok(_) => {
// TODO: automatically open newly created chat panel
window.close_modal(cx);
}
Err(e) => {
_ = this.update(cx, |this, cx| {
this.set_error(Some(e.to_string().into()), cx);
});
}
}
});
})
.ok();
}
})
.detach();
}
pub fn label(&self, _window: &Window, cx: &App) -> SharedString {
if self.selected.read(cx).len() > 1 {
"Create Group DM".into()
} else {
"Create DM".into()
}
}
pub fn is_submitting(&self) -> bool {
self.is_submitting
}
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let client = get_client();
let content = self.user_input.read(cx).text().to_string();
// Show loading spinner
self.set_loading(true, cx);
let task: Task<Result<Profile, anyhow::Error>> = if content.contains("@") {
cx.background_spawn(async move {
let profile = nip05::profile(&content, None).await?;
let public_key = profile.public_key;
let metadata = client
.fetch_metadata(public_key, Duration::from_secs(2))
.await?
.unwrap_or_default();
Ok(Profile::new(public_key, metadata))
})
} else {
let Ok(public_key) = PublicKey::parse(&content) else {
self.set_loading(false, cx);
self.set_error(Some("Public Key is not valid".into()), cx);
return;
};
cx.background_spawn(async move {
let metadata = client
.fetch_metadata(public_key, Duration::from_secs(2))
.await?
.unwrap_or_default();
Ok(Profile::new(public_key, metadata))
})
};
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(profile) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
let public_key = profile.public_key();
this.contacts.update(cx, |this, cx| {
this.insert(0, profile);
cx.notify();
});
this.selected.update(cx, |this, cx| {
this.insert(public_key);
cx.notify();
});
// Stop loading indicator
this.set_loading(false, cx);
// Clear input
this.user_input.update(cx, |this, cx| {
this.set_text("", window, cx);
cx.notify();
});
})
.ok();
})
.ok();
}
Err(e) => {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.set_loading(false, cx);
this.set_error(Some(e.to_string().into()), cx);
})
.ok();
})
.ok();
}
}
})
.detach();
}
fn set_error(&mut self, error: Option<SharedString>, cx: &mut Context<Self>) {
self.error_message.update(cx, |this, cx| {
*this = error;
cx.notify();
});
// Dismiss error after 2 seconds
cx.spawn(async move |this, cx| {
Timer::after(Duration::from_secs(2)).await;
cx.update(|cx| {
this.update(cx, |this, cx| {
this.set_error(None, cx);
})
.ok();
})
.ok();
})
.detach();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
fn on_action_select(
&mut self,
action: &SelectContact,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.selected.update(cx, |this, cx| {
if this.contains(&action.0) {
this.remove(&action.0);
} else {
this.insert(action.0);
};
cx.notify();
});
}
}
impl Render for Compose {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_action_select))
.flex()
.flex_col()
.gap_1()
.child(
div()
.px_2()
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(DESCRIPTION),
)
.when_some(self.error_message.read(cx).as_ref(), |this, msg| {
this.child(
div()
.px_2()
.text_xs()
.text_color(cx.theme().danger)
.child(msg.clone()),
)
})
.child(
div().flex().flex_col().child(
div()
.h_10()
.px_2()
.border_b_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
.flex()
.items_center()
.gap_1()
.child(div().text_xs().font_semibold().child("Title:"))
.child(self.title_input.clone()),
),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(div().px_2().text_xs().font_semibold().child("To:"))
.child(
div()
.flex()
.items_center()
.gap_2()
.px_2()
.child(
Button::new("add_user_to_compose_btn")
.icon(IconName::Plus)
.small()
.rounded(ButtonRounded::Size(px(9999.)))
.loading(self.is_loading)
.on_click(cx.listener(|this, _, window, cx| {
this.add(window, cx);
})),
)
.child(self.user_input.clone()),
)
.map(|this| {
let contacts = self.contacts.read(cx).clone();
let view = cx.entity();
if contacts.is_empty() {
this.child(
div()
.w_full()
.h_24()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_align(TextAlign::Center)
.child(
div()
.text_xs()
.font_semibold()
.line_height(relative(1.2))
.child("No contacts"),
)
.child(
div()
.text_xs()
.text_color(
cx.theme().base.step(cx, ColorScaleStep::ELEVEN),
)
.child("Your recently contacts will appear here."),
),
)
} else {
this.child(
uniform_list(
view,
"contacts",
contacts.len(),
move |this, range, _window, cx| {
let selected = this.selected.read(cx);
let mut items = Vec::new();
for ix in range {
let item = contacts.get(ix).unwrap().clone();
let is_select = selected.contains(&item.public_key());
items.push(
div()
.id(ix)
.w_full()
.h_9()
.px_2()
.flex()
.items_center()
.justify_between()
.child(
div()
.flex()
.items_center()
.gap_2()
.text_xs()
.child(div().flex_shrink_0().child(
img(item.shared_avatar()).size_6(),
))
.child(item.shared_name()),
)
.when(is_select, |this| {
this.child(
Icon::new(IconName::CircleCheck)
.size_3()
.text_color(cx.theme().base.step(
cx,
ColorScaleStep::TWELVE,
)),
)
})
.hover(|this| {
this.bg(cx
.theme()
.base
.step(cx, ColorScaleStep::THREE))
})
.on_click(move |_, window, cx| {
window.dispatch_action(
Box::new(SelectContact(
item.public_key(),
)),
cx,
);
}),
);
}
items
},
)
.min_h(px(250.)),
)
}
}),
)
}
}
type Handler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
#[derive(IntoElement)]
pub struct ComposeButton {
base: Div,
label: SharedString,
handler: Handler,
}
impl ComposeButton {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
base: div(),
label: label.into(),
handler: Rc::new(|_, _, _| {}),
}
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.handler = Rc::new(handler);
self
}
}
impl RenderOnce for ComposeButton {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone();
self.base
.id("compose")
.flex()
.items_center()
.gap_2()
.px_1()
.h_7()
.text_xs()
.font_semibold()
.rounded(px(cx.theme().radius))
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.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(self.label.clone())
.on_click(move |ev, window, cx| handler(ev, window, cx))
}
}

View File

@@ -0,0 +1,323 @@
use std::rc::Rc;
use gpui::{
div, prelude::FluentBuilder, px, App, ClickEvent, Img, InteractiveElement, IntoElement,
ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement, Styled as _, Window,
};
use ui::{
theme::{scale::ColorScaleStep, ActiveTheme},
Collapsible, Icon, IconName, StyledExt,
};
type Handler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
#[derive(IntoElement)]
pub struct Parent {
icon: Option<Icon>,
active_icon: Option<Icon>,
label: SharedString,
items: Vec<Folder>,
collapsed: bool,
handler: Handler,
}
impl Parent {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
icon: None,
active_icon: None,
items: Vec::new(),
collapsed: false,
handler: Rc::new(|_, _, _| {}),
}
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn active_icon(mut self, icon: impl Into<Icon>) -> Self {
self.active_icon = Some(icon.into());
self
}
pub fn collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
pub fn child(mut self, child: impl Into<Folder>) -> Self {
self.items.push(child.into());
self
}
#[allow(dead_code)]
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Folder>>) -> Self {
self.items = children.into_iter().map(Into::into).collect();
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.handler = Rc::new(handler);
self
}
}
impl Collapsible for Parent {
fn is_collapsed(&self) -> bool {
self.collapsed
}
fn collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
}
impl RenderOnce for Parent {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone();
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.id(self.label.clone())
.flex()
.items_center()
.gap_2()
.px_2()
.h_6()
.rounded(px(cx.theme().radius))
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.font_semibold()
.when_some(self.icon, |this, icon| {
this.map(|this| {
if self.collapsed {
this.child(icon.size_4())
} else {
this.when_some(self.active_icon, |this, icon| {
this.child(icon.size_4())
})
}
})
})
.child(self.label.clone())
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.on_click(move |ev, window, cx| handler(ev, window, cx)),
)
.when(!self.collapsed, |this| {
this.child(div().flex().flex_col().gap_1().pl_3().children(self.items))
})
}
}
#[derive(IntoElement)]
pub struct Folder {
icon: Option<Icon>,
active_icon: Option<Icon>,
label: SharedString,
items: Vec<FolderItem>,
collapsed: bool,
handler: Handler,
}
impl Folder {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
icon: None,
active_icon: None,
items: Vec::new(),
collapsed: false,
handler: Rc::new(|_, _, _| {}),
}
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn active_icon(mut self, icon: impl Into<Icon>) -> Self {
self.active_icon = Some(icon.into());
self
}
pub fn collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<FolderItem>>) -> Self {
self.items = children.into_iter().map(Into::into).collect();
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.handler = Rc::new(handler);
self
}
}
impl Collapsible for Folder {
fn is_collapsed(&self) -> bool {
self.collapsed
}
fn collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
}
impl RenderOnce for Folder {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone();
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.id(self.label.clone())
.flex()
.items_center()
.gap_2()
.px_2()
.h_6()
.rounded(px(cx.theme().radius))
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.font_semibold()
.when_some(self.icon, |this, icon| {
this.map(|this| {
if self.collapsed {
this.child(icon.size_4())
} else {
this.when_some(self.active_icon, |this, icon| {
this.child(icon.size_4())
})
}
})
})
.child(self.label.clone())
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.on_click(move |ev, window, cx| handler(ev, window, cx)),
)
.when(!self.collapsed, |this| {
this.child(div().flex().flex_col().gap_1().pl_6().children(self.items))
})
}
}
#[derive(IntoElement)]
pub struct FolderItem {
ix: usize,
img: Option<Img>,
label: Option<SharedString>,
description: Option<SharedString>,
handler: Handler,
}
impl FolderItem {
pub fn new(ix: usize) -> Self {
Self {
ix,
img: None,
label: None,
description: None,
handler: Rc::new(|_, _, _| {}),
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn img(mut self, img: Option<Img>) -> Self {
self.img = img;
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.handler = Rc::new(handler);
self
}
}
impl RenderOnce for FolderItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone();
div()
.id(self.ix)
.h_6()
.px_2()
.w_full()
.flex()
.items_center()
.justify_between()
.text_xs()
.rounded(px(cx.theme().radius))
.child(
div()
.flex_1()
.flex()
.items_center()
.gap_2()
.truncate()
.font_medium()
.map(|this| {
if let Some(img) = self.img {
this.child(img.size_4().flex_shrink_0())
} else {
this.child(
div()
.flex()
.justify_center()
.items_center()
.size_4()
.rounded_full()
.bg(cx.theme().accent.step(cx, ColorScaleStep::THREE))
.child(Icon::new(IconName::GroupFill).size_2().text_color(
cx.theme().accent.step(cx, ColorScaleStep::TWELVE),
)),
)
}
})
.when_some(self.label, |this, label| this.child(label)),
)
.when_some(self.description, |this, description| {
this.child(
div()
.flex_shrink_0()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(description),
)
})
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
.on_click(move |ev, window, cx| handler(ev, window, cx))
}
}

View File

@@ -0,0 +1,264 @@
use chats::{
room::{Room, RoomKind},
ChatRegistry,
};
use compose::{Compose, ComposeButton};
use folder::{Folder, FolderItem, Parent};
use gpui::{
div, img, prelude::FluentBuilder, px, AnyElement, App, AppContext, Context, Entity,
EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, SharedString, Styled,
Window,
};
use ui::{
button::{Button, ButtonRounded, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
popup_menu::PopupMenu,
scroll::ScrollbarAxis,
skeleton::Skeleton,
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Disableable, IconName, StyledExt,
};
use crate::chat_space::{AddPanel, PanelKind};
mod compose;
mod folder;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
Sidebar::new(window, cx)
}
pub struct Sidebar {
name: SharedString,
focus_handle: FocusHandle,
ongoing: bool,
incoming: bool,
trusted: bool,
unknown: bool,
}
impl Sidebar {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
Self {
name: "Chat Sidebar".into(),
ongoing: false,
incoming: false,
trusted: true,
unknown: true,
focus_handle,
}
}
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| {
let label = compose.read(cx).label(window, cx);
let is_submitting = compose.read(cx).is_submitting();
modal
.title("Direct Messages")
.width(px(420.))
.child(compose.clone())
.footer(
div()
.p_2()
.border_t_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE))
.child(
Button::new("create_dm_btn")
.label(label)
.primary()
.bold()
.rounded(ButtonRounded::Large)
.w_full()
.loading(is_submitting)
.disabled(is_submitting)
.on_click(window.listener_for(&compose, |this, _, window, cx| {
this.compose(window, cx)
})),
),
)
})
}
fn open_room(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(
Box::new(AddPanel::new(
PanelKind::Room(id),
ui::dock_area::dock::DockPlacement::Center,
)),
cx,
);
}
fn ongoing(&mut self, cx: &mut Context<Self>) {
self.ongoing = !self.ongoing;
cx.notify();
}
fn incoming(&mut self, cx: &mut Context<Self>) {
self.incoming = !self.incoming;
cx.notify();
}
fn trusted(&mut self, cx: &mut Context<Self>) {
self.trusted = !self.trusted;
cx.notify();
}
fn unknown(&mut self, cx: &mut Context<Self>) {
self.unknown = !self.unknown;
cx.notify();
}
#[allow(dead_code)]
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
(0..total).map(|_| {
div()
.h_8()
.w_full()
.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_items(rooms: &Vec<&Entity<Room>>, cx: &Context<Self>) -> Vec<FolderItem> {
let mut items = Vec::with_capacity(rooms.len());
for room in rooms {
let room = room.read(cx);
let id = room.id;
let ago = room.ago();
let label = room.display_name(cx);
let img = room.display_image(cx).map(img);
let item = FolderItem::new(id as usize)
.label(label)
.description(ago)
.img(img)
.on_click({
cx.listener(move |this, _, window, cx| {
this.open_room(id, window, cx);
})
});
items.push(item);
}
items
}
}
impl Panel for Sidebar {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Sidebar {}
impl Focusable for Sidebar {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let registry = ChatRegistry::global(cx).read(cx);
let rooms = registry.rooms(cx);
let loading = registry.loading();
let ongoing = rooms.get(&RoomKind::Ongoing);
let trusted = rooms.get(&RoomKind::Trusted);
let unknown = rooms.get(&RoomKind::Unknown);
div()
.scrollable(cx.entity_id(), ScrollbarAxis::Vertical)
.size_full()
.flex()
.flex_col()
.gap_3()
.px_2()
.py_3()
.child(ComposeButton::new("New Message").on_click(cx.listener(
|this, _, window, cx| {
this.render_compose(window, cx);
},
)))
.map(|this| {
if loading {
this.children(self.render_skeleton(6))
} else {
this.when_some(ongoing, |this, rooms| {
this.child(
Folder::new("Ongoing")
.icon(IconName::FolderFill)
.active_icon(IconName::FolderOpenFill)
.collapsed(self.ongoing)
.on_click(cx.listener(move |this, _, _, cx| {
this.ongoing(cx);
}))
.children(Self::render_items(rooms, cx)),
)
})
.child(
Parent::new("Incoming")
.icon(IconName::FolderFill)
.active_icon(IconName::FolderOpenFill)
.collapsed(self.incoming)
.on_click(cx.listener(move |this, _, _, cx| {
this.incoming(cx);
}))
.when_some(trusted, |this, rooms| {
this.child(
Folder::new("Trusted")
.icon(IconName::FolderFill)
.active_icon(IconName::FolderOpenFill)
.collapsed(self.trusted)
.on_click(cx.listener(move |this, _, _, cx| {
this.trusted(cx);
}))
.children(Self::render_items(rooms, cx)),
)
})
.when_some(unknown, |this, rooms| {
this.child(
Folder::new("Unknown")
.icon(IconName::FolderFill)
.active_icon(IconName::FolderOpenFill)
.collapsed(self.unknown)
.on_click(cx.listener(move |this, _, _, cx| {
this.unknown(cx);
}))
.children(Self::render_items(rooms, cx)),
)
}),
)
}
})
}
}

View File

@@ -0,0 +1,101 @@
use gpui::{
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use ui::{
button::Button,
dock_area::panel::{Panel, PanelEvent},
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
StyledExt,
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Welcome> {
Welcome::new(window, cx)
}
pub struct Welcome {
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Welcome {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
name: "Welcome".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for Welcome {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
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)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Welcome {}
impl Focusable for Welcome {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Welcome {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_1()
.child(
svg()
.path("brand/coop.svg")
.size_12()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
)
.child(
div()
.child("coop on nostr.")
.text_color(cx.theme().base.step(cx, ColorScaleStep::FOUR))
.font_black()
.text_sm(),
),
)
}
}