2133 lines
74 KiB
Rust
2133 lines
74 KiB
Rust
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, LazyLock, RwLock};
|
|
|
|
pub use actions::*;
|
|
use anyhow::Error;
|
|
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
|
use common::TimestampExt;
|
|
use futures::lock::Mutex;
|
|
use gpui::prelude::FluentBuilder;
|
|
use gpui::{
|
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
|
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
|
|
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
|
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
|
|
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
|
|
retain_all, svg, white,
|
|
};
|
|
use itertools::Itertools;
|
|
use nostr_sdk::prelude::*;
|
|
use person::{Person, PersonRegistry};
|
|
use regex::Regex;
|
|
use settings::{AppSettings, SignerKind};
|
|
use smallvec::{SmallVec, smallvec};
|
|
use state::{
|
|
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
|
|
};
|
|
use theme::ActiveTheme;
|
|
use ui::avatar::Avatar;
|
|
use ui::button::{Button, ButtonVariants};
|
|
use ui::dock::{Panel, PanelEvent};
|
|
use ui::input::{Input, InputEvent, InputState};
|
|
use ui::menu::DropdownMenu;
|
|
use ui::notification::Notification;
|
|
use ui::scroll::Scrollbar;
|
|
use ui::tooltip::Tooltip;
|
|
use ui::{
|
|
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
|
h_flex, v_flex,
|
|
};
|
|
|
|
use crate::file::*;
|
|
use crate::text::RenderedText;
|
|
|
|
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
|
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
|
|
|
/// Regex matching strings that consist entirely of emoji characters,
|
|
/// zero-width joiners, variation selectors, and keycap combiners.
|
|
static EMOJI_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
|
|
|
mod actions;
|
|
mod file;
|
|
mod text;
|
|
|
|
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
|
cx.new(|cx| ChatPanel::new(room, window, cx))
|
|
}
|
|
|
|
/// Chat Panel
|
|
pub struct ChatPanel {
|
|
id: SharedString,
|
|
focus_handle: FocusHandle,
|
|
|
|
/// Chat Room
|
|
room: WeakEntity<Room>,
|
|
|
|
/// Message list state
|
|
list_state: ListState,
|
|
|
|
/// All messages (sorted by created_at)
|
|
messages: Vec<Message>,
|
|
|
|
/// O(1) message lookup by EventId
|
|
message_index: HashMap<EventId, usize>,
|
|
|
|
/// All reactions
|
|
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
|
|
|
|
/// Mapping message ids to their rendered texts
|
|
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
|
|
|
/// Mapping message (rumor event) ids to their reports
|
|
reports_by_id: Arc<RwLock<BTreeMap<EventId, Vec<SendReport>>>>,
|
|
|
|
/// Chat input state
|
|
input: Entity<InputState>,
|
|
|
|
/// Subject input state
|
|
subject_input: Entity<InputState>,
|
|
|
|
/// Subject bar visibility
|
|
subject_bar: Entity<bool>,
|
|
|
|
/// Sent message ids
|
|
sent_ids: Arc<Mutex<Vec<EventId>>>,
|
|
|
|
/// Replies to
|
|
replies_to: Entity<HashSet<EventId>>,
|
|
|
|
/// Media Attachment
|
|
attachments: Entity<Vec<Url>>,
|
|
|
|
/// Uploaded, encrypted file attachments which are not sent yet
|
|
encrypted_attachments: Entity<Vec<PendingFile>>,
|
|
|
|
/// Decrypted attachments of file messages, by message id
|
|
decrypted_files: HashMap<EventId, DecryptedFile>,
|
|
|
|
/// Upload state
|
|
uploading: bool,
|
|
|
|
/// Async operations
|
|
tasks: Vec<Task<Result<(), Error>>>,
|
|
|
|
/// Event subscriptions
|
|
subscriptions: SmallVec<[Subscription; 3]>,
|
|
}
|
|
|
|
impl ChatPanel {
|
|
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
// Define attachments and replies_to entities
|
|
let attachments = cx.new(|_| vec![]);
|
|
let encrypted_attachments = cx.new(|_| vec![]);
|
|
let replies_to = cx.new(|_| HashSet::new());
|
|
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
|
|
|
// Define list of messages
|
|
let messages = Vec::new();
|
|
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
|
|
|
// Get room id and name
|
|
let (id, name) = room
|
|
.read_with(cx, |this, _cx| {
|
|
let id = this.id.to_string().into();
|
|
let name = this.display_name(cx);
|
|
|
|
(id, name)
|
|
})
|
|
.unwrap_or(("Unknown".into(), "Message...".into()));
|
|
|
|
// Define input state
|
|
let input = cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.placeholder(format!("Message {}", name))
|
|
.auto_grow(1, 20)
|
|
.clean_on_escape()
|
|
});
|
|
|
|
// Define subject input state
|
|
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
|
|
let subject_bar = cx.new(|_cx| false);
|
|
|
|
// Define subscriptions
|
|
let mut subscriptions = smallvec![];
|
|
|
|
subscriptions.push(
|
|
// Subscribe the chat input event
|
|
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
|
|
if let InputEvent::PressEnter { .. } = event {
|
|
this.send_text_message(window, cx);
|
|
};
|
|
}),
|
|
);
|
|
|
|
subscriptions.push(
|
|
// Subscribe the subject input event
|
|
cx.subscribe_in(
|
|
&subject_input,
|
|
window,
|
|
move |this, _input, event, window, cx| {
|
|
if let InputEvent::PressEnter { .. } = event {
|
|
this.change_subject(window, cx);
|
|
};
|
|
},
|
|
),
|
|
);
|
|
|
|
// Define all functions that will run after the current cycle
|
|
cx.defer_in(window, |this, window, cx| {
|
|
this.connect(cx);
|
|
this.handle_notifications(cx);
|
|
this.subscribe_room_events(window, cx);
|
|
this.get_messages(window, cx);
|
|
});
|
|
|
|
Self {
|
|
focus_handle: cx.focus_handle(),
|
|
id,
|
|
messages,
|
|
message_index: HashMap::new(),
|
|
reactions: BTreeMap::new(),
|
|
room,
|
|
list_state,
|
|
input,
|
|
subject_input,
|
|
subject_bar,
|
|
replies_to,
|
|
attachments,
|
|
encrypted_attachments,
|
|
decrypted_files: HashMap::new(),
|
|
rendered_texts_by_id: BTreeMap::new(),
|
|
reports_by_id,
|
|
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
|
uploading: false,
|
|
subscriptions,
|
|
tasks: vec![],
|
|
}
|
|
}
|
|
|
|
/// Get messaging relays and announcement for each member
|
|
fn connect(&mut self, cx: &mut Context<Self>) {
|
|
if let Some(room) = self.room.upgrade() {
|
|
let task = room.read(cx).connect(cx);
|
|
self.tasks.push(task);
|
|
}
|
|
}
|
|
|
|
/// Handle nostr notifications
|
|
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
|
let nostr = NostrRegistry::global(cx);
|
|
let client = nostr.read(cx).client();
|
|
let sent_ids = self.sent_ids.clone();
|
|
let reports = self.reports_by_id.clone();
|
|
|
|
let (tx, rx) = flume::bounded::<Arc<SendStatus>>(256);
|
|
|
|
self.tasks.push(cx.background_spawn(async move {
|
|
let mut notifications = client.notifications();
|
|
|
|
while let Some(notification) = notifications.next().await {
|
|
if let ClientNotification::Message { message, relay_url } = notification
|
|
&& let RelayMessage::Ok {
|
|
event_id,
|
|
status,
|
|
message,
|
|
} = *message
|
|
{
|
|
let sent_ids = sent_ids.lock().await;
|
|
|
|
if sent_ids.contains(&event_id) {
|
|
let status = if status {
|
|
SendStatus::ok(event_id, relay_url)
|
|
} else {
|
|
SendStatus::failed(event_id, relay_url, message.into())
|
|
};
|
|
tx.send_async(Arc::new(status)).await.ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}));
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
while let Ok(status) = rx.recv_async().await {
|
|
{
|
|
let mut map = reports.write().unwrap();
|
|
let status_id = match &*status {
|
|
SendStatus::Ok { id, .. } => *id,
|
|
SendStatus::Failed { id, .. } => *id,
|
|
};
|
|
|
|
// Find the matching report and update it (exit early on first match)
|
|
'outer: for reports_list in map.values_mut() {
|
|
for report in reports_list.iter_mut() {
|
|
let Some(output) = report.output.as_mut() else {
|
|
continue;
|
|
};
|
|
if *output.id() != status_id {
|
|
continue;
|
|
}
|
|
match &*status {
|
|
SendStatus::Ok { relay, .. } => {
|
|
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
|
}
|
|
SendStatus::Failed { relay, message, .. } => {
|
|
output.failed.insert(relay.clone(), message.clone());
|
|
}
|
|
}
|
|
break 'outer;
|
|
}
|
|
}
|
|
}
|
|
this.update(cx, |_, cx| cx.notify()).ok();
|
|
}
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Subscribe to room events
|
|
fn subscribe_room_events(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(room) = self.room.upgrade() else {
|
|
return;
|
|
};
|
|
|
|
self.subscriptions.push(cx.subscribe_in(
|
|
&room,
|
|
window,
|
|
move |this, _room, event, window, cx| {
|
|
match event {
|
|
RoomEvent::Incoming(message) => {
|
|
if message.rumor.kind == Kind::Reaction {
|
|
this.insert_reaction(&message.rumor, cx);
|
|
} else {
|
|
this.insert_message(message, false, cx);
|
|
|
|
if !window.is_window_active() {
|
|
cx.show_system_notification(SystemNotification {
|
|
tag: "message".into(),
|
|
title: "New Message".into(),
|
|
body: "You have a new message.".into(),
|
|
actions: vec![SystemNotificationAction {
|
|
id: "open".into(),
|
|
label: "Open".into(),
|
|
}],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
RoomEvent::Reload => {
|
|
// Defer to avoid re-entrant read on Room while
|
|
// emit_refresh holds a write lock (via refresh_rooms).
|
|
cx.defer_in(window, |this, window, cx| {
|
|
this.get_messages(window, cx);
|
|
});
|
|
}
|
|
};
|
|
},
|
|
));
|
|
}
|
|
|
|
/// Load all messages belonging to this room
|
|
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
|
let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else {
|
|
return;
|
|
};
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
let events = get_messages.await?;
|
|
|
|
// Update message list
|
|
this.update(cx, |this, cx| {
|
|
this.insert_messages(&events, cx);
|
|
})?;
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Get user input content and merged all attachments if available
|
|
fn get_input_value(&self, cx: &Context<Self>) -> String {
|
|
// Get input's value
|
|
let mut content = self.input.read(cx).value().trim().to_string();
|
|
|
|
// Get all attaches and merge its with message
|
|
let attachments = self.attachments.read(cx);
|
|
|
|
if !attachments.is_empty() {
|
|
let urls = attachments
|
|
.iter()
|
|
.map(|url| url.to_string())
|
|
.collect_vec()
|
|
.join("\n");
|
|
|
|
if content.is_empty() {
|
|
content = urls;
|
|
} else {
|
|
content = format!("{content}\n{urls}");
|
|
}
|
|
}
|
|
|
|
content
|
|
}
|
|
|
|
fn change_subject(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
|
let subject = self.subject_input.read(cx).value();
|
|
|
|
self.room
|
|
.update(cx, |this, cx| {
|
|
this.set_subject(subject, cx);
|
|
})
|
|
.ok();
|
|
}
|
|
|
|
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
// Get the message which includes all plain attachments
|
|
let content = self.get_input_value(cx);
|
|
|
|
// Get the replies to this message
|
|
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
|
|
|
// Uploaded files are sent as encrypted file messages
|
|
let files: Vec<FileAttachment> = self
|
|
.encrypted_attachments
|
|
.read(cx)
|
|
.iter()
|
|
.map(|pending| pending.file.clone())
|
|
.collect();
|
|
|
|
// Return if there is nothing to send
|
|
if content.trim().is_empty() && files.is_empty() {
|
|
window.push_notification("Cannot send an empty message", cx);
|
|
return;
|
|
}
|
|
|
|
// If replying to exactly one message with only a valid emoji,
|
|
// send as a reaction instead of a text message
|
|
if replies.len() == 1
|
|
&& EMOJI_RE.is_match(&content)
|
|
&& self.attachments.read(cx).is_empty()
|
|
&& files.is_empty()
|
|
{
|
|
for reply in &replies {
|
|
self.send_reaction(&content, reply, window, cx);
|
|
}
|
|
self.clear(window, cx);
|
|
return;
|
|
}
|
|
|
|
// Send the text part, including the plain attachment urls
|
|
if !content.trim().is_empty() {
|
|
self.send_message(&content, replies.clone(), false, window, cx);
|
|
}
|
|
|
|
// Send every file as its own encrypted file message
|
|
for file in files {
|
|
self.send_file(file, replies.clone(), window, cx);
|
|
}
|
|
}
|
|
|
|
fn send_reaction(
|
|
&mut self,
|
|
emoji: &str,
|
|
target: &EventId,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
// Return if emoji is empty
|
|
if emoji.trim().is_empty() {
|
|
window.push_notification("Cannot send an empty reaction", cx);
|
|
return;
|
|
}
|
|
|
|
self.send_message(emoji, vec![*target], true, window, cx);
|
|
}
|
|
|
|
/// Send a message to all members of the chat
|
|
fn send_message(
|
|
&mut self,
|
|
value: &str,
|
|
replies: Vec<EventId>,
|
|
reaction: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if value.trim().is_empty() {
|
|
window.push_notification("Cannot send an empty message", cx);
|
|
return;
|
|
}
|
|
|
|
// Upgrade room and create rumor + send task in a single read lock
|
|
let Some(room) = self.room.upgrade() else {
|
|
return;
|
|
};
|
|
|
|
let outcome = room.read_with(cx, |room, cx| {
|
|
let rumor = room.rumor(value, replies, reaction, cx)?;
|
|
let send_task = room.send(rumor.clone(), cx)?;
|
|
|
|
Some((rumor, send_task))
|
|
});
|
|
|
|
match outcome {
|
|
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
|
|
None => window.push_notification("Failed to create message", cx),
|
|
}
|
|
}
|
|
|
|
/// Send an encrypted file message (NIP-17 kind 15) to all members of the chat
|
|
fn send_file(
|
|
&mut self,
|
|
file: FileAttachment,
|
|
replies: Vec<EventId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(room) = self.room.upgrade() else {
|
|
return;
|
|
};
|
|
|
|
let outcome = room.read_with(cx, |room, cx| {
|
|
let rumor = room.file_rumor(file, replies, cx)?;
|
|
let send_task = room.send(rumor.clone(), cx)?;
|
|
|
|
Some((rumor, send_task))
|
|
});
|
|
|
|
match outcome {
|
|
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
|
|
None => window.push_notification("Failed to create message", cx),
|
|
}
|
|
}
|
|
|
|
/// Insert a rumor optimistically and track the send reports of its gift wraps
|
|
fn dispatch(
|
|
&mut self,
|
|
rumor: UnsignedEvent,
|
|
send_task: Task<Vec<SendReport>>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let id = rumor.id.expect("rumor must have an id");
|
|
let sent_ids = self.sent_ids.clone();
|
|
|
|
// Insert optimistic message and clear input
|
|
if rumor.kind != Kind::Reaction {
|
|
self.insert_message(&rumor, true, cx);
|
|
self.clear(window, cx);
|
|
} else {
|
|
self.insert_reaction(&rumor, cx);
|
|
}
|
|
|
|
// Update reports
|
|
self.insert_reports(id, vec![], cx);
|
|
|
|
// Spawn a single task to await the send and update reports
|
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
let outputs = send_task.await;
|
|
|
|
let mut sent_ids = sent_ids.lock().await;
|
|
sent_ids.extend(outputs.iter().filter_map(|output| output.gift_wrap_id));
|
|
|
|
this.update(cx, |this, cx| {
|
|
this.insert_reports(id, outputs, cx);
|
|
})?;
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Clear the input field, attachments, and replies
|
|
///
|
|
/// Only run after sending a message
|
|
fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.input.update(cx, |this, cx| {
|
|
this.set_value("", window, cx);
|
|
});
|
|
self.attachments.update(cx, |this, cx| {
|
|
this.clear();
|
|
cx.notify();
|
|
});
|
|
self.encrypted_attachments.update(cx, |this, cx| {
|
|
this.clear();
|
|
cx.notify();
|
|
});
|
|
self.replies_to.update(cx, |this, cx| {
|
|
this.clear();
|
|
cx.notify();
|
|
})
|
|
}
|
|
|
|
/// Insert reports
|
|
fn insert_reports(&mut self, id: EventId, reports: Vec<SendReport>, cx: &mut Context<Self>) {
|
|
self.reports_by_id
|
|
.write()
|
|
.unwrap()
|
|
.entry(id)
|
|
.or_default()
|
|
.extend(reports);
|
|
cx.notify();
|
|
}
|
|
|
|
/// Insert a message into the chat panel
|
|
fn insert_message<E>(&mut self, m: E, scroll: bool, cx: &mut Context<Self>)
|
|
where
|
|
E: Into<Message>,
|
|
{
|
|
let old_len = self.messages.len();
|
|
let msg: Message = m.into();
|
|
|
|
if let Err(pos) = self.messages.binary_search(&msg) {
|
|
self.messages.insert(pos, msg);
|
|
// Rebuild message index after insertion (indices from pos to end shift)
|
|
for (i, message) in self.messages.iter().enumerate().skip(pos) {
|
|
self.message_index.insert(message.id, i);
|
|
}
|
|
self.list_state.splice(old_len..old_len, 1);
|
|
|
|
if scroll {
|
|
self.list_state.scroll_to(ListOffset {
|
|
item_ix: self.list_state.item_count(),
|
|
offset_in_item: px(0.0),
|
|
});
|
|
}
|
|
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
/// Convert and insert a vector of nostr events into the chat panel
|
|
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
|
|
for event in events.iter() {
|
|
if event.kind == Kind::Reaction {
|
|
self.insert_reaction(event, cx);
|
|
continue;
|
|
}
|
|
// Bulk inserting messages, so no need to scroll to the latest message
|
|
self.insert_message(event, false, cx);
|
|
}
|
|
}
|
|
|
|
/// Insert a reaction into the chat panel
|
|
fn insert_reaction(&mut self, event: &UnsignedEvent, cx: &mut Context<Self>) {
|
|
if event.kind != Kind::Reaction {
|
|
return;
|
|
}
|
|
|
|
for id in event.tags.event_ids() {
|
|
self.reactions
|
|
.entry(id)
|
|
.or_default()
|
|
.push((SharedString::from(&event.content), event.pubkey));
|
|
}
|
|
|
|
cx.notify();
|
|
}
|
|
|
|
/// Check if a message has any reports
|
|
fn has_reports(&self, id: &EventId) -> bool {
|
|
self.reports_by_id.read().unwrap().contains_key(id)
|
|
}
|
|
|
|
/// Clone reports for a message (used for modal display, not called during render)
|
|
fn sent_reports(&self, id: &EventId) -> Option<Vec<SendReport>> {
|
|
self.reports_by_id.read().unwrap().get(id).cloned()
|
|
}
|
|
|
|
/// Get a message by its ID (O(1) lookup)
|
|
fn message(&self, id: &EventId) -> Option<&Message> {
|
|
self.message_index
|
|
.get(id)
|
|
.and_then(|&ix| self.messages.get(ix))
|
|
}
|
|
|
|
/// Get a reaction by its target ID (returns reference, no allocation)
|
|
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
|
|
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
|
|
}
|
|
|
|
/// Check if a message has any reactions
|
|
fn has_reaction(&self, id: &EventId) -> bool {
|
|
self.reactions.contains_key(id)
|
|
}
|
|
|
|
/// Scroll to a message by its ID
|
|
fn scroll_to(&self, id: &EventId) {
|
|
if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) {
|
|
self.list_state.scroll_to_reveal_item(ix);
|
|
}
|
|
}
|
|
|
|
fn copy_author(&self, public_key: &PublicKey, cx: &App) {
|
|
let content = public_key.to_bech32().unwrap();
|
|
let item = ClipboardItem::new_string(content);
|
|
|
|
cx.write_to_clipboard(item);
|
|
}
|
|
|
|
fn copy_message(&self, id: &EventId, cx: &App) {
|
|
let Some(message) = self.message(id) else {
|
|
return;
|
|
};
|
|
let content = message.preview().to_string();
|
|
let item = ClipboardItem::new_string(content);
|
|
|
|
cx.write_to_clipboard(item);
|
|
}
|
|
|
|
fn reply_to(&mut self, id: &EventId, cx: &mut Context<Self>) {
|
|
if let Some(text) = self.message(id) {
|
|
self.replies_to.update(cx, |this, cx| {
|
|
this.insert(text.id);
|
|
cx.notify();
|
|
});
|
|
}
|
|
}
|
|
|
|
fn remove_reply(&mut self, id: &EventId, cx: &mut Context<Self>) {
|
|
self.replies_to.update(cx, |this, cx| {
|
|
this.remove(id);
|
|
cx.notify();
|
|
});
|
|
}
|
|
|
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
// Get the user's configured blossom server
|
|
let server = AppSettings::get_file_server(cx);
|
|
|
|
// Encrypt attachments which are not part of a message being written
|
|
let encrypted = self.input.read(cx).value().trim().is_empty();
|
|
|
|
// Ask user for file upload
|
|
let path = cx.prompt_for_paths(PathPromptOptions {
|
|
files: true,
|
|
directories: false,
|
|
multiple: false,
|
|
prompt: None,
|
|
});
|
|
|
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
// Selecting no file means the prompt was cancelled
|
|
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
|
return Ok(());
|
|
};
|
|
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.upload_file(server, path, encrypted, window, cx);
|
|
})?;
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Upload a file, encrypted when the attachment is the whole message
|
|
fn upload_file(
|
|
&mut self,
|
|
server: Url,
|
|
path: PathBuf,
|
|
encrypted: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.set_uploading(true, cx);
|
|
|
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
let result = if encrypted {
|
|
upload_encrypted(server.clone(), path.clone(), cx)
|
|
.await
|
|
.map(|file| Uploaded::File(file, path.clone()))
|
|
} else {
|
|
upload(server.clone(), path.clone(), cx)
|
|
.await
|
|
.map(Uploaded::Url)
|
|
};
|
|
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.set_uploading(false, cx);
|
|
|
|
match result {
|
|
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
|
|
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
|
|
Err(e) if encrypted => {
|
|
this.report_encrypted_upload_error(server, path, e, window, cx)
|
|
}
|
|
Err(e) => {
|
|
window.push_notification(
|
|
Notification::error(e.to_string()).autohide(false),
|
|
cx,
|
|
);
|
|
}
|
|
}
|
|
})?;
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Report a failed encrypted upload, offering to retry it without encryption
|
|
fn report_encrypted_upload_error(
|
|
&mut self,
|
|
server: Url,
|
|
path: PathBuf,
|
|
error: Error,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let view = cx.entity().downgrade();
|
|
|
|
window.push_notification(
|
|
Notification::error(error.to_string())
|
|
.title("Encrypted upload failed")
|
|
.action(move |_this, _window, _cx| {
|
|
let view = view.clone();
|
|
let server = server.clone();
|
|
let path = path.clone();
|
|
|
|
Button::new("retry-without-encryption")
|
|
.label("Upload without encryption")
|
|
.on_click(move |_ev, window, cx| {
|
|
view.update(cx, |this, cx| {
|
|
this.upload_file(server.clone(), path.clone(), false, window, cx);
|
|
})
|
|
.ok();
|
|
})
|
|
}),
|
|
cx,
|
|
);
|
|
}
|
|
|
|
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
|
|
self.uploading = uploading;
|
|
cx.notify();
|
|
}
|
|
|
|
fn add_attachment(&mut self, url: Url, cx: &mut Context<Self>) {
|
|
self.attachments.update(cx, |this, cx| {
|
|
this.push(url);
|
|
cx.notify();
|
|
});
|
|
}
|
|
|
|
fn remove_attachment(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
|
|
self.attachments.update(cx, |this, cx| {
|
|
if let Some(ix) = this.iter().position(|this| this == url) {
|
|
this.remove(ix);
|
|
cx.notify();
|
|
}
|
|
});
|
|
}
|
|
|
|
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
|
|
self.encrypted_attachments.update(cx, |this, cx| {
|
|
this.push(PendingFile { file, path });
|
|
cx.notify();
|
|
});
|
|
}
|
|
|
|
fn remove_pending_file(&mut self, url: &Url, cx: &mut Context<Self>) {
|
|
self.encrypted_attachments.update(cx, |this, cx| {
|
|
if let Some(ix) = this.iter().position(|pending| &pending.file.url == url) {
|
|
this.remove(ix);
|
|
cx.notify();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Download and decrypt the attachment of a file message for preview
|
|
fn load_file(&mut self, id: EventId, file: FileAttachment, cx: &mut Context<Self>) {
|
|
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
|
|
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
let result = download_and_decrypt_to_file(&file, cx).await;
|
|
|
|
this.update(cx, |this, cx| {
|
|
match result {
|
|
Ok(path) => {
|
|
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
|
}
|
|
Err(e) => {
|
|
this.decrypted_files
|
|
.insert(id, DecryptedFile::Failed(e.to_string().into()));
|
|
}
|
|
}
|
|
|
|
cx.notify();
|
|
})?;
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
/// Decrypt the attachment of a file message and open it with the OS
|
|
fn open_file(
|
|
&mut self,
|
|
id: EventId,
|
|
file: FileAttachment,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match self.decrypted_files.get(&id) {
|
|
Some(DecryptedFile::Ready(path)) => {
|
|
cx.open_url(&file_url(path));
|
|
return;
|
|
}
|
|
Some(DecryptedFile::Loading) => return,
|
|
_ => {}
|
|
};
|
|
|
|
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
|
|
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
|
let result = download_and_decrypt_to_file(&file, cx).await;
|
|
|
|
this.update_in(cx, |this, _window, cx| {
|
|
match result {
|
|
Ok(path) => {
|
|
cx.open_url(&file_url(&path));
|
|
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
|
}
|
|
Err(e) => {
|
|
this.decrypted_files
|
|
.insert(id, DecryptedFile::Failed(e.to_string().into()));
|
|
}
|
|
}
|
|
|
|
cx.notify();
|
|
})?;
|
|
|
|
Ok(())
|
|
}));
|
|
}
|
|
|
|
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
|
|
let persons = PersonRegistry::global(cx);
|
|
persons.read(cx).get(public_key, cx)
|
|
}
|
|
|
|
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
|
match command {
|
|
Command::Insert(content) => {
|
|
self.input.update(cx, |this, cx| {
|
|
let new_value = format!("{} {}", this.value(), content);
|
|
this.set_value(new_value, window, cx);
|
|
});
|
|
}
|
|
Command::ChangeSubject(subject) => {
|
|
if self
|
|
.room
|
|
.update(cx, |this, cx| {
|
|
this.set_subject(subject, cx);
|
|
})
|
|
.is_err()
|
|
{
|
|
window.push_notification(Notification::error("Failed to change subject"), cx);
|
|
}
|
|
}
|
|
Command::ChangeSigner(kind) => {
|
|
let settings = AppSettings::global(cx);
|
|
let is_nip4e_enabled = settings.read(cx).is_nip4e_enabled(cx);
|
|
let is_force_nip4e = *kind == SignerKind::Encryption || *kind == SignerKind::Auto;
|
|
|
|
if !is_nip4e_enabled && is_force_nip4e {
|
|
window.push_notification("Decoupling Encryption Key is not enabled", cx);
|
|
return;
|
|
}
|
|
|
|
if self
|
|
.room
|
|
.update(cx, |this, cx| {
|
|
this.set_signer_kind(kind, cx);
|
|
})
|
|
.is_err()
|
|
{
|
|
window.push_notification(Notification::error("Failed to change signer"), cx);
|
|
}
|
|
}
|
|
Command::ToggleBackup => {
|
|
if self
|
|
.room
|
|
.update(cx, |this, cx| {
|
|
this.set_backup(cx);
|
|
})
|
|
.is_err()
|
|
{
|
|
window.push_notification(Notification::error("Failed to toggle backup"), cx);
|
|
}
|
|
}
|
|
Command::Copy(public_key) => {
|
|
self.copy_author(public_key, cx);
|
|
}
|
|
Command::Relays(public_key) => {
|
|
self.open_relays(public_key, window, cx);
|
|
}
|
|
Command::Njump(public_key) => {
|
|
self.open_njump(public_key, cx);
|
|
}
|
|
Command::Trace(id) => {
|
|
self.open_trace(id, window, cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn open_trace(&mut self, id: &EventId, window: &mut Window, cx: &mut Context<Self>) {
|
|
let chat = ChatRegistry::global(cx);
|
|
let seen_on = chat.read(cx).rumor_seen_on(id);
|
|
|
|
window.open_modal(cx, move |this, _window, cx| {
|
|
this.title("Seen on").show_close(true).child(
|
|
v_flex()
|
|
.gap_1()
|
|
.when_none(&seen_on, |this| {
|
|
this.child(
|
|
h_flex()
|
|
.h_10()
|
|
.justify_center()
|
|
.text_sm()
|
|
.bg(cx.theme().elevated_surface_background)
|
|
.rounded(cx.theme().radius)
|
|
.child("Message isn't traced yet"),
|
|
)
|
|
})
|
|
.when_some(seen_on.as_ref(), |this, relays| {
|
|
this.children({
|
|
let mut items = vec![];
|
|
|
|
for url in relays.iter() {
|
|
items.push(
|
|
h_flex()
|
|
.h_7()
|
|
.px_2()
|
|
.gap_2()
|
|
.bg(cx.theme().elevated_surface_background)
|
|
.rounded(cx.theme().radius)
|
|
.text_sm()
|
|
.child(div().size_1p5().rounded_full().bg(gpui::green()))
|
|
.child(SharedString::from(url.to_string())),
|
|
);
|
|
}
|
|
|
|
items
|
|
})
|
|
}),
|
|
)
|
|
});
|
|
}
|
|
|
|
fn open_relays(&mut self, public_key: &PublicKey, window: &mut Window, cx: &mut Context<Self>) {
|
|
let profile = self.profile(public_key, cx);
|
|
|
|
window.open_modal(cx, move |this, _window, cx| {
|
|
let relays = profile.messaging_relays();
|
|
|
|
this.title("Messaging Relays")
|
|
.show_close(true)
|
|
.child(v_flex().gap_1().children({
|
|
let mut items = vec![];
|
|
|
|
for url in relays.iter() {
|
|
items.push(
|
|
h_flex()
|
|
.h_7()
|
|
.px_2()
|
|
.gap_2()
|
|
.bg(cx.theme().elevated_surface_background)
|
|
.rounded(cx.theme().radius)
|
|
.text_sm()
|
|
.child(div().size_1p5().rounded_full().bg(gpui::green()))
|
|
.child(SharedString::from(url.to_string())),
|
|
);
|
|
}
|
|
|
|
items
|
|
}))
|
|
});
|
|
}
|
|
|
|
fn open_njump(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
|
|
let content = format!("https://njump.me/{}", public_key.to_bech32().unwrap());
|
|
cx.open_url(&content);
|
|
}
|
|
|
|
fn render_announcement(&self, cx: &Context<Self>) -> AnyElement {
|
|
const MSG: &str =
|
|
"This conversation is private. Only members can see each other's messages.";
|
|
|
|
v_flex()
|
|
.h_40()
|
|
.w_full()
|
|
.gap_3()
|
|
.p_3()
|
|
.items_center()
|
|
.justify_center()
|
|
.text_center()
|
|
.text_xs()
|
|
.text_color(cx.theme().text_placeholder)
|
|
.line_height(relative(1.3))
|
|
.child(
|
|
svg()
|
|
.path("brand/coop.svg")
|
|
.size_12()
|
|
.text_color(cx.theme().ghost_element_active),
|
|
)
|
|
.child(MSG)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_warning(&self, ix: usize, content: SharedString, cx: &Context<Self>) -> AnyElement {
|
|
div()
|
|
.id(ix)
|
|
.w_full()
|
|
.py_2()
|
|
.px_3()
|
|
.child(
|
|
h_flex()
|
|
.w_full()
|
|
.gap_3()
|
|
.text_sm()
|
|
.child(
|
|
h_flex()
|
|
.flex_shrink_0()
|
|
.size_8()
|
|
.justify_center()
|
|
.rounded_full()
|
|
.bg(cx.theme().warning_background)
|
|
.text_color(cx.theme().warning_foreground)
|
|
.child(Icon::new(IconName::Warning).small()),
|
|
)
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.w_full()
|
|
.flex_initial()
|
|
.overflow_hidden()
|
|
.child(content),
|
|
),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn is_group_start(&self, ix: usize) -> bool {
|
|
// 5 minutes
|
|
const GROUP_WINDOW: u64 = 300;
|
|
|
|
if ix == 0 {
|
|
return true;
|
|
}
|
|
|
|
if let Some(previous) = self.messages.get(ix - 1)
|
|
&& let Some(current) = self.messages.get(ix)
|
|
{
|
|
if current.author != previous.author {
|
|
return true;
|
|
}
|
|
|
|
let gap = current
|
|
.created_at
|
|
.as_secs()
|
|
.saturating_sub(previous.created_at.as_secs());
|
|
|
|
return gap > GROUP_WINDOW;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn render_message(
|
|
&mut self,
|
|
ix: usize,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> AnyElement {
|
|
let file = self.messages.get(ix).and_then(|message| {
|
|
let file = message.file.clone()?;
|
|
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
|
|
.then_some((message.id, file))
|
|
});
|
|
|
|
if let Some((id, file)) = file {
|
|
self.load_file(id, file, cx);
|
|
}
|
|
|
|
if let Some(message) = self.messages.get(ix) {
|
|
let persons = PersonRegistry::global(cx);
|
|
let show_author = self.is_group_start(ix);
|
|
let text = self
|
|
.rendered_texts_by_id
|
|
.entry(message.id)
|
|
.or_insert_with(|| {
|
|
RenderedText::new(&message.content, &message.mentions, &persons, true, cx)
|
|
})
|
|
.element(ix.into(), window, cx);
|
|
|
|
self.render_text_message(ix, message, text, show_author, cx)
|
|
} else {
|
|
self.render_warning(ix, SharedString::from("Message not found"), cx)
|
|
}
|
|
}
|
|
|
|
fn render_text_message(
|
|
&self,
|
|
ix: usize,
|
|
message: &Message,
|
|
rendered_text: AnyElement,
|
|
show_author: bool,
|
|
cx: &Context<Self>,
|
|
) -> AnyElement {
|
|
let id = message.id;
|
|
let author = self.profile(&message.author, cx);
|
|
let pk = author.public_key();
|
|
|
|
let replies = message.replies_to.as_slice();
|
|
let has_replies = !replies.is_empty();
|
|
let has_reactions = self.has_reaction(&id);
|
|
let has_reports = self.has_reports(&id);
|
|
|
|
// Hide avatar setting
|
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
|
|
|
div()
|
|
.id(ix)
|
|
.group("")
|
|
.relative()
|
|
.w_full()
|
|
.py_1()
|
|
.px_3()
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.gap_3()
|
|
.when(!hide_avatar, |this| {
|
|
if show_author {
|
|
this.child(
|
|
Avatar::new(author.avatar())
|
|
.flex_shrink_0()
|
|
.relative()
|
|
.dropdown_menu(move |this, _window, _cx| {
|
|
this.menu("Public Key", Box::new(Command::Copy(pk)))
|
|
.menu("View Relays", Box::new(Command::Relays(pk)))
|
|
.separator()
|
|
.menu("View on njump.me", Box::new(Command::Njump(pk)))
|
|
}),
|
|
)
|
|
} else {
|
|
this.child(div().flex_shrink_0().w(px(32.)))
|
|
}
|
|
})
|
|
.child(
|
|
v_flex()
|
|
.flex_1()
|
|
.w_full()
|
|
.flex_initial()
|
|
.overflow_hidden()
|
|
.when(show_author, |this| {
|
|
this.child(
|
|
h_flex()
|
|
.gap_2()
|
|
.text_sm()
|
|
.text_color(cx.theme().text_placeholder)
|
|
.child(div().font_semibold().child(author.name()))
|
|
.child(message.created_at.to_human_time())
|
|
.when(has_reports, |this| {
|
|
this.child(self.render_sent_reports(&id, cx))
|
|
}),
|
|
)
|
|
})
|
|
.when(has_replies, |this| {
|
|
this.children(self.render_message_replies(replies, cx))
|
|
})
|
|
.when(message.file.is_none(), |this| this.child(rendered_text))
|
|
.child(self.render_media(&message.media, cx))
|
|
.when_some(message.file.as_ref(), |this, file| {
|
|
this.child(self.render_message_file(&id, file, cx))
|
|
})
|
|
.when(has_reactions, |this| {
|
|
this.child(self.render_reactions(&id, cx))
|
|
}),
|
|
),
|
|
)
|
|
.child(
|
|
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),
|
|
)
|
|
.child(self.render_actions(&id, &pk, cx))
|
|
.on_mouse_down(
|
|
MouseButton::Middle,
|
|
cx.listener(move |this, _, _window, cx| {
|
|
this.copy_message(&id, cx);
|
|
}),
|
|
)
|
|
.on_double_click(cx.listener(move |this, _, _window, cx| {
|
|
this.reply_to(&id, cx);
|
|
}))
|
|
.hover(|this| this.bg(cx.theme().surface_background))
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_media(&self, media: &[SharedUri], cx: &Context<Self>) -> impl IntoElement {
|
|
// No media: return empty div
|
|
if media.is_empty() {
|
|
return div();
|
|
};
|
|
|
|
// Single media item: render full-width image
|
|
if media.len() == 1 {
|
|
return div().child(
|
|
img(media[0].clone())
|
|
.border_1()
|
|
.border_color(cx.theme().border_variant)
|
|
.h(px(250.))
|
|
.object_fit(ObjectFit::Cover)
|
|
.rounded(cx.theme().radius),
|
|
);
|
|
}
|
|
|
|
// Multiple media items: render in a row
|
|
div()
|
|
.w_full()
|
|
.flex_1()
|
|
.flex()
|
|
.flex_row()
|
|
.flex_wrap()
|
|
.gap_2()
|
|
.children({
|
|
let mut items = vec![];
|
|
|
|
for (ix, item) in media.iter().enumerate() {
|
|
items.push(
|
|
div()
|
|
.id(format!("media-{ix}"))
|
|
.flex_grow_0()
|
|
.flex_shrink_0()
|
|
.child(
|
|
img(item.clone())
|
|
.h_32()
|
|
.border_1()
|
|
.border_color(cx.theme().border_variant)
|
|
.rounded(cx.theme().radius),
|
|
),
|
|
);
|
|
}
|
|
|
|
items
|
|
})
|
|
}
|
|
|
|
fn render_message_replies(
|
|
&self,
|
|
replies: &[EventId],
|
|
cx: &Context<Self>,
|
|
) -> impl IntoIterator<Item = impl IntoElement> {
|
|
let mut items = Vec::with_capacity(replies.len());
|
|
|
|
for (ix, id) in replies.iter().enumerate() {
|
|
let Some(message) = self.message(id) else {
|
|
continue;
|
|
};
|
|
let author = self.profile(&message.author, cx);
|
|
|
|
items.push(
|
|
div()
|
|
.id(ix)
|
|
.w_full()
|
|
.px_2()
|
|
.border_l_2()
|
|
.border_color(cx.theme().element_active)
|
|
.text_sm()
|
|
.child(div().font_semibold().child(author.name()))
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.text_ellipsis()
|
|
.line_clamp(1)
|
|
.child(message.preview()),
|
|
)
|
|
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
|
.on_click({
|
|
let id = *id;
|
|
cx.listener(move |this, _event, _window, _cx| {
|
|
this.scroll_to(&id);
|
|
})
|
|
}),
|
|
);
|
|
}
|
|
|
|
items
|
|
}
|
|
|
|
fn render_reactions(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
|
let current_user = NostrRegistry::global(cx).read(cx).current_user();
|
|
let reactions = self.reaction(id);
|
|
|
|
// Group reactions by emoji and collect authors for each
|
|
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
|
|
for (emoji, author) in reactions {
|
|
grouped.entry(emoji.clone()).or_default().push(*author);
|
|
}
|
|
|
|
h_flex()
|
|
.mt_2()
|
|
.gap_1()
|
|
.children(grouped.into_iter().map(|(emoji, authors)| {
|
|
let count = authors.len();
|
|
let has_reacted = current_user
|
|
.map(|pk| authors.contains(&pk))
|
|
.unwrap_or(false);
|
|
|
|
h_flex()
|
|
.gap_2()
|
|
.py_0p5()
|
|
.px_1()
|
|
.rounded(cx.theme().radius)
|
|
.text_xs()
|
|
.border_1()
|
|
.when(has_reacted, |this| {
|
|
this.text_color(cx.theme().secondary_foreground)
|
|
.bg(cx.theme().secondary_background)
|
|
.border_color(cx.theme().secondary_active)
|
|
})
|
|
.when(!has_reacted, |this| this.border_color(cx.theme().border))
|
|
.child(emoji)
|
|
.child(SharedString::from(count.to_string()))
|
|
}))
|
|
}
|
|
|
|
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
|
|
let reports = self.sent_reports(id);
|
|
|
|
let pending = reports
|
|
.as_ref()
|
|
.is_some_and(|reports| reports.is_empty() || reports.iter().any(|r| r.pending()));
|
|
|
|
let success = reports
|
|
.as_ref()
|
|
.is_some_and(|reports| !reports.is_empty() && reports.iter().any(|r| r.success()));
|
|
|
|
let failed = reports
|
|
.as_ref()
|
|
.is_some_and(|reports| !reports.is_empty() && reports.iter().all(|r| r.failed()));
|
|
|
|
let label = if success {
|
|
SharedString::from("• Sent")
|
|
} else if failed {
|
|
SharedString::from("• Error")
|
|
} else if pending {
|
|
SharedString::from("• Sending...")
|
|
} else {
|
|
SharedString::from("• Unknown")
|
|
};
|
|
|
|
div()
|
|
.id(SharedString::from(id.to_hex()))
|
|
.child(label)
|
|
.when(failed, |this| this.text_color(cx.theme().text_danger))
|
|
.when_some(reports, |this, reports| {
|
|
this.when(!pending, |this| {
|
|
this.on_click(move |_e, window, cx| {
|
|
let reports = reports.clone();
|
|
|
|
window.open_modal(cx, move |this, _window, cx| {
|
|
this.title(SharedString::from("Sent Reports"))
|
|
.show_close(true)
|
|
.child(v_flex().gap_4().children({
|
|
let mut items = Vec::with_capacity(reports.len());
|
|
|
|
for report in reports.iter() {
|
|
items.push(Self::render_report(report, cx))
|
|
}
|
|
|
|
items
|
|
}))
|
|
});
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
fn render_report(report: &SendReport, cx: &App) -> impl IntoElement {
|
|
let persons = PersonRegistry::global(cx);
|
|
let profile = persons.read(cx).get(&report.receiver, cx);
|
|
let name = profile.name();
|
|
let avatar = profile.avatar();
|
|
|
|
v_flex()
|
|
.gap_2()
|
|
.w_full()
|
|
.child(
|
|
h_flex()
|
|
.gap_2()
|
|
.text_sm()
|
|
.child(SharedString::from("Sent to:"))
|
|
.child(
|
|
h_flex()
|
|
.gap_1()
|
|
.font_semibold()
|
|
.child(Avatar::new(avatar).small())
|
|
.child(name.clone()),
|
|
),
|
|
)
|
|
.when_some(report.error.clone(), |this, error| {
|
|
this.child(
|
|
h_flex()
|
|
.flex_wrap()
|
|
.justify_center()
|
|
.p_1()
|
|
.h_16()
|
|
.w_full()
|
|
.text_sm()
|
|
.rounded(cx.theme().radius)
|
|
.bg(cx.theme().warning_background)
|
|
.text_color(cx.theme().warning_foreground)
|
|
.child(div().flex_1().w_full().text_center().child(error)),
|
|
)
|
|
})
|
|
.when_some(report.output.clone(), |this, output| {
|
|
this.child(
|
|
v_flex()
|
|
.gap_2()
|
|
.w_full()
|
|
.children({
|
|
let mut items = Vec::with_capacity(output.failed.len());
|
|
|
|
for (url, msg) in output.failed.into_iter() {
|
|
items.push(
|
|
v_flex()
|
|
.gap_0p5()
|
|
.p_1()
|
|
.w_full()
|
|
.rounded(cx.theme().radius)
|
|
.bg(cx.theme().danger_background)
|
|
.child(
|
|
div()
|
|
.text_xs()
|
|
.font_semibold()
|
|
.line_height(relative(1.25))
|
|
.child(SharedString::from(url.to_string())),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_xs()
|
|
.text_color(cx.theme().danger_foreground)
|
|
.line_height(relative(1.25))
|
|
.child(SharedString::from(msg.to_string())),
|
|
),
|
|
)
|
|
}
|
|
|
|
items
|
|
})
|
|
.children({
|
|
let mut items = Vec::with_capacity(output.success.len());
|
|
|
|
for url in output.success.into_iter() {
|
|
items.push(
|
|
v_flex()
|
|
.gap_0p5()
|
|
.p_1()
|
|
.w_full()
|
|
.rounded(cx.theme().radius)
|
|
.bg(cx.theme().elevated_surface_background)
|
|
.child(
|
|
div()
|
|
.text_xs()
|
|
.font_semibold()
|
|
.line_height(relative(1.25))
|
|
.child(SharedString::from(url.0.to_string())),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_xs()
|
|
.line_height(relative(1.25))
|
|
.child(SharedString::from("Successfully")),
|
|
),
|
|
)
|
|
}
|
|
|
|
items
|
|
}),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn render_actions(
|
|
&self,
|
|
id: &EventId,
|
|
public_key: &PublicKey,
|
|
cx: &Context<Self>,
|
|
) -> impl IntoElement {
|
|
h_flex()
|
|
.p_0p5()
|
|
.gap_1()
|
|
.invisible()
|
|
.absolute()
|
|
.right_4()
|
|
.top_neg_2()
|
|
.when(cx.theme().shadow, |this| this.shadow_sm())
|
|
.rounded(cx.theme().radius)
|
|
.border_1()
|
|
.border_color(cx.theme().border)
|
|
.bg(cx.theme().background)
|
|
.children({
|
|
let mut items = vec![];
|
|
|
|
for emoji in COMPACT_REACTION_EMOJIS {
|
|
items.push(
|
|
Button::new(*emoji)
|
|
.label(*emoji)
|
|
.tooltip(*emoji)
|
|
.small()
|
|
.ghost()
|
|
.on_click({
|
|
let emoji = *emoji;
|
|
let id = *id;
|
|
cx.listener(move |this, _event, window, cx| {
|
|
this.send_reaction(emoji, &id, window, cx);
|
|
})
|
|
}),
|
|
);
|
|
}
|
|
|
|
items
|
|
})
|
|
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
|
.child(
|
|
Button::new("reply")
|
|
.icon(IconName::Reply)
|
|
.tooltip("Reply")
|
|
.small()
|
|
.ghost()
|
|
.on_click({
|
|
let id = id.to_owned();
|
|
cx.listener(move |this, _event, _window, cx| {
|
|
this.reply_to(&id, cx);
|
|
})
|
|
}),
|
|
)
|
|
.child(
|
|
Button::new("copy")
|
|
.icon(IconName::Copy)
|
|
.tooltip("Copy")
|
|
.small()
|
|
.ghost()
|
|
.on_click({
|
|
let id = id.to_owned();
|
|
cx.listener(move |this, _event, _window, cx| {
|
|
this.copy_message(&id, cx);
|
|
})
|
|
}),
|
|
)
|
|
.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
|
|
.child(
|
|
Button::new("advance")
|
|
.icon(IconName::Ellipsis)
|
|
.small()
|
|
.ghost()
|
|
.dropdown_menu({
|
|
let public_key = *public_key;
|
|
let id = *id;
|
|
move |this, _window, _cx| {
|
|
this.menu("Copy author", Box::new(Command::Copy(public_key)))
|
|
.menu("Seen on", Box::new(Command::Trace(id)))
|
|
}
|
|
}),
|
|
)
|
|
.group_hover("", |this| this.visible())
|
|
}
|
|
|
|
fn render_attachment(&self, url: &Url, cx: &Context<Self>) -> impl IntoElement {
|
|
div()
|
|
.id(SharedString::from(url.to_string()))
|
|
.relative()
|
|
.w_16()
|
|
.child(
|
|
img(url.as_str())
|
|
.size_16()
|
|
.when(cx.theme().shadow, |this| this.shadow_lg())
|
|
.rounded(cx.theme().radius)
|
|
.object_fit(ObjectFit::Cover),
|
|
)
|
|
.child(
|
|
div()
|
|
.absolute()
|
|
.top_neg_2()
|
|
.right_neg_2()
|
|
.size_4()
|
|
.flex()
|
|
.items_center()
|
|
.justify_center()
|
|
.rounded_full()
|
|
.bg(red())
|
|
.child(Icon::new(IconName::Close).size_2().text_color(white())),
|
|
)
|
|
.on_click({
|
|
let url = url.clone();
|
|
cx.listener(move |this, _, window, cx| {
|
|
this.remove_attachment(&url, window, cx);
|
|
})
|
|
})
|
|
}
|
|
|
|
fn render_attachment_list(
|
|
&self,
|
|
_window: &Window,
|
|
cx: &Context<Self>,
|
|
) -> impl IntoIterator<Item = impl IntoElement> {
|
|
let mut items = vec![];
|
|
|
|
for url in self.attachments.read(cx).iter() {
|
|
items.push(self.render_attachment(url, cx));
|
|
}
|
|
|
|
items
|
|
}
|
|
|
|
/// Render the encrypted file attachment of a message
|
|
fn render_message_file(
|
|
&self,
|
|
id: &EventId,
|
|
file: &FileAttachment,
|
|
cx: &Context<Self>,
|
|
) -> AnyElement {
|
|
let state = self.decrypted_files.get(id);
|
|
|
|
if let Some(path) = state
|
|
.and_then(|state| match state {
|
|
DecryptedFile::Ready(path) => Some(path),
|
|
_ => None,
|
|
})
|
|
.filter(|_| file.is_image())
|
|
{
|
|
return div()
|
|
.child(
|
|
img(path.clone())
|
|
.border_1()
|
|
.border_color(cx.theme().border_variant)
|
|
.h(px(250.))
|
|
.object_fit(ObjectFit::Cover)
|
|
.rounded(cx.theme().radius),
|
|
)
|
|
.into_any_element();
|
|
}
|
|
|
|
let label = match state {
|
|
Some(DecryptedFile::Loading) => SharedString::from("Decrypting..."),
|
|
Some(DecryptedFile::Failed(error)) => error.clone(),
|
|
Some(DecryptedFile::Ready(_)) => SharedString::from("Click to open"),
|
|
None => SharedString::from("Click to decrypt"),
|
|
};
|
|
|
|
self.render_file_chip(id, file, label, cx)
|
|
}
|
|
|
|
/// Render an encrypted file as a chip which decrypts and opens it on click
|
|
fn render_file_chip(
|
|
&self,
|
|
id: &EventId,
|
|
file: &FileAttachment,
|
|
label: SharedString,
|
|
cx: &Context<Self>,
|
|
) -> AnyElement {
|
|
h_flex()
|
|
.id(SharedString::from(format!("file-{id}")))
|
|
.self_start()
|
|
.items_start()
|
|
.min_w_0()
|
|
.gap_2()
|
|
.p_2()
|
|
.border_1()
|
|
.border_color(cx.theme().border_variant)
|
|
.rounded(cx.theme().radius)
|
|
.child(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent))
|
|
.child(
|
|
v_flex()
|
|
.min_w_0()
|
|
.overflow_hidden()
|
|
.text_sm()
|
|
.child(div().line_height(relative(1.2)).child(file.display_name()))
|
|
.child(
|
|
div()
|
|
.text_xs()
|
|
.text_color(cx.theme().text_placeholder)
|
|
.child(label),
|
|
),
|
|
)
|
|
.on_click({
|
|
let file = file.clone();
|
|
let id = *id;
|
|
|
|
cx.listener(move |this, _, window, cx| {
|
|
this.open_file(id, file.clone(), window, cx);
|
|
})
|
|
})
|
|
.into_any_element()
|
|
}
|
|
|
|
/// Render an uploaded, encrypted file which is not sent yet
|
|
fn render_pending_file(&self, pending: &PendingFile, cx: &Context<Self>) -> impl IntoElement {
|
|
let file = &pending.file;
|
|
let label = file.display_name();
|
|
|
|
div()
|
|
.id(SharedString::from(file.url.to_string()))
|
|
.relative()
|
|
.w_16()
|
|
.tooltip(move |window, cx| Tooltip::new(label.clone(), window, cx).into())
|
|
.map(|this| {
|
|
if file.is_image() {
|
|
this.child(
|
|
img(pending.path.clone())
|
|
.size_16()
|
|
.when(cx.theme().shadow, |this| this.shadow_sm())
|
|
.rounded(cx.theme().radius)
|
|
.object_fit(ObjectFit::Cover),
|
|
)
|
|
} else {
|
|
this.child(
|
|
div()
|
|
.size_16()
|
|
.flex()
|
|
.items_center()
|
|
.justify_center()
|
|
.rounded(cx.theme().radius)
|
|
.border_1()
|
|
.border_color(cx.theme().border_variant)
|
|
.bg(cx.theme().surface_background)
|
|
.text_xs()
|
|
.text_center()
|
|
.child("Preview not available"),
|
|
)
|
|
}
|
|
})
|
|
.child(
|
|
v_flex()
|
|
.absolute()
|
|
.top_neg_1()
|
|
.right_neg_1()
|
|
.size_4()
|
|
.items_center()
|
|
.justify_center()
|
|
.rounded_full()
|
|
.border_1()
|
|
.border_color(cx.theme().border_variant)
|
|
.bg(gpui::green())
|
|
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
|
|
)
|
|
.on_click({
|
|
let url = file.url.clone();
|
|
cx.listener(move |this, _, _, cx| {
|
|
this.remove_pending_file(&url, cx);
|
|
})
|
|
})
|
|
}
|
|
|
|
fn render_pending_file_list(
|
|
&self,
|
|
_window: &Window,
|
|
cx: &Context<Self>,
|
|
) -> impl IntoIterator<Item = impl IntoElement> {
|
|
let mut items = vec![];
|
|
|
|
for pending in self.encrypted_attachments.read(cx).iter() {
|
|
items.push(self.render_pending_file(pending, cx));
|
|
}
|
|
|
|
items
|
|
}
|
|
|
|
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
|
if let Some(text) = self.message(id) {
|
|
let persons = PersonRegistry::global(cx);
|
|
let profile = persons.read(cx).get(&text.author, cx);
|
|
|
|
div()
|
|
.w_full()
|
|
.pl_2()
|
|
.border_l_2()
|
|
.border_color(cx.theme().element_active)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.justify_between()
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.items_baseline()
|
|
.gap_1()
|
|
.text_xs()
|
|
.text_color(cx.theme().text_muted)
|
|
.child("Replying to:")
|
|
.child(
|
|
div()
|
|
.text_color(cx.theme().text_accent)
|
|
.child(profile.name()),
|
|
),
|
|
)
|
|
.child(
|
|
Button::new("remove-reply")
|
|
.icon(IconName::Close)
|
|
.xsmall()
|
|
.ghost()
|
|
.on_click({
|
|
let id = text.id;
|
|
cx.listener(move |this, _, _, cx| {
|
|
this.remove_reply(&id, cx);
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.text_sm()
|
|
.text_ellipsis()
|
|
.line_clamp(1)
|
|
.child(text.preview()),
|
|
)
|
|
} else {
|
|
div()
|
|
}
|
|
}
|
|
|
|
fn render_reply_list(
|
|
&self,
|
|
_window: &Window,
|
|
cx: &Context<Self>,
|
|
) -> impl IntoIterator<Item = impl IntoElement> {
|
|
let mut items = vec![];
|
|
|
|
for id in self.replies_to.read(cx).iter() {
|
|
items.push(self.render_reply(id, cx));
|
|
}
|
|
|
|
items
|
|
}
|
|
|
|
fn render_config_menu(&self, _window: &mut Window, cx: &Context<Self>) -> impl IntoElement {
|
|
let (backup, signer_kind) = self
|
|
.room
|
|
.read_with(cx, |this, _cx| {
|
|
(this.config().backup(), this.config().signer_kind().clone())
|
|
})
|
|
.ok()
|
|
.unwrap_or((true, SignerKind::default()));
|
|
|
|
Button::new("encryption")
|
|
.icon(IconName::Settings2)
|
|
.tooltip("Configuration")
|
|
.ghost()
|
|
.large()
|
|
.dropdown_menu(move |this, _window, _cx| {
|
|
let auto = matches!(signer_kind, SignerKind::Auto);
|
|
let encryption = matches!(signer_kind, SignerKind::Encryption);
|
|
let user = matches!(signer_kind, SignerKind::User);
|
|
|
|
this.label("Signer")
|
|
.menu_with_check_and_disabled(
|
|
"Auto",
|
|
auto,
|
|
Box::new(Command::ChangeSigner(SignerKind::Auto)),
|
|
auto,
|
|
)
|
|
.menu_with_check_and_disabled(
|
|
"Decoupled Encryption Key",
|
|
encryption,
|
|
Box::new(Command::ChangeSigner(SignerKind::Encryption)),
|
|
encryption,
|
|
)
|
|
.menu_with_check_and_disabled(
|
|
"User Identity",
|
|
user,
|
|
Box::new(Command::ChangeSigner(SignerKind::User)),
|
|
user,
|
|
)
|
|
.separator()
|
|
.label("Backup")
|
|
.menu_with_check("Backup messages", backup, Box::new(Command::ToggleBackup))
|
|
})
|
|
}
|
|
|
|
fn render_emoji_menu(&self, _window: &Window, _cx: &Context<Self>) -> impl IntoElement {
|
|
Button::new("emoji")
|
|
.icon(IconName::Emoji)
|
|
.ghost()
|
|
.large()
|
|
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
|
let menu = this.horizontal();
|
|
REACTION_EMOJIS.iter().fold(menu, |this, emoji| {
|
|
this.menu(*emoji, Box::new(Command::Insert(emoji)))
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Panel for ChatPanel {
|
|
fn panel_id(&self) -> SharedString {
|
|
self.id.clone()
|
|
}
|
|
|
|
fn title(&self, cx: &App) -> AnyElement {
|
|
self.room
|
|
.read_with(cx, |this, cx| {
|
|
let label = this.display_name(cx);
|
|
let url = this.display_image(cx);
|
|
|
|
h_flex()
|
|
.gap_1p5()
|
|
.child(Avatar::new(url).xsmall())
|
|
.child(label)
|
|
.into_any_element()
|
|
})
|
|
.unwrap_or(div().child("Unknown").into_any_element())
|
|
}
|
|
|
|
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
|
let subject_bar = self.subject_bar.clone();
|
|
|
|
vec![
|
|
Button::new("subject")
|
|
.icon(IconName::Input)
|
|
.tooltip("Change subject")
|
|
.small()
|
|
.ghost()
|
|
.on_click(move |_ev, _window, cx| {
|
|
subject_bar.update(cx, |this, cx| {
|
|
*this = !*this;
|
|
cx.notify();
|
|
});
|
|
}),
|
|
]
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<PanelEvent> for ChatPanel {}
|
|
|
|
impl Focusable for ChatPanel {
|
|
fn focus_handle(&self, _: &App) -> FocusHandle {
|
|
self.focus_handle.clone()
|
|
}
|
|
}
|
|
|
|
impl Render for ChatPanel {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
const WARNING: &str = "Attachments added while typing are uploaded without encryption";
|
|
|
|
let is_typing = !self.input.read(cx).value().trim().is_empty();
|
|
let pending_attachments = !self.encrypted_attachments.read(cx).is_empty();
|
|
|
|
v_flex()
|
|
.image_cache(retain_all(self.id.clone()))
|
|
.on_action(cx.listener(Self::on_command))
|
|
.size_full()
|
|
.when(*self.subject_bar.read(cx), |this| {
|
|
this.child(
|
|
h_flex()
|
|
.h_12()
|
|
.w_full()
|
|
.px_2()
|
|
.gap_2()
|
|
.border_b_1()
|
|
.border_color(cx.theme().border)
|
|
.child(Input::new(&self.subject_input).text_sm().small())
|
|
.child(
|
|
Button::new("change")
|
|
.icon(IconName::CheckCircle)
|
|
.label("Change")
|
|
.secondary()
|
|
.disabled(self.uploading)
|
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
|
this.change_subject(window, cx);
|
|
})),
|
|
),
|
|
)
|
|
})
|
|
.child(
|
|
v_flex()
|
|
.flex_1()
|
|
.relative()
|
|
.map(|this| {
|
|
if self.messages.is_empty() {
|
|
this.child(
|
|
h_flex()
|
|
.size_full()
|
|
.justify_end()
|
|
.child(self.render_announcement(cx)),
|
|
)
|
|
} else {
|
|
this.child(
|
|
list(
|
|
self.list_state.clone(),
|
|
cx.processor(move |this, ix, window, cx| {
|
|
this.render_message(ix, window, cx)
|
|
}),
|
|
)
|
|
.size_full(),
|
|
)
|
|
}
|
|
})
|
|
.child(Scrollbar::vertical(&self.list_state)),
|
|
)
|
|
.child(
|
|
v_flex()
|
|
.flex_shrink_0()
|
|
.p_2()
|
|
.w_full()
|
|
.gap_1p5()
|
|
.children(self.render_attachment_list(window, cx))
|
|
.children(self.render_pending_file_list(window, cx))
|
|
.children(self.render_reply_list(window, cx))
|
|
.when(is_typing && pending_attachments, |this| {
|
|
this.child(
|
|
div()
|
|
.px_1()
|
|
.text_xs()
|
|
.text_color(cx.theme().text_warning)
|
|
.child(WARNING),
|
|
)
|
|
})
|
|
.child(
|
|
h_flex()
|
|
.items_end()
|
|
.child(
|
|
Button::new("upload")
|
|
.icon(IconName::Plus)
|
|
.tooltip("Upload media")
|
|
.loading(self.uploading)
|
|
.disabled(self.uploading)
|
|
.ghost()
|
|
.large()
|
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
|
this.upload(window, cx);
|
|
})),
|
|
)
|
|
.child(Input::new(&self.input).appearance(false).flex_1())
|
|
.child(
|
|
h_flex()
|
|
.pl_1()
|
|
.gap_1()
|
|
.child(self.render_emoji_menu(window, cx))
|
|
.child(self.render_config_menu(window, cx))
|
|
.child(
|
|
Button::new("send")
|
|
.icon(IconName::PaperPlaneFill)
|
|
.disabled(self.uploading)
|
|
.ghost()
|
|
.large()
|
|
.on_click(cx.listener(move |this, _ev, window, cx| {
|
|
this.send_text_message(window, cx);
|
|
})),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|