feat: add support for subject of conversation

This commit is contained in:
2025-04-22 15:10:36 +07:00
parent 52a79dca08
commit 86eca5803f
11 changed files with 256 additions and 70 deletions

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M4 4.75A2.75 2.75 0 0 1 6.75 2h10.5A2.75 2.75 0 0 1 20 4.75v7.917a3.9 3.9 0 0 0-4.091.909l-3.75 3.75a2.25 2.25 0 0 0-.659 1.59v2.334c0 .263.045.515.128.75H6.75A2.75 2.75 0 0 1 4 19.25V4.75Z"/>
<path fill="currentColor" fill-rule="evenodd" d="M19.303 15.697a.9.9 0 0 0-1.273 0l-3.53 3.53V20.5h1.273l3.53-3.53a.9.9 0 0 0 0-1.273Zm-2.333-1.06a2.4 2.4 0 1 1 3.394 3.393l-3.75 3.75a.75.75 0 0 1-.53.22H13.75a.75.75 0 0 1-.75-.75v-2.333a.75.75 0 0 1 .22-.53l3.75-3.75Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@@ -288,19 +288,20 @@ impl ChatRegistry {
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
room.update(cx, |this, cx| { room.update(cx, |this, cx| {
this.created_at(event.created_at, cx); this.created_at(event.created_at, cx);
this.emit_message(event, window, cx);
cx.defer_in(window, |this, window, cx| {
this.emit_message(event, window, cx);
});
}); });
// Re-sort rooms by last seen cx.defer_in(window, |this, _, cx| {
self.rooms this.rooms
.sort_by_key(|room| Reverse(room.read(cx).created_at)); .sort_by_key(|room| Reverse(room.read(cx).created_at));
});
} else { } else {
let new_room = cx.new(|_| Room::new(&event));
// Push the new room to the front of the list // Push the new room to the front of the list
self.rooms.insert(0, new_room); self.rooms.insert(0, cx.new(|_| Room::new(&event)));
cx.notify();
} }
cx.notify();
} }
} }

View File

@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use account::Account; use account::Account;
use anyhow::Error; use anyhow::{anyhow, Error};
use chrono::{Local, TimeZone}; use chrono::{Local, TimeZone};
use common::{compare, profile::SharedProfile, room_hash}; use common::{compare, profile::SharedProfile, room_hash};
use global::get_client; use global::get_client;
@@ -285,6 +285,17 @@ impl Room {
cx.notify(); cx.notify();
} }
/// Updates the subject of the room
///
/// # Arguments
///
/// * `subject` - The new subject to set
/// * `cx` - The context to notify about the update
pub fn subject(&mut self, subject: String, cx: &mut Context<Self>) {
self.subject = Some(subject.into());
cx.notify();
}
/// Fetches metadata for all members in the room /// Fetches metadata for all members in the room
/// ///
/// # Arguments /// # Arguments
@@ -358,15 +369,21 @@ impl Room {
/// A Task that resolves to Result<Vec<String>, Error> where the /// A Task that resolves to Result<Vec<String>, Error> where the
/// strings contain error messages for any failed sends /// strings contain error messages for any failed sends
pub fn send_message(&self, content: String, cx: &App) -> Task<Result<Vec<String>, Error>> { pub fn send_message(&self, content: String, cx: &App) -> Task<Result<Vec<String>, Error>> {
let client = get_client(); let account = Account::global(cx).read(cx);
let Some(profile) = account.profile.clone() else {
return Task::ready(Err(anyhow!("User is not logged in")));
};
let public_key = profile.public_key();
let subject = self.subject.clone();
let pubkeys = self.members.clone(); let pubkeys = self.members.clone();
cx.background_spawn(async move { cx.background_spawn(async move {
let signer = client.signer().await?; let client = get_client();
let public_key = signer.get_public_key().await?;
let mut report = vec![]; let mut report = vec![];
let tags: Vec<Tag> = pubkeys let mut tags: Vec<Tag> = pubkeys
.iter() .iter()
.filter_map(|pubkey| { .filter_map(|pubkey| {
if pubkey != &public_key { if pubkey != &public_key {
@@ -377,6 +394,12 @@ impl Room {
}) })
.collect(); .collect();
if let Some(subject) = subject {
tags.push(Tag::from_standardized(TagStandard::Subject(
subject.to_string(),
)));
}
for pubkey in pubkeys.iter() { for pubkey in pubkeys.iter() {
if let Err(e) = client if let Err(e) = client
.send_private_msg(*pubkey, &content, tags.clone()) .send_private_msg(*pubkey, &content, tags.clone())

View File

@@ -22,8 +22,6 @@ use crate::views::{onboarding, sidebar};
const MODAL_WIDTH: f32 = 420.; const MODAL_WIDTH: f32 = 420.;
const SIDEBAR_WIDTH: f32 = 280.; const SIDEBAR_WIDTH: f32 = 280.;
impl_internal_actions!(dock, [AddPanel, ToggleModal]);
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
ChatSpace::new(window, cx) ChatSpace::new(window, cx)
} }
@@ -58,6 +56,8 @@ pub struct ToggleModal {
pub modal: ModalKind, pub modal: ModalKind,
} }
impl_internal_actions!(dock, [AddPanel, ToggleModal]);
#[derive(Clone, PartialEq, Eq, Deserialize)] #[derive(Clone, PartialEq, Eq, Deserialize)]
pub struct AddPanel { pub struct AddPanel {
panel: PanelKind, panel: PanelKind,
@@ -263,7 +263,7 @@ impl ChatSpace {
}; };
} }
fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) { pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
if let Some(Some(root)) = window.root::<Root>() { if let Some(Some(root)) = window.root::<Root>() {
if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() { if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() {
let panel = Arc::new(panel); let panel = Arc::new(panel);

View File

@@ -298,11 +298,11 @@ fn main() {
// Spawn a task to handle events from nostr channel // Spawn a task to handle events from nostr channel
cx.spawn_in(window, async move |_, cx| { 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 { while let Ok(signal) = event_rx.recv().await {
cx.update(|window, cx| { cx.update(|window, cx| {
let chats = ChatRegistry::global(cx);
let auto_updater = AutoUpdater::global(cx);
match signal { match signal {
Signal::Event(event) => { Signal::Event(event) => {
chats.update(cx, |this, cx| { chats.update(cx, |this, cx| {

View File

@@ -4,12 +4,14 @@ use chats::{message::RoomMessage, room::Room, ChatRegistry};
use common::{nip96_upload, profile::SharedProfile}; use common::{nip96_upload, profile::SharedProfile};
use global::{constants::IMAGE_SERVICE, get_client}; use global::{constants::IMAGE_SERVICE, get_client};
use gpui::{ use gpui::{
div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext, div, img, impl_internal_actions, list, prelude::FluentBuilder, px, relative, svg, white,
Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement, AnyElement, App, AppContext, Context, Element, Empty, Entity, EventEmitter, Flatten,
IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Render, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit,
SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, Window, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled,
StyledImage, Subscription, Window,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Deserialize;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use smol::fs; use smol::fs;
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, sync::Arc};
@@ -24,7 +26,15 @@ use ui::{
v_flex, ContextModal, Disableable, Icon, IconName, Size, StyledExt, v_flex, ContextModal, Disableable, Icon, IconName, Size, StyledExt,
}; };
use crate::views::subject;
const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages."; const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages.";
const DESC: &str = "This conversation is private. Only members can see each other's messages.";
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub struct ChangeSubject(pub String);
impl_internal_actions!(chat, [ChangeSubject]);
pub fn init(id: &u64, window: &mut Window, cx: &mut App) -> Result<Arc<Entity<Chat>>, Error> { 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) { if let Some(room) = ChatRegistry::global(cx).read(cx).room(id, cx) {
@@ -99,7 +109,7 @@ impl Chat {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.render_message(ix, window, cx).into_any_element() this.render_message(ix, window, cx).into_any_element()
}) })
.unwrap() .unwrap_or(Empty.into_any())
} }
}); });
@@ -216,7 +226,7 @@ impl Chat {
return; return;
} }
// Disable input when sending message // temporarily disable message input
self.input.update(cx, |this, cx| { self.input.update(cx, |this, cx| {
this.set_loading(true, window, cx); this.set_loading(true, window, cx);
this.set_disabled(true, window, cx); this.set_disabled(true, window, cx);
@@ -226,27 +236,38 @@ impl Chat {
let task = room.send_message(content, cx); let task = room.send_message(content, cx);
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
if let Ok(msgs) = task.await { match task.await {
cx.update(|window, cx| { Ok(reports) => {
this.update(cx, |this, cx| { cx.update(|window, cx| {
// Reset message input this.update(cx, |this, cx| {
cx.update_entity(&this.input, |this, cx| { // Reset message input
this.set_loading(false, window, cx); this.input.update(cx, |this, cx| {
this.set_disabled(false, window, cx); this.set_loading(false, window, cx);
this.set_text("", window, cx); this.set_disabled(false, window, cx);
cx.notify(); this.set_text("", window, cx);
}); cx.notify();
});
})
.ok();
for item in reports.into_iter() {
window.push_notification(
Notification::error(item).title("Message Failed to Send"),
cx,
);
}
}) })
.ok(); .ok();
}
for item in msgs.into_iter() { Err(e) => {
cx.update(|window, cx| {
window.push_notification( window.push_notification(
Notification::error(item).title("Message Failed to Send"), Notification::error(e.to_string()).title("Message Failed to Send"),
cx, cx,
); );
} })
}) .ok();
.ok(); }
} }
}) })
.detach(); .detach();
@@ -265,12 +286,15 @@ impl Chat {
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(paths.await.map_err(|e| e.into())) { match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => { Ok(Some(mut paths)) => {
let path = paths.pop().unwrap(); let Some(path) = paths.pop() else {
return;
};
if let Ok(file_data) = fs::read(path).await { if let Ok(file_data) = fs::read(path).await {
let client = get_client(); let client = get_client();
let (tx, rx) = oneshot::channel::<Url>(); let (tx, rx) = oneshot::channel::<Url>();
// spawn task via async_utility
spawn(async move { spawn(async move {
if let Ok(url) = nip96_upload(client, file_data).await { if let Ok(url) = nip96_upload(client, file_data).await {
_ = tx.send(url); _ = tx.send(url);
@@ -280,7 +304,6 @@ impl Chat {
if let Ok(url) = rx.await { if let Ok(url) = rx.await {
cx.update(|_, cx| { cx.update(|_, cx| {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
// Stop loading spinner
this.set_loading(false, cx); this.set_loading(false, cx);
this.attaches.update(cx, |this, cx| { this.attaches.update(cx, |this, cx| {
@@ -299,13 +322,13 @@ impl Chat {
} }
} }
Ok(None) => { Ok(None) => {
// Stop loading spinner cx.update(|_, cx| {
if let Some(view) = this.upgrade() { this.update(cx, |this, cx| {
cx.update_entity(&view, |this, cx| {
this.set_loading(false, cx); this.set_loading(false, cx);
}) })
.unwrap(); .ok();
} })
.ok();
} }
Err(_) => {} Err(_) => {}
} }
@@ -316,9 +339,10 @@ impl Chat {
fn remove_media(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) { fn remove_media(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
self.attaches.update(cx, |model, cx| { self.attaches.update(cx, |model, cx| {
if let Some(urls) = model.as_mut() { if let Some(urls) = model.as_mut() {
let ix = urls.iter().position(|x| x == url).unwrap(); if let Some(ix) = urls.iter().position(|x| x == url) {
urls.remove(ix); urls.remove(ix);
cx.notify(); cx.notify();
}
} }
}); });
} }
@@ -334,10 +358,10 @@ impl Chat {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
const ROOM_DESCRIPTION: &str = let Some(message) = self.messages.read(cx).get(ix) else {
"This conversation is private. Only members of this chat can see each other's messages."; return div().into_element();
};
let message = self.messages.read(cx).get(ix).unwrap();
let text_data = &mut self.text_data; let text_data = &mut self.text_data;
div() div()
@@ -427,7 +451,7 @@ impl Chat {
.size_10() .size_10()
.text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)), .text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)),
) )
.child(ROOM_DESCRIPTION), .child(DESC),
}) })
} }
} }
@@ -472,8 +496,28 @@ impl Panel for Chat {
menu.track_focus(&self.focus_handle) menu.track_focus(&self.focus_handle)
} }
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> { fn toolbar_buttons(&self, _window: &Window, cx: &App) -> Vec<Button> {
vec![] let id = self.room.read(cx).id;
let subject = self
.room
.read(cx)
.subject
.as_ref()
.map(|subject| subject.to_string());
let button = Button::new("subject")
.icon(IconName::EditFill)
.tooltip("Change Subject")
.on_click(move |_, window, cx| {
let subject = subject::init(id, subject.clone(), window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.title("Change the subject of the conversation")
.child(subject.clone())
});
});
vec![button]
} }
} }

View File

@@ -7,4 +7,5 @@ pub mod onboarding;
pub mod profile; pub mod profile;
pub mod relays; pub mod relays;
pub mod sidebar; pub mod sidebar;
pub mod subject;
pub mod welcome; pub mod welcome;

View File

@@ -283,8 +283,7 @@ impl Render for Relays {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div() div()
.track_focus(&self.focus_handle) .track_focus(&self.focus_handle)
.w_full() .size_full()
.h_full()
.flex() .flex()
.flex_col() .flex_col()
.justify_between() .justify_between()

View File

@@ -0,0 +1,111 @@
use chats::ChatRegistry;
use gpui::{
div, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
ParentElement, Render, Styled, Window,
};
use ui::{
button::{Button, ButtonVariants},
input::TextInput,
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Size,
};
pub fn init(
id: u64,
subject: Option<String>,
window: &mut Window,
cx: &mut App,
) -> Entity<Subject> {
Subject::new(id, subject, window, cx)
}
pub struct Subject {
id: u64,
input: Entity<TextInput>,
focus_handle: FocusHandle,
}
impl Subject {
pub fn new(
id: u64,
subject: Option<String>,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
let input = cx.new(|cx| {
let mut this = TextInput::new(window, cx).text_size(Size::Small);
if let Some(text) = subject.clone() {
this.set_text(text, window, cx);
} else {
this.set_placeholder("prepare for holidays...");
}
this
});
cx.new(|cx| Self {
id,
input,
focus_handle: cx.focus_handle(),
})
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let registry = ChatRegistry::global(cx).read(cx);
let subject = self.input.read(cx).text();
if subject.is_empty() {
window.push_notification("Subject cannot be empty", cx);
return;
}
if let Some(room) = registry.room(&self.id, cx) {
room.update(cx, |this, cx| {
this.subject = Some(subject);
cx.notify();
});
window.close_modal(cx);
} else {
window.push_notification("Room not found", cx);
}
}
}
impl Render for Subject {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const HELP_TEXT: &str = "Subject will be updated when you send a message.";
div()
.track_focus(&self.focus_handle)
.size_full()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.text_sm()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child("Subject:"),
)
.child(self.input.clone())
.child(
div()
.text_xs()
.italic()
.text_color(cx.theme().base.step(cx, ColorScaleStep::NINE))
.child(HELP_TEXT),
),
)
.child(
Button::new("submit")
.label("Change")
.primary()
.w_full()
.on_click(cx.listener(|this, _, window, cx| this.update(window, cx))),
)
}
}

View File

@@ -29,6 +29,7 @@ pub enum IconName {
CloseCircle, CloseCircle,
CloseCircleFill, CloseCircleFill,
Copy, Copy,
EditFill,
Ellipsis, Ellipsis,
Eye, Eye,
EyeOff, EyeOff,
@@ -92,6 +93,7 @@ impl IconName {
Self::CloseCircle => "icons/close-circle.svg", Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg", Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Copy => "icons/copy.svg", Self::Copy => "icons/copy.svg",
Self::EditFill => "icons/edit-fill.svg",
Self::Ellipsis => "icons/ellipsis.svg", Self::Ellipsis => "icons/ellipsis.svg",
Self::Eye => "icons/eye.svg", Self::Eye => "icons/eye.svg",
Self::EyeOff => "icons/eye-off.svg", Self::EyeOff => "icons/eye-off.svg",

View File

@@ -1,9 +1,10 @@
use crate::theme::{scale::ColorScaleStep, ActiveTheme};
use gpui::{ use gpui::{
div, px, relative, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, div, relative, App, AppContext, Context, Entity, IntoElement, ParentElement, Render,
SharedString, Styled, Window, SharedString, Styled, Window,
}; };
use crate::theme::{scale::ColorScaleStep, ActiveTheme};
pub struct Tooltip { pub struct Tooltip {
text: SharedString, text: SharedString,
} }
@@ -21,15 +22,15 @@ impl Render for Tooltip {
div() div()
.font_family(".SystemUIFont") .font_family(".SystemUIFont")
.m_3() .m_3()
.p_2()
.border_1() .border_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE)) .border_color(cx.theme().base.step(cx, ColorScaleStep::SIX))
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)) .bg(cx.theme().base.step(cx, ColorScaleStep::TWO))
.shadow_md() .shadow_lg()
.rounded(px(6.)) .rounded_lg()
.py_1() .text_sm()
.px_2() .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.text_xs() .line_height(relative(1.25))
.line_height(relative(1.))
.child(self.text.clone()), .child(self.text.clone()),
) )
} }