chore: improve render message (#84)

* .

* refactor upload button

* refactor

* dispatch action on mention clicked

* add profile modal

* .

* .

* .

* improve rich_text

* improve handle url

* make registry simpler

* refactor

* .

* clean up
This commit is contained in:
reya
2025-07-16 14:37:26 +07:00
committed by GitHub
parent 9f02942d87
commit 8195eedaf6
21 changed files with 887 additions and 468 deletions

6
Cargo.lock generated
View File

@@ -4780,14 +4780,12 @@ dependencies = [
"global", "global",
"gpui", "gpui",
"i18n", "i18n",
"identity",
"itertools 0.13.0", "itertools 0.13.0",
"log", "log",
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"oneshot", "oneshot",
"rust-i18n", "rust-i18n",
"settings",
"smallvec", "smallvec",
"smol", "smol",
] ]
@@ -6457,7 +6455,6 @@ name = "ui"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono",
"common", "common",
"emojis", "emojis",
"gpui", "gpui",
@@ -6465,10 +6462,11 @@ dependencies = [
"image", "image",
"itertools 0.13.0", "itertools 0.13.0",
"linkify", "linkify",
"log",
"nostr-sdk", "nostr-sdk",
"once_cell", "once_cell",
"paste",
"regex", "regex",
"registry",
"rust-i18n", "rust-i18n",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -41,7 +41,7 @@ impl DisplayProfile for Profile {
} }
} }
let pubkey = self.public_key().to_hex(); let Ok(pubkey) = self.public_key().to_bech32();
format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into() format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into()
} }

View File

@@ -6,8 +6,8 @@ use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH};
use global::nostr_client; use global::nostr_client;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, px, relative, Action, App, AppContext, Axis, Context, Entity, IntoElement, ParentElement, div, px, relative, Action, App, AppContext, Axis, Context, Entity, InteractiveElement,
Render, SharedString, Styled, Subscription, Task, Window, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window,
}; };
use i18n::t; use i18n::t;
use identity::Identity; use identity::Identity;
@@ -16,6 +16,7 @@ use registry::{Registry, RoomEmitter};
use serde::Deserialize; use serde::Deserialize;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use theme::{ActiveTheme, Theme, ThemeMode}; use theme::{ActiveTheme, Theme, ThemeMode};
use ui::actions::OpenProfile;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock_area::dock::DockPlacement; use ui::dock_area::dock::DockPlacement;
use ui::dock_area::panel::PanelView; use ui::dock_area::panel::PanelView;
@@ -24,7 +25,10 @@ use ui::modal::ModalButtonProps;
use ui::{ContextModal, IconName, Root, Sizable, StyledExt, TitleBar}; use ui::{ContextModal, IconName, Root, Sizable, StyledExt, TitleBar};
use crate::views::chat::{self, Chat}; use crate::views::chat::{self, Chat};
use crate::views::{login, new_account, onboarding, preferences, sidebar, startup, welcome}; use crate::views::user_profile::UserProfile;
use crate::views::{
login, new_account, onboarding, preferences, sidebar, startup, user_profile, welcome,
};
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)
@@ -69,7 +73,7 @@ pub struct ChatSpace {
dock: Entity<DockArea>, dock: Entity<DockArea>,
toolbar: bool, toolbar: bool,
#[allow(unused)] #[allow(unused)]
subscriptions: SmallVec<[Subscription; 4]>, subscriptions: SmallVec<[Subscription; 5]>,
} }
impl ChatSpace { impl ChatSpace {
@@ -167,12 +171,19 @@ impl ChatSpace {
)); ));
// Automatically load messages when chat panel opens // Automatically load messages when chat panel opens
subscriptions.push(cx.observe_new::<Chat>(|this: &mut Chat, window, cx| { subscriptions.push(cx.observe_new::<Chat>(|this, window, cx| {
if let Some(window) = window { if let Some(window) = window {
this.load_messages(window, cx); this.load_messages(window, cx);
} }
})); }));
// Automatically run on_load function from UserProfile
subscriptions.push(cx.observe_new::<UserProfile>(|this, window, cx| {
if let Some(window) = window {
this.on_load(window, cx);
}
}));
// Subscribe to open chat room requests // Subscribe to open chat room requests
subscriptions.push(cx.subscribe_in( subscriptions.push(cx.subscribe_in(
&registry, &registry,
@@ -307,6 +318,16 @@ impl ChatSpace {
}); });
} }
fn on_open_profile(&mut self, a: &OpenProfile, window: &mut Window, cx: &mut Context<Self>) {
let public_key = a.0;
let profile = user_profile::init(public_key, window, cx);
window.open_modal(cx, move |this, _window, _cx| {
// user_profile::init(public_key, window, cx)
this.child(profile.clone())
});
}
pub(crate) 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>() {
@@ -329,6 +350,7 @@ impl Render for ChatSpace {
let notification_layer = Root::render_notification_layer(window, cx); let notification_layer = Root::render_notification_layer(window, cx);
div() div()
.on_action(cx.listener(Self::on_open_profile))
.relative() .relative()
.size_full() .size_full()
.child( .child(

View File

@@ -20,6 +20,7 @@ use gpui::{
use gpui::{point, SharedString, TitlebarOptions}; use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations}; use gpui::{WindowBackgroundAppearance, WindowDecorations};
use identity::Identity;
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use registry::Registry; use registry::Registry;
@@ -257,6 +258,7 @@ fn main() {
cx.update(|window, cx| { cx.update(|window, cx| {
let registry = Registry::global(cx); let registry = Registry::global(cx);
let auto_updater = AutoUpdater::global(cx); let auto_updater = AutoUpdater::global(cx);
let identity = Identity::read_global(cx);
match signal { match signal {
// Load chat rooms and stop the loading status // Load chat rooms and stop the loading status
@@ -289,10 +291,12 @@ fn main() {
} }
// Convert the gift wrapped message to a message // Convert the gift wrapped message to a message
NostrSignal::GiftWrap(event) => { NostrSignal::GiftWrap(event) => {
if let Some(public_key) = identity.public_key() {
registry.update(cx, |this, cx| { registry.update(cx, |this, cx| {
this.event_to_message(event, window, cx); this.event_to_message(public_key, event, window, cx);
}); });
} }
}
NostrSignal::Notice(_msg) => { NostrSignal::Notice(_msg) => {
// window.push_notification(msg, cx); // window.push_notification(msg, cx);
} }

View File

@@ -3,17 +3,19 @@ use std::collections::HashMap;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use anyhow::anyhow;
use common::display::DisplayProfile; use common::display::DisplayProfile;
use common::nip96::nip96_upload; use common::nip96::nip96_upload;
use global::nostr_client; use global::nostr_client;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, img, list, px, red, rems, white, Action, AnyElement, App, AppContext, ClipboardItem, div, img, list, px, red, rems, white, Action, AnyElement, App, AppContext, ClipboardItem,
Context, Div, Element, Empty, Entity, EventEmitter, Flatten, FocusHandle, Focusable, Context, Element, Empty, Entity, EventEmitter, Flatten, FocusHandle, Focusable,
InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, InteractiveElement, IntoElement, ListAlignment, ListState, MouseButton, ObjectFit,
PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString,
Styled, StyledImage, Subscription, Window, StatefulInteractiveElement, Styled, StyledImage, Subscription, Window,
}; };
use gpui_tokio::Tokio;
use i18n::t; use i18n::t;
use identity::Identity; use identity::Identity;
use itertools::Itertools; use itertools::Itertools;
@@ -38,7 +40,7 @@ use ui::{
v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
}; };
use crate::views::subject; mod subject;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)] #[action(namespace = chat, no_json)]
@@ -63,6 +65,7 @@ pub struct Chat {
// Media Attachment // Media Attachment
attaches: Entity<Option<Vec<Url>>>, attaches: Entity<Option<Vec<Url>>>,
uploading: bool, uploading: bool,
// System
image_cache: Entity<RetainAllImageCache>, image_cache: Entity<RetainAllImageCache>,
#[allow(dead_code)] #[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>, subscriptions: SmallVec<[Subscription; 2]>,
@@ -91,17 +94,14 @@ impl Chat {
subscriptions.push(cx.subscribe_in( subscriptions.push(cx.subscribe_in(
&input, &input,
window, window,
move |this: &mut Self, input, event, window, cx| { move |this: &mut Self, input, event, window, cx| match event {
if let InputEvent::PressEnter { .. } = event { InputEvent::PressEnter { .. } => {
if input.read(cx).value().trim().is_empty() {
window.push_notification(
Notification::new(t!("chat.empty_message_error")),
cx,
);
} else {
this.send_message(window, cx); this.send_message(window, cx);
} }
InputEvent::Change(text) => {
this.mention_popup(text, input, cx);
} }
_ => {}
}, },
)); ));
@@ -189,8 +189,12 @@ impl Chat {
.detach(); .detach();
} }
/// Get user input message including all attachments fn mention_popup(&mut self, _text: &str, _input: &Entity<InputState>, _cx: &mut Context<Self>) {
fn message(&self, cx: &Context<Self>) -> String { // TODO: open mention popup at current cursor position
}
/// Get user input content and merged all attachments
fn input_content(&self, cx: &Context<Self>) -> String {
let mut content = self.input.read(cx).value().trim().to_string(); let mut content = self.input.read(cx).value().trim().to_string();
// Get all attaches and merge its with message // Get all attaches and merge its with message
@@ -238,21 +242,40 @@ impl Chat {
} }
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Return if user is not logged in
let Some(identity) = Identity::read_global(cx).public_key() else {
// window.push_notification("Login is required", cx);
return;
};
// Get the message which includes all attachments
let content = self.input_content(cx);
// Get the backup setting
let backup = AppSettings::get_global(cx).settings.backup_messages;
// Return if message is empty
if content.trim().is_empty() {
window.push_notification(t!("chat.empty_message_error"), cx);
return;
}
// Temporary disable input
self.input.update(cx, |this, cx| { self.input.update(cx, |this, cx| {
this.set_loading(true, cx); this.set_loading(true, cx);
this.set_disabled(true, cx); this.set_disabled(true, cx);
}); });
// Get the message which includes all attachments
let content = self.message(cx);
// Get replies_to if it's present // Get replies_to if it's present
let replies = self.replies_to.read(cx).as_ref(); let replies = self.replies_to.read(cx).as_ref();
// Get the current room entity // Get the current room entity
let room = self.room.read(cx); let room = self.room.read(cx);
// Create a temporary message for optimistic update // Create a temporary message for optimistic update
let temp_message = room.create_temp_message(&content, replies, cx); let temp_message = room.create_temp_message(identity, &content, replies);
// Create a task for sending the message in the background // Create a task for sending the message in the background
let send_message = room.send_in_background(&content, replies, cx); let send_message = room.send_in_background(&content, replies, backup, cx);
if let Some(message) = temp_message { if let Some(message) = temp_message {
let id = message.id; let id = message.id;
@@ -284,7 +307,7 @@ impl Chat {
if let Some(msg) = if let Some(msg) =
this.iter().find(|msg| msg.borrow().id == id).cloned() this.iter().find(|msg| msg.borrow().id == id).cloned()
{ {
msg.borrow_mut().errors = Some(reports.into()); msg.borrow_mut().errors = Some(reports);
cx.notify(); cx.notify();
} }
}); });
@@ -320,7 +343,24 @@ impl Chat {
} }
} }
fn reply(&mut self, message: Message, cx: &mut Context<Self>) { fn copy_message(&self, ix: usize, cx: &Context<Self>) {
let Some(item) = self
.messages
.read(cx)
.get(ix)
.map(|m| ClipboardItem::new_string(m.borrow().content.to_string()))
else {
return;
};
cx.write_to_clipboard(item);
}
fn reply_to(&mut self, ix: usize, cx: &mut Context<Self>) {
let Some(message) = self.messages.read(cx).get(ix).map(|m| m.borrow().clone()) else {
return;
};
self.replies_to.update(cx, |this, cx| { self.replies_to.update(cx, |this, cx| {
if let Some(replies) = this { if let Some(replies) = this {
replies.push(message); replies.push(message);
@@ -349,40 +389,72 @@ impl Chat {
}); });
} }
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.uploading { if self.uploading {
return; return;
} }
// Block the upload button to until current task is resolved
self.uploading(true, cx); self.uploading(true, cx);
let nip96 = AppSettings::get_global(cx).settings.media_server.clone(); // Get the user's configured NIP96 server
let nip96_server = AppSettings::get_global(cx).settings.media_server.clone();
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions { let paths = cx.prompt_for_paths(PathPromptOptions {
files: true, files: true,
directories: false, directories: false,
multiple: false, multiple: false,
}); });
cx.spawn_in(window, async move |this, cx| { let task = Tokio::spawn(cx, async move {
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 Some(path) = paths.pop() else { if let Some(path) = paths.pop() {
return; let file = fs::read(path).await?;
}; let url = nip96_upload(nostr_client(), &nip96_server, file).await?;
if let Ok(file_data) = fs::read(path).await { Ok(url)
let (tx, rx) = oneshot::channel::<Option<Url>>(); } else {
Err(anyhow!("Path not found"))
// Spawn task via async utility instead of GPUI context }
nostr_sdk::async_utility::task::spawn(async move { }
let url = nip96_upload(nostr_client(), &nip96, file_data).await.ok(); Ok(None) => Err(anyhow!("User cancelled")),
_ = tx.send(url); Err(e) => Err(anyhow!("File dialog error: {e}")),
}
}); });
if let Ok(Some(url)) = rx.await { cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(task.await.map_err(|e| e.into())) {
Ok(Ok(url)) => {
this.update(cx, |this, cx| {
this.add_attachment(url, cx);
})
.ok();
}
Ok(Err(e)) => {
log::warn!("User cancelled: {e}");
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.uploading(false, cx); this.uploading(false, cx);
this.attaches.update(cx, |this, cx| { })
.ok();
}
Err(e) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
window.push_notification(e.to_string(), cx);
this.uploading(false, cx);
})
.ok();
})
.ok();
}
}
})
.detach();
}
fn add_attachment(&mut self, url: Url, cx: &mut Context<Self>) {
self.attaches.update(cx, |this, cx| {
if let Some(model) = this.as_mut() { if let Some(model) = this.as_mut() {
model.push(url); model.push(url);
} else { } else {
@@ -390,31 +462,10 @@ impl Chat {
} }
cx.notify(); cx.notify();
}); });
}) self.uploading(false, cx);
.ok();
} else {
this.update(cx, |this, cx| {
this.uploading(false, cx);
})
.ok();
}
}
}
Ok(None) => {
this.update(cx, |this, cx| {
this.uploading(false, cx);
})
.ok();
}
Err(e) => {
log::error!("System error: {e}")
}
}
})
.detach();
} }
fn remove_media(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) { fn remove_attachment(&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() {
if let Some(ix) = urls.iter().position(|x| x == url) { if let Some(ix) = urls.iter().position(|x| x == url) {
@@ -459,11 +510,11 @@ impl Chat {
.child(Icon::new(IconName::Close).size_2().text_color(white())), .child(Icon::new(IconName::Close).size_2().text_color(white())),
) )
.on_click(cx.listener(move |this, _, window, cx| { .on_click(cx.listener(move |this, _, window, cx| {
this.remove_media(&url, window, cx); this.remove_attachment(&url, window, cx);
})) }))
} }
fn render_reply(&mut self, message: &Message, cx: &Context<Self>) -> impl IntoElement { fn render_reply_to(&mut self, message: &Message, cx: &Context<Self>) -> impl IntoElement {
let registry = Registry::read_global(cx); let registry = Registry::read_global(cx);
let profile = registry.get_person(&message.author, cx); let profile = registry.get_person(&message.author, cx);
@@ -520,22 +571,19 @@ impl Chat {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
let Some(message) = self.messages.read(cx).get(ix) else { let Some(message) = self.messages.read(cx).get(ix).map(|m| m.borrow()) else {
return div().id(ix); return div().id(ix);
}; };
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars; let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars;
let registry = Registry::read_global(cx); let registry = Registry::read_global(cx);
let message = message.borrow();
let author = registry.get_person(&message.author, cx); let author = registry.get_person(&message.author, cx);
let mentions = registry.get_group_person(&message.mentions, cx);
let texts = self let texts = self
.text_data .text_data
.entry(message.id) .entry(message.id)
.or_insert_with(|| RichText::new(message.content.to_string(), &mentions)); .or_insert_with(|| RichText::new(&message.content, cx));
div() div()
.id(ix) .id(ix)
@@ -578,17 +626,17 @@ impl Chat {
) )
.when_some(message.replies_to.as_ref(), |this, replies| { .when_some(message.replies_to.as_ref(), |this, replies| {
this.w_full().children({ this.w_full().children({
let mut items = vec![]; let mut items = Vec::with_capacity(replies.len());
let messages = self.messages.read(cx);
for (ix, id) in replies.iter().enumerate() { for (ix, id) in replies.iter().cloned().enumerate() {
if let Some(message) = self let Some(message) = messages
.messages
.read(cx)
.iter() .iter()
.find(|msg| msg.borrow().id == *id) .map(|m| m.borrow())
.cloned() .find(|m| m.id == id)
{ else {
let message = message.borrow(); continue;
};
items.push( items.push(
div() div()
@@ -611,26 +659,42 @@ impl Chat {
.child(message.content.clone()), .child(message.content.clone()),
) )
.hover(|this| { .hover(|this| {
this.bg(cx this.bg(cx.theme().elevated_surface_background)
.theme()
.elevated_surface_background)
}) })
.on_click({ .on_click(cx.listener(move |this, _, _, cx| {
let id = message.id;
cx.listener(move |this, _, _, cx| {
this.scroll_to(id, cx) this.scroll_to(id, cx)
}) })),
}),
); );
} }
}
items items
}) })
}) })
.child(texts.element("body".into(), window, cx)) .child(texts.element(ix.into(), window, cx))
.when_some(message.errors.clone(), |this, errors| { .when_some(message.errors.as_ref(), |this, errors| {
this.child( this.child(self.render_message_errors(errors, cx))
}),
),
)
.child(self.render_border(cx))
.child(self.render_actions(ix, cx))
.on_mouse_down(
MouseButton::Middle,
cx.listener(move |this, _event, _window, cx| {
this.copy_message(ix, cx);
}),
)
.on_double_click(cx.listener({
move |this, _event, _window, cx| {
this.reply_to(ix, cx);
}
}))
.hover(|this| this.bg(cx.theme().surface_background))
}
fn render_message_errors(&self, errors: &[SendError], _cx: &Context<Self>) -> impl IntoElement {
let errors = Rc::new(errors.to_owned());
div() div()
.id("") .id("")
.flex() .flex()
@@ -642,58 +706,82 @@ impl Chat {
.child(Icon::new(IconName::Info).small()) .child(Icon::new(IconName::Info).small())
.child(SharedString::new(t!("chat.send_fail"))) .child(SharedString::new(t!("chat.send_fail")))
.on_click(move |_, window, cx| { .on_click(move |_, window, cx| {
let errors = errors.clone(); let errors = Rc::clone(&errors);
window.open_modal(cx, move |this, _window, cx| { window.open_modal(cx, move |this, _window, cx| {
this.title(SharedString::new(t!("chat.logs_title"))) this.title(SharedString::new(t!("chat.logs_title"))).child(
.child(message_errors(errors.clone(), cx)) div()
.w_full()
.flex()
.flex_col()
.gap_2()
.px_3()
.pb_3()
.children(errors.iter().map(|error| {
div()
.text_sm()
.child(
div()
.flex()
.items_baseline()
.gap_1()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("chat.send_to_label")))
.child(error.profile.display_name()),
)
.child(error.message.clone())
})),
)
}); });
}), })
) }
}),
), fn render_border(&self, cx: &Context<Self>) -> impl IntoElement {
) div()
.child(message_border(cx)) .group_hover("", |this| this.bg(cx.theme().element_active))
.child(message_actions( .absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().border_transparent)
}
fn render_actions(&self, ix: usize, cx: &Context<Self>) -> impl IntoElement {
div()
.group_hover("", |this| this.visible())
.invisible()
.absolute()
.right_4()
.top_neg_2()
.shadow_sm()
.rounded_md()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.p_0p5()
.flex()
.gap_1()
.children({
vec![ vec![
Button::new("reply") Button::new("reply")
.icon(IconName::Reply) .icon(IconName::Reply)
.tooltip(t!("chat.reply_button")) .tooltip(t!("chat.reply_button"))
.small() .small()
.ghost() .ghost()
.on_click({ .on_click(cx.listener(move |this, _event, _window, cx| {
let message = message.clone(); this.reply_to(ix, cx);
cx.listener(move |this, _event, _window, cx| { })),
this.reply(message.clone(), cx);
})
}),
Button::new("copy") Button::new("copy")
.icon(IconName::Copy) .icon(IconName::Copy)
.tooltip(t!("chat.copy_message_button")) .tooltip(t!("chat.copy_message_button"))
.small() .small()
.ghost() .ghost()
.on_click({ .on_click(cx.listener(move |this, _event, _window, cx| {
let content = ClipboardItem::new_string(message.content.to_string()); this.copy_message(ix, cx);
cx.listener(move |_this, _event, _window, cx| { })),
cx.write_to_clipboard(content.clone()) ]
}) })
}),
],
cx,
))
.on_mouse_down(gpui::MouseButton::Middle, {
let content = ClipboardItem::new_string(message.content.to_string());
cx.listener(move |_this, _event, _window, cx| {
cx.write_to_clipboard(content.clone())
})
})
.on_double_click(cx.listener({
let message = message.clone();
move |this, _, _window, cx| {
this.reply(message.clone(), cx);
}
}))
.hover(|this| this.bg(cx.theme().surface_background))
} }
} }
@@ -703,9 +791,10 @@ impl Panel for Chat {
} }
fn title(&self, cx: &App) -> AnyElement { fn title(&self, cx: &App) -> AnyElement {
self.room.read_with(cx, |this, _| { self.room.read_with(cx, |this, cx| {
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let label = this.display_name(cx); let label = this.display_name(cx);
let url = this.display_image(cx); let url = this.display_image(proxy, cx);
div() div()
.flex() .flex()
@@ -780,7 +869,7 @@ impl Render for Chat {
let mut items = vec![]; let mut items = vec![];
for message in messages.iter() { for message in messages.iter() {
items.push(self.render_reply(message, cx)); items.push(self.render_reply_to(message, cx));
} }
items items
@@ -806,7 +895,7 @@ impl Render for Chat {
.loading(self.uploading) .loading(self.uploading)
.on_click(cx.listener( .on_click(cx.listener(
move |this, _, window, cx| { move |this, _, window, cx| {
this.upload_media(window, cx); this.upload(window, cx);
}, },
)), )),
) )
@@ -821,55 +910,3 @@ impl Render for Chat {
) )
} }
} }
fn message_border(cx: &App) -> Div {
div()
.group_hover("", |this| this.bg(cx.theme().element_active))
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().border_transparent)
}
fn message_errors(errors: SmallVec<[SendError; 1]>, cx: &App) -> Div {
div()
.flex()
.flex_col()
.gap_2()
.px_3()
.pb_3()
.children(errors.into_iter().map(|error| {
div()
.text_sm()
.child(
div()
.flex()
.items_baseline()
.gap_1()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("chat.send_to_label")))
.child(error.profile.display_name()),
)
.child(error.message)
}))
}
fn message_actions(buttons: impl IntoIterator<Item = impl IntoElement>, cx: &App) -> Div {
div()
.group_hover("", |this| this.visible())
.invisible()
.absolute()
.right_4()
.top_neg_2()
.shadow_sm()
.rounded_md()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.p_0p5()
.flex()
.gap_1()
.children(buttons)
}

View File

@@ -17,11 +17,11 @@ use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput}; use ui::input::{InputState, TextInput};
use ui::{ContextModal, Disableable, IconName, Sizable}; use ui::{ContextModal, Disableable, IconName, Sizable};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Profile> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<EditProfile> {
Profile::new(window, cx) EditProfile::new(window, cx)
} }
pub struct Profile { pub struct EditProfile {
profile: Option<Metadata>, profile: Option<Metadata>,
name_input: Entity<InputState>, name_input: Entity<InputState>,
avatar_input: Entity<InputState>, avatar_input: Entity<InputState>,
@@ -31,7 +31,7 @@ pub struct Profile {
is_submitting: bool, is_submitting: bool,
} }
impl Profile { impl EditProfile {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> { pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let name_input = let name_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("profile.placeholder_name"))); cx.new(|cx| InputState::new(window, cx).placeholder(t!("profile.placeholder_name")));
@@ -70,7 +70,7 @@ impl Profile {
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
if let Ok(Some(metadata)) = task.await { if let Ok(Some(metadata)) = task.await {
cx.update(|window, cx| { cx.update(|window, cx| {
this.update(cx, |this: &mut Profile, cx| { this.update(cx, |this: &mut EditProfile, cx| {
this.avatar_input.update(cx, |this, cx| { this.avatar_input.update(cx, |this, cx| {
if let Some(avatar) = metadata.picture.as_ref() { if let Some(avatar) = metadata.picture.as_ref() {
this.set_value(avatar, window, cx); this.set_value(avatar, window, cx);
@@ -230,7 +230,7 @@ impl Profile {
} }
} }
impl Render for Profile { impl Render for EditProfile {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div() div()
.size_full() .size_full()

View File

@@ -1,12 +1,12 @@
pub mod chat; pub mod chat;
pub mod compose; pub mod compose;
pub mod edit_profile;
pub mod login; pub mod login;
pub mod new_account; pub mod new_account;
pub mod onboarding; pub mod onboarding;
pub mod preferences; pub mod preferences;
pub mod profile;
pub mod relays; pub mod relays;
pub mod sidebar; pub mod sidebar;
pub mod startup; pub mod startup;
pub mod subject; pub mod user_profile;
pub mod welcome; pub mod welcome;

View File

@@ -17,7 +17,7 @@ use ui::input::{InputState, TextInput};
use ui::switch::Switch; use ui::switch::Switch;
use ui::{ContextModal, IconName, Sizable, Size, StyledExt}; use ui::{ContextModal, IconName, Sizable, Size, StyledExt};
use crate::views::{profile, relays}; use crate::views::{edit_profile, relays};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
Preferences::new(window, cx) Preferences::new(window, cx)
@@ -49,15 +49,15 @@ impl Preferences {
}) })
} }
fn open_profile(&self, window: &mut Window, cx: &mut Context<Self>) { fn open_edit_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
let profile = profile::init(window, cx); let edit_profile = edit_profile::init(window, cx);
window.open_modal(cx, move |modal, _window, _cx| { window.open_modal(cx, move |modal, _window, _cx| {
let title = SharedString::new(t!("preferences.modal_profile_title")); let title = SharedString::new(t!("preferences.modal_profile_title"));
modal modal
.title(title) .title(title)
.width(px(DEFAULT_MODAL_WIDTH)) .width(px(DEFAULT_MODAL_WIDTH))
.child(profile.clone()) .child(edit_profile.clone())
}); });
} }
@@ -143,8 +143,8 @@ impl Render for Preferences {
))), ))),
), ),
) )
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(move |this, _e, window, cx| {
this.open_profile(window, cx); this.open_edit_profile(window, cx);
})), })),
) )
.child( .child(
@@ -152,7 +152,7 @@ impl Render for Preferences {
.label("DM Relays") .label("DM Relays")
.ghost() .ghost()
.small() .small()
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(move |this, _e, window, cx| {
this.open_relays(window, cx); this.open_relays(window, cx);
})), })),
), ),

View File

@@ -684,6 +684,7 @@ impl Sidebar {
range: Range<usize>, range: Range<usize>,
cx: &Context<Self>, cx: &Context<Self>,
) -> Vec<impl IntoElement> { ) -> Vec<impl IntoElement> {
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let mut items = Vec::with_capacity(range.end - range.start); let mut items = Vec::with_capacity(range.end - range.start);
for ix in range { for ix in range {
@@ -692,7 +693,7 @@ impl Sidebar {
let id = this.id; let id = this.id;
let ago = this.ago(); let ago = this.ago();
let label = this.display_name(cx); let label = this.display_name(cx);
let img = this.display_image(cx); let img = this.display_image(proxy, cx);
let handler = cx.listener(move |this, _, window, cx| { let handler = cx.listener(move |this, _, window, cx| {
this.open_room(id, window, cx); this.open_room(id, window, cx);

View File

@@ -0,0 +1,269 @@
use std::time::Duration;
use common::display::DisplayProfile;
use common::nip05::nip05_verify;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement,
ParentElement, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use nostr_sdk::prelude::*;
use registry::Registry;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, StyledExt};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
UserProfile::new(public_key, window, cx)
}
pub struct UserProfile {
public_key: PublicKey,
followed: bool,
verified: bool,
copied: bool,
}
impl UserProfile {
pub fn new(public_key: PublicKey, _window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self {
public_key,
followed: false,
verified: false,
copied: false,
})
}
pub fn on_load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Skip if user isn't logged in
let Some(identity) = Identity::read_global(cx).public_key() else {
return;
};
let public_key = self.public_key;
let check_follow: Task<bool> = cx.background_spawn(async move {
let client = nostr_client();
let filter = Filter::new()
.kind(Kind::ContactList)
.author(identity)
.pubkey(public_key)
.limit(1);
client.database().count(filter).await.unwrap_or(0) >= 1
});
let verify_nip05 = if let Some(address) = self.address(cx) {
Some(Tokio::spawn(cx, async move {
nip05_verify(public_key, &address).await.unwrap_or(false)
}))
} else {
None
};
cx.spawn_in(window, async move |this, cx| {
let followed = check_follow.await;
// Update the followed status
this.update(cx, |this, cx| {
this.followed = followed;
cx.notify();
})
.ok();
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
}
}
})
.detach();
}
fn profile(&self, cx: &Context<Self>) -> Profile {
let registry = Registry::read_global(cx);
registry.get_person(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<String> {
self.profile(cx).metadata().nip05
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
let Ok(bech32) = self.public_key.to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
}
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.public_key.to_bech32();
let item = ClipboardItem::new_string(bech32);
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
// Reset the copied state after a delay
if status {
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
})
.detach();
}
}
}
impl Render for UserProfile {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let profile = self.profile(cx);
let Ok(bech32) = profile.public_key().to_bech32();
let shared_bech32 = SharedString::new(bech32);
v_flex()
.px_4()
.pt_8()
.pb_4()
.gap_4()
.child(
v_flex()
.gap_3()
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(4.)))
.child(
v_flex()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.display_name()),
)
.when_some(self.address(cx), |this, address| {
this.child(
h_flex()
.justify_center()
.gap_1()
.text_xs()
.text_color(cx.theme().text_muted)
.child(address)
.when(self.verified, |this| {
this.child(
div()
.relative()
.text_color(cx.theme().text_accent)
.child(
Icon::new(IconName::CheckCircleFill)
.small()
.block(),
),
)
}),
)
}),
)
.when(!self.followed, |this| {
this.child(
div()
.p_1()
.rounded_full()
.bg(cx.theme().surface_background)
.text_xs()
.child(SharedString::new(t!("profile.unknown"))),
)
}),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.block()
.text_color(cx.theme().text_muted)
.child("Public Key:"),
)
.child(
h_flex()
.gap_1()
.child(
div()
.p_1p5()
.h_9()
.rounded_md()
.bg(cx.theme().elevated_surface_background)
.truncate()
.text_ellipsis()
.line_clamp(1)
.child(shared_bech32),
)
.child(
Button::new("copy-pubkey")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.ghost()
.disabled(self.copied)
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy_pubkey(window, cx);
})),
),
),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("profile.label_bio"))),
)
.when_some(profile.metadata().about, |this, bio| {
this.child(
div()
.p_1p5()
.rounded_md()
.bg(cx.theme().elevated_surface_background)
.child(bio),
)
}),
)
.child(
Button::new("open-njump")
.label(t!("profile.njump"))
.primary()
.small()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_njump(window, cx);
})),
)
}
}

View File

@@ -7,8 +7,6 @@ publish.workspace = true
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
global = { path = "../global" } global = { path = "../global" }
identity = { path = "../identity" }
settings = { path = "../settings" }
rust-i18n.workspace = true rust-i18n.workspace = true
i18n.workspace = true i18n.workspace = true

View File

@@ -9,7 +9,6 @@ use global::nostr_client;
use gpui::{ use gpui::{
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window, App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
}; };
use identity::Identity;
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use room::RoomKind; use room::RoomKind;
@@ -142,11 +141,11 @@ impl Registry {
.unwrap_or(Profile::new(public_key.to_owned(), Metadata::default())) .unwrap_or(Profile::new(public_key.to_owned(), Metadata::default()))
} }
pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec<Option<Profile>> { pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec<Profile> {
let mut profiles = vec![]; let mut profiles = vec![];
for public_key in public_keys.iter() { for public_key in public_keys.iter() {
let profile = self.persons.get(public_key).map(|e| e.read(cx)).cloned(); let profile = self.get_person(public_key, cx);
profiles.push(profile); profiles.push(profile);
} }
@@ -315,11 +314,19 @@ impl Registry {
let is_ongoing = client.database().count(filter).await.unwrap_or(1) >= 1; let is_ongoing = client.database().count(filter).await.unwrap_or(1) >= 1;
if is_ongoing { if is_ongoing {
rooms.insert(Room::new(&event).kind(RoomKind::Ongoing)); rooms.insert(
Room::new(&event)
.kind(RoomKind::Ongoing)
.rearrange_by(public_key),
);
} else if is_trust { } else if is_trust {
rooms.insert(Room::new(&event).kind(RoomKind::Trusted)); rooms.insert(
Room::new(&event)
.kind(RoomKind::Trusted)
.rearrange_by(public_key),
);
} else { } else {
rooms.insert(Room::new(&event)); rooms.insert(Room::new(&event).rearrange_by(public_key));
} }
} }
@@ -388,14 +395,16 @@ impl Registry {
/// ///
/// If the room doesn't exist, it will be created. /// If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages. /// Updates room ordering based on the most recent messages.
pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) { pub fn event_to_message(
&mut self,
identity: PublicKey,
event: Event,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = room_hash(&event); let id = room_hash(&event);
let author = event.pubkey; let author = event.pubkey;
let Some(identity) = Identity::read_global(cx).public_key() else {
return;
};
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) {
// Update room // Update room
room.update(cx, |this, cx| { room.update(cx, |this, cx| {
@@ -415,15 +424,16 @@ impl Registry {
// Re-sort the rooms registry by their created at // Re-sort the rooms registry by their created at
self.sort(cx); self.sort(cx);
} else { } else {
let room = Room::new(&event).kind(RoomKind::Unknown); let room = Room::new(&event)
let kind = room.kind; .kind(RoomKind::Unknown)
.rearrange_by(identity);
// Push the new room to the front of the list // Push the new room to the front of the list
self.add_room(cx.new(|_| room), cx); self.add_room(cx.new(|_| room), cx);
// Notify the UI about the new room // Notify the UI about the new room
cx.defer_in(window, move |_this, _window, cx| { cx.defer_in(window, move |_this, _window, cx| {
cx.emit(RoomEmitter::Request(kind)); cx.emit(RoomEmitter::Request(RoomKind::Unknown));
}); });
} }
} }

View File

@@ -5,7 +5,6 @@ use std::rc::Rc;
use chrono::{Local, TimeZone}; use chrono::{Local, TimeZone};
use gpui::SharedString; use gpui::SharedString;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use crate::room::SendError; use crate::room::SendError;
@@ -24,11 +23,11 @@ pub struct Message {
/// When the message was created /// When the message was created
pub created_at: Timestamp, pub created_at: Timestamp,
/// List of mentioned public keys in the message /// List of mentioned public keys in the message
pub mentions: SmallVec<[PublicKey; 2]>, pub mentions: Vec<PublicKey>,
/// List of EventIds this message is replying to /// List of EventIds this message is replying to
pub replies_to: Option<SmallVec<[EventId; 1]>>, pub replies_to: Option<Vec<EventId>>,
/// Any errors that occurred while sending this message /// Any errors that occurred while sending this message
pub errors: Option<SmallVec<[SendError; 1]>>, pub errors: Option<Vec<SendError>>,
} }
/// Builder pattern implementation for constructing Message objects. /// Builder pattern implementation for constructing Message objects.
@@ -38,9 +37,9 @@ pub struct MessageBuilder {
author: PublicKey, author: PublicKey,
content: Option<SharedString>, content: Option<SharedString>,
created_at: Option<Timestamp>, created_at: Option<Timestamp>,
mentions: SmallVec<[PublicKey; 2]>, mentions: Vec<PublicKey>,
replies_to: Option<SmallVec<[EventId; 1]>>, replies_to: Option<Vec<EventId>>,
errors: Option<SmallVec<[SendError; 1]>>, errors: Option<Vec<SendError>>,
} }
impl MessageBuilder { impl MessageBuilder {
@@ -51,7 +50,7 @@ impl MessageBuilder {
author, author,
content: None, content: None,
created_at: None, created_at: None,
mentions: smallvec![], mentions: vec![],
replies_to: None, replies_to: None,
errors: None, errors: None,
} }
@@ -86,7 +85,7 @@ impl MessageBuilder {
/// Sets a single message this is replying to /// Sets a single message this is replying to
pub fn reply_to(mut self, reply_to: EventId) -> Self { pub fn reply_to(mut self, reply_to: EventId) -> Self {
self.replies_to = Some(smallvec![reply_to]); self.replies_to = Some(vec![reply_to]);
self self
} }
@@ -95,7 +94,7 @@ impl MessageBuilder {
where where
I: IntoIterator<Item = EventId>, I: IntoIterator<Item = EventId>,
{ {
let replies: SmallVec<[EventId; 1]> = replies_to.into_iter().collect(); let replies: Vec<EventId> = replies_to.into_iter().collect();
if !replies.is_empty() { if !replies.is_empty() {
self.replies_to = Some(replies); self.replies_to = Some(replies);
} }

View File

@@ -5,10 +5,8 @@ use chrono::{Local, TimeZone};
use common::display::DisplayProfile; use common::display::DisplayProfile;
use global::nostr_client; use global::nostr_client;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window}; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
use identity::Identity;
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings;
use smallvec::SmallVec; use smallvec::SmallVec;
use crate::message::Message; use crate::message::Message;
@@ -26,7 +24,7 @@ pub struct Incoming(pub Message);
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SendError { pub struct SendError {
pub profile: Profile, pub profile: Profile,
pub message: String, pub message: SharedString,
} }
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] #[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
@@ -126,6 +124,27 @@ impl Room {
self self
} }
/// Sets the rearrange_by field of the room and returns the modified room
///
/// This is a builder-style method that allows chaining room modifications.
///
/// # Arguments
///
/// * `rearrange_by` - The PublicKey to set for rearranging the member list
///
/// # Returns
///
/// The modified Room instance with the new member list after rearrangement
pub fn rearrange_by(mut self, rearrange_by: PublicKey) -> Self {
let (not_match, matches): (Vec<PublicKey>, Vec<PublicKey>) = self
.members
.into_iter()
.partition(|key| key != &rearrange_by);
self.members = not_match.into();
self.members.extend(matches);
self
}
/// Set the room kind to ongoing /// Set the room kind to ongoing
/// ///
/// # Arguments /// # Arguments
@@ -240,14 +259,13 @@ impl Room {
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `proxy` - Whether to use the proxy for the avatar URL
/// * `cx` - The application context /// * `cx` - The application context
/// ///
/// # Returns /// # Returns
/// ///
/// A SharedString containing the image path or URL /// A SharedString containing the image path or URL
pub fn display_image(&self, cx: &App) -> SharedString { pub fn display_image(&self, proxy: bool, cx: &App) -> SharedString {
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
if let Some(picture) = self.picture.as_ref() { if let Some(picture) = self.picture.as_ref() {
picture.clone() picture.clone()
} else if !self.is_group() { } else if !self.is_group() {
@@ -262,19 +280,8 @@ impl Room {
/// First member is always different from the current user. /// First member is always different from the current user.
pub(crate) fn first_member(&self, cx: &App) -> Profile { pub(crate) fn first_member(&self, cx: &App) -> Profile {
let registry = Registry::read_global(cx); let registry = Registry::read_global(cx);
if let Some(identity) = Identity::read_global(cx).public_key().as_ref() {
self.members
.iter()
.filter(|&pubkey| pubkey != identity)
.collect::<Vec<_>>()
.first()
.map(|public_key| registry.get_person(public_key, cx))
.unwrap_or(registry.get_person(identity, cx))
} else {
registry.get_person(&self.members[0], cx) registry.get_person(&self.members[0], cx)
} }
}
/// Merge the names of the first two members of the room. /// Merge the names of the first two members of the room.
pub(crate) fn merge_name(&self, cx: &App) -> SharedString { pub(crate) fn merge_name(&self, cx: &App) -> SharedString {
@@ -474,11 +481,10 @@ impl Room {
/// or `None` if no account is found. /// or `None` if no account is found.
pub fn create_temp_message( pub fn create_temp_message(
&self, &self,
public_key: PublicKey,
content: &str, content: &str,
replies: Option<&Vec<Message>>, replies: Option<&Vec<Message>>,
cx: &App,
) -> Option<Message> { ) -> Option<Message> {
let public_key = Identity::read_global(cx).public_key()?;
let builder = EventBuilder::private_msg_rumor(public_key, content); let builder = EventBuilder::private_msg_rumor(public_key, content);
// Add event reference if it's present (replying to another event) // Add event reference if it's present (replying to another event)
@@ -549,6 +555,7 @@ impl Room {
&self, &self,
content: &str, content: &str,
replies: Option<&Vec<Message>>, replies: Option<&Vec<Message>>,
backup: bool,
cx: &App, cx: &App,
) -> Task<Result<Vec<SendError>, Error>> { ) -> Task<Result<Vec<SendError>, Error>> {
let content = content.to_owned(); let content = content.to_owned();
@@ -556,7 +563,6 @@ impl Room {
let subject = self.subject.clone(); let subject = self.subject.clone();
let picture = self.picture.clone(); let picture = self.picture.clone();
let public_keys = self.members.clone(); let public_keys = self.members.clone();
let backup = AppSettings::get_global(cx).settings.backup_messages;
cx.background_spawn(async move { cx.background_spawn(async move {
let client = nostr_client(); let client = nostr_client();
@@ -615,7 +621,7 @@ impl Room {
let profile = Profile::new(*receiver, metadata); let profile = Profile::new(*receiver, metadata);
let report = SendError { let report = SendError {
profile, profile,
message: e.to_string(), message: e.to_string().into(),
}; };
reports.push(report); reports.push(report);
@@ -636,7 +642,7 @@ impl Room {
let profile = Profile::new(*current_user, metadata); let profile = Profile::new(*current_user, metadata);
let report = SendError { let report = SendError {
profile, profile,
message: e.to_string(), message: e.to_string().into(),
}; };
reports.push(report); reports.push(report);
} }

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
theme = { path = "../theme" } theme = { path = "../theme" }
registry = { path = "../registry" }
rust-i18n.workspace = true rust-i18n.workspace = true
i18n.workspace = true i18n.workspace = true
@@ -18,13 +19,12 @@ serde_json.workspace = true
smallvec.workspace = true smallvec.workspace = true
anyhow.workspace = true anyhow.workspace = true
itertools.workspace = true itertools.workspace = true
chrono.workspace = true log.workspace = true
emojis.workspace = true
paste = "1"
regex = "1" regex = "1"
unicode-segmentation = "1.12.0" unicode-segmentation = "1.12.0"
uuid = "1.10" uuid = "1.10"
once_cell = "1.19.0" once_cell = "1.19.0"
image = "0.25.1" image = "0.25.1"
linkify = "0.10.0" linkify = "0.10.0"
emojis.workspace = true

View File

@@ -1,6 +1,12 @@
use gpui::{actions, Action}; use gpui::{actions, Action};
use nostr_sdk::prelude::PublicKey;
use serde::Deserialize; use serde::Deserialize;
/// Define a open profile action
#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)]
#[action(namespace = profile, no_json)]
pub struct OpenProfile(pub PublicKey);
/// Define a custom confirm action /// Define a custom confirm action
#[derive(Clone, Action, PartialEq, Eq, Deserialize)] #[derive(Clone, Action, PartialEq, Eq, Deserialize)]
#[action(namespace = list, no_json)] #[action(namespace = list, no_json)]

View File

@@ -572,8 +572,12 @@ impl DockArea {
} }
} }
DockPlacement::Center => { DockPlacement::Center => {
let focus_handle = panel.focus_handle(cx);
// Add panel
self.items self.items
.add_panel(panel, &cx.entity().downgrade(), window, cx); .add_panel(panel, &cx.entity().downgrade(), window, cx);
// Focus to the newly added panel
window.focus(&focus_handle);
} }
} }
} }

View File

@@ -8,7 +8,7 @@ pub use window_border::{window_border, WindowBorder};
pub use crate::Disableable; pub use crate::Disableable;
pub(crate) mod actions; pub mod actions;
pub mod animation; pub mod animation;
pub mod avatar; pub mod avatar;
pub mod button; pub mod button;

View File

@@ -1,40 +1,59 @@
use std::collections::HashMap;
use std::ops::Range; use std::ops::Range;
use std::sync::Arc; use std::sync::Arc;
use common::display::DisplayProfile; use common::display::DisplayProfile;
use gpui::{ use gpui::{
AnyElement, AnyView, App, ElementId, FontWeight, HighlightStyle, InteractiveText, IntoElement, AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement,
SharedString, StyledText, UnderlineStyle, Window, SharedString, StyledText, UnderlineStyle, Window,
}; };
use linkify::{LinkFinder, LinkKind}; use linkify::{LinkFinder, LinkKind};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use registry::Registry;
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::actions::OpenProfile;
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(?:[a-zA-Z]+://)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(/.*)?$").unwrap()
});
static NOSTR_URI_REGEX: Lazy<Regex> = static NOSTR_URI_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap()); Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
static BECH32_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\b(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+\b").unwrap());
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight { pub enum Highlight {
Highlight(HighlightStyle), Link(HighlightStyle),
Mention, Nostr,
}
impl Highlight {
fn link() -> Self {
Self::Link(HighlightStyle {
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
})
}
fn nostr() -> Self {
Self::Nostr
}
} }
impl From<HighlightStyle> for Highlight { impl From<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self { fn from(style: HighlightStyle) -> Self {
Self::Highlight(style) Self::Link(style)
} }
} }
type CustomRangeTooltipFn = type CustomRangeTooltipFn =
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>; Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
#[derive(Clone, Default)] #[derive(Default)]
pub struct RichText { pub struct RichText {
pub text: SharedString, pub text: SharedString,
pub highlights: Vec<(Range<usize>, Highlight)>, pub highlights: Vec<(Range<usize>, Highlight)>,
@@ -45,19 +64,19 @@ pub struct RichText {
} }
impl RichText { impl RichText {
pub fn new(content: String, profiles: &[Option<Profile>]) -> Self { pub fn new(content: &str, cx: &App) -> Self {
let mut text = String::new(); let mut text = String::new();
let mut highlights = Vec::new(); let mut highlights = Vec::new();
let mut link_ranges = Vec::new(); let mut link_ranges = Vec::new();
let mut link_urls = Vec::new(); let mut link_urls = Vec::new();
render_plain_text_mut( render_plain_text_mut(
&content, content,
profiles,
&mut text, &mut text,
&mut highlights, &mut highlights,
&mut link_ranges, &mut link_ranges,
&mut link_urls, &mut link_urls,
cx,
); );
text.truncate(text.trim_end().len()); text.truncate(text.trim_end().len());
@@ -72,10 +91,10 @@ impl RichText {
} }
} }
pub fn set_tooltip_builder_for_custom_ranges( pub fn set_tooltip_builder_for_custom_ranges<F>(&mut self, f: F)
&mut self, where
f: impl Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static, F: Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
) { {
self.custom_ranges_tooltip_fn = Some(Arc::new(f)); self.custom_ranges_tooltip_fn = Some(Arc::new(f));
} }
@@ -90,7 +109,7 @@ impl RichText {
( (
range.clone(), range.clone(),
match highlight { match highlight {
Highlight::Highlight(highlight) => { Highlight::Link(highlight) => {
// Check if this is a link highlight by seeing if it has an underline // Check if this is a link highlight by seeing if it has an underline
if highlight.underline.is_some() { if highlight.underline.is_some() {
// It's a link, so apply the link color // It's a link, so apply the link color
@@ -101,9 +120,8 @@ impl RichText {
*highlight *highlight
} }
} }
Highlight::Mention => HighlightStyle { Highlight::Nostr => HighlightStyle {
color: Some(link_color), color: Some(link_color),
font_weight: Some(FontWeight::MEDIUM),
..Default::default() ..Default::default()
}, },
}, },
@@ -113,15 +131,24 @@ impl RichText {
) )
.on_click(self.link_ranges.clone(), { .on_click(self.link_ranges.clone(), {
let link_urls = self.link_urls.clone(); let link_urls = self.link_urls.clone();
move |ix, _, cx| { move |ix, window, cx| {
let url = &link_urls[ix]; let token = link_urls[ix].as_str();
if url.starts_with("http") {
cx.open_url(url); if token.starts_with("nostr:") {
let clean_url = token.replace("nostr:", "");
let Ok(public_key) = PublicKey::parse(&clean_url) else {
log::error!("Failed to parse public key from: {clean_url}");
return;
};
window.dispatch_action(Box::new(OpenProfile(public_key)), cx);
} else if is_url(token) {
if !token.starts_with("http") {
cx.open_url(&format!("https://{token}"));
} else {
cx.open_url(token);
} }
// Handle mention URLs } else {
else if url.starts_with("mention:") { log::warn!("Unrecognized token {token}")
// Handle mention clicks
// For example: cx.emit_custom_event(MentionClicked(url.strip_prefix("mention:").unwrap().to_string()));
} }
} }
}) })
@@ -154,29 +181,20 @@ impl RichText {
} }
} }
pub fn render_plain_text_mut( fn render_plain_text_mut(
content: &str, content: &str,
profiles: &[Option<Profile>],
text: &mut String, text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>, highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>, link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>, link_urls: &mut Vec<String>,
cx: &App,
) { ) {
// Copy the content directly // Copy the content directly
text.push_str(content); text.push_str(content);
// Create a profile lookup using PublicKey directly // Initialize the link finder
let profile_lookup: HashMap<PublicKey, Profile> = profiles
.iter()
.filter_map(|profile| {
profile
.as_ref()
.map(|profile| (profile.public_key(), profile.clone()))
})
.collect();
// Process regular URLs using linkify
let mut finder = LinkFinder::new(); let mut finder = LinkFinder::new();
finder.url_must_have_scheme(false);
finder.kinds(&[LinkKind::Url]); finder.kinds(&[LinkKind::Url]);
// Collect all URLs // Collect all URLs
@@ -191,7 +209,7 @@ pub fn render_plain_text_mut(
url_matches.push((range, url)); url_matches.push((range, url));
} }
// Process nostr entities with nostr: prefix // Collect all nostr entities with nostr: prefix
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new(); let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
for nostr_match in NOSTR_URI_REGEX.find_iter(content) { for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
@@ -209,106 +227,131 @@ pub fn render_plain_text_mut(
} }
} }
// Process raw bech32 entities (without nostr: prefix)
let mut bech32_matches: Vec<(Range<usize>, String)> = Vec::new();
for bech32_match in BECH32_REGEX.find_iter(content) {
let start = bech32_match.start();
let end = bech32_match.end();
let range = start..end;
let bech32_entity = bech32_match.as_str().to_string();
// Check if this entity overlaps with any already processed matches
let overlaps_with_url = url_matches
.iter()
.any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end);
let overlaps_with_nostr = nostr_matches
.iter()
.any(|(nostr_range, _)| nostr_range.start < range.end && range.start < nostr_range.end);
if !overlaps_with_url && !overlaps_with_nostr {
bech32_matches.push((range, bech32_entity));
}
}
// Combine all matches for processing from end to start // Combine all matches for processing from end to start
let mut all_matches = Vec::new(); let mut all_matches = Vec::new();
all_matches.extend(url_matches); all_matches.extend(url_matches);
all_matches.extend(nostr_matches); all_matches.extend(nostr_matches);
all_matches.extend(bech32_matches);
// Sort by position (end to start) to avoid changing positions when replacing text // Sort by position (end to start) to avoid changing positions when replacing text
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start)); all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
// Process all matches // Process all matches
for (range, entity) in all_matches { for (range, entity) in all_matches {
if entity.starts_with("http") { // Handle URL token
// Regular URL if is_url(&entity) {
highlights.push(( // Add underline highlight
range.clone(), highlights.push((range.clone(), Highlight::link()));
Highlight::Highlight(HighlightStyle { // Make it clickable
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}),
));
link_ranges.push(range); link_ranges.push(range);
link_urls.push(entity); link_urls.push(entity);
} else {
let entity_without_prefix = if entity.starts_with("nostr:") { continue;
entity.strip_prefix("nostr:").unwrap_or(&entity)
} else {
&entity
}; };
// Try to find a matching profile if this is npub or nprofile if let Ok(nip21) = Nip21::parse(&entity) {
let profile_match = if entity_without_prefix.starts_with("npub") { match nip21 {
PublicKey::from_bech32(entity_without_prefix) Nip21::Pubkey(public_key) => {
.ok() render_pubkey(
.and_then(|pubkey| profile_lookup.get(&pubkey).cloned()) public_key,
} else if entity_without_prefix.starts_with("nprofile") { text,
Nip19Profile::from_bech32(entity_without_prefix) &range,
.ok() highlights,
.and_then(|profile| profile_lookup.get(&profile.public_key).cloned()) link_ranges,
} else { link_urls,
None cx,
}; );
}
Nip21::Profile(nip19_profile) => {
render_pubkey(
nip19_profile.public_key,
text,
&range,
highlights,
link_ranges,
link_urls,
cx,
);
}
Nip21::EventId(event_id) => {
render_bech32(
event_id.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Event(nip19_event) => {
render_bech32(
nip19_event.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
Nip21::Coordinate(nip19_coordinate) => {
render_bech32(
nip19_coordinate.to_bech32().unwrap(),
text,
&range,
highlights,
link_ranges,
link_urls,
);
}
}
}
}
if let Some(profile) = profile_match { fn render_pubkey(
// Profile found - create a mention public_key: PublicKey,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
let registry = Registry::read_global(cx);
let profile = registry.get_person(&public_key, cx);
let display_name = format!("@{}", profile.display_name()); let display_name = format!("@{}", profile.display_name());
// Replace mention with profile name // Replace token with display name
text.replace_range(range.clone(), &display_name); text.replace_range(range.clone(), &display_name);
// Adjust ranges // Adjust ranges
let new_length = display_name.len(); let new_length = display_name.len();
let length_diff = new_length as isize - (range.end - range.start) as isize; let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement // New range for the replacement
let new_range = range.start..(range.start + new_length); let new_range = range.start..(range.start + new_length);
// Add highlight for the profile name // Add highlight for the profile name
highlights.push((new_range.clone(), Highlight::Mention)); highlights.push((new_range.clone(), Highlight::nostr()));
// Make it clickable // Make it clickable
link_ranges.push(new_range); link_ranges.push(new_range);
link_urls.push(format!("mention:{entity_without_prefix}")); link_urls.push(format!("nostr:{}", profile.public_key().to_hex()));
// Adjust subsequent ranges if needed // Adjust subsequent ranges if needed
if length_diff != 0 { if length_diff != 0 {
adjust_ranges(highlights, link_ranges, range.end, length_diff); adjust_ranges(highlights, link_ranges, range.end, length_diff);
} }
} else { }
// No profile match or not a profile entity - create njump.me link
let njump_url = format!("https://njump.me/{entity_without_prefix}"); fn render_bech32(
bech32: String,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
let njump_url = format!("https://njump.me/{bech32}");
// Create a shortened display format for the URL // Create a shortened display format for the URL
let shortened_entity = format_shortened_entity(entity_without_prefix); let shortened_entity = format_shortened_entity(&bech32);
let display_text = format!("https://njump.me/{shortened_entity}"); let display_text = format!("https://njump.me/{shortened_entity}");
// Replace the original entity with the shortened display version // Replace the original entity with the shortened display version
@@ -317,22 +360,11 @@ pub fn render_plain_text_mut(
// Adjust the ranges // Adjust the ranges
let new_length = display_text.len(); let new_length = display_text.len();
let length_diff = new_length as isize - (range.end - range.start) as isize; let length_diff = new_length as isize - (range.end - range.start) as isize;
// New range for the replacement // New range for the replacement
let new_range = range.start..(range.start + new_length); let new_range = range.start..(range.start + new_length);
// Add underline highlight // Add underline highlight
highlights.push(( highlights.push((new_range.clone(), Highlight::link()));
new_range.clone(),
Highlight::Highlight(HighlightStyle {
underline: Some(UnderlineStyle {
thickness: 1.0.into(),
..Default::default()
}),
..Default::default()
}),
));
// Make it clickable // Make it clickable
link_ranges.push(new_range); link_ranges.push(new_range);
link_urls.push(njump_url); link_urls.push(njump_url);
@@ -342,8 +374,11 @@ pub fn render_plain_text_mut(
adjust_ranges(highlights, link_ranges, range.end, length_diff); adjust_ranges(highlights, link_ranges, range.end, length_diff);
} }
} }
} }
}
/// Check if a string is a URL
fn is_url(s: &str) -> bool {
URL_REGEX.is_match(s)
} }
/// Format a bech32 entity with ellipsis and last 4 characters /// Format a bech32 entity with ellipsis and last 4 characters

View File

@@ -128,7 +128,7 @@ welcome:
zh-CN: "安全的 Nostr 通信" zh-CN: "安全的 Nostr 通信"
zh-TW: "安全的 Nostr 通信" zh-TW: "安全的 Nostr 通信"
ru: "Безопасная коммуникация на Nostr" ru: "Безопасная коммуникация на Nostr"
vi: "Trò chuyện toàn trên Nostr" vi: "Trò chuyện an toàn trên Nostr"
ja: "Nostr 上のセキュアなコミュニケーション" ja: "Nostr 上のセキュアなコミュニケーション"
es: "Comunicación segura en Nostr" es: "Comunicación segura en Nostr"
pt: "Comunicação segura no Nostr" pt: "Comunicação segura no Nostr"
@@ -453,6 +453,16 @@ chatspace:
es: "Cambiar el idioma de la aplicación" es: "Cambiar el idioma de la aplicación"
pt: "Alterar o idioma do aplicativo" pt: "Alterar o idioma do aplicativo"
ko: "앱 언어 변경" ko: "앱 언어 변경"
share_profile:
en: "Share Profile"
zh-CN: "分享个人资料"
zh-TW: "分享個人資料"
ru: "Поделиться профилем"
vi: "Chia sẻ hồ sơ"
ja: "プロフィールを共有する"
es: "Compartir perfil"
pt: "Compartilhar perfil"
ko: "프로필 공유"
relays: relays:
description: description:
@@ -599,6 +609,26 @@ profile:
es: "Biografía:" es: "Biografía:"
pt: "Bio:" pt: "Bio:"
ko: "소개:" ko: "소개:"
unknown:
en: "Unknown contact"
zh-CN: "未知联系人"
zh-TW: "未知聯絡人"
ru: "Неизвестный контакт"
vi: "Liên hệ không xác định"
ja: "不明な連絡先"
es: "Contacto desconocido"
pt: "Contato desconhecido"
ko: "알 수 없는 연락처"
njump:
en: "Open in njump.me"
zh-CN: "在njump.me中打开"
zh-TW: "在njump.me中打開"
ru: "Открыть в njump.me"
vi: "Mở trong njump.me"
ja: "njump.meで開く"
es: "Abrir en njump.me"
pt: "Abrir no njump.me"
ko: "njump.me에서 열기"
preferences: preferences:
modal_profile_title: modal_profile_title:
@@ -945,6 +975,16 @@ chat:
es: "Cambiar el asunto de la conversación" es: "Cambiar el asunto de la conversación"
pt: "Alterar o assunto da conversa" pt: "Alterar o assunto da conversa"
ko: "대화 제목 변경" ko: "대화 제목 변경"
replying_to_label:
en: "Replying to:"
zh-CN: "回复:"
zh-TW: "回覆:"
ru: "Ответ на:"
vi: "Trả lời:"
ja: "返信先:"
es: "Respondiendo a:"
pt: "Respondendo a:"
ko: "답장 대상:"
sidebar: sidebar:
find_or_start_conversation: find_or_start_conversation:
@@ -1144,16 +1184,6 @@ sidebar:
es: "Enviar a:" es: "Enviar a:"
pt: "Enviar para:" pt: "Enviar para:"
ko: "보내기:" ko: "보내기:"
replying_to_label:
en: "Replying to:"
zh-CN: "回复:"
zh-TW: "回覆:"
ru: "Ответ на:"
vi: "Trả lời:"
ja: "返信先:"
es: "Respondiendo a:"
pt: "Respondendo a:"
ko: "답장 대상:"
send_fail: send_fail:
en: "Failed to send message. Click to see details." en: "Failed to send message. Click to see details."
zh-CN: "发送消息失败。点击查看详情。" zh-CN: "发送消息失败。点击查看详情。"