From c239e351b8803e7cde844af17f1e66901781c1c3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 10 Apr 2026 02:00:18 +0000 Subject: [PATCH] feat: add support for rendering images in chat messages (#29) Reviewed-on: https://git.reya.su/reya/coop/pulls/29 Co-authored-by: Ren Amamiya Co-committed-by: Ren Amamiya --- Cargo.lock | 99 +++++++++++--------- crates/chat/src/message.rs | 18 +++- crates/chat_ui/src/lib.rs | 61 +++++++++++- crates/chat_ui/src/text.rs | 2 + crates/common/Cargo.toml | 1 + crates/common/src/caching.rs | 135 +++++++++++++++++++++++++++ crates/common/src/lib.rs | 4 + crates/common/src/media_extractor.rs | 117 +++++++++++++++++++++++ crates/state/src/constants.rs | 3 + desktop/src/sidebar/mod.rs | 14 +-- desktop/src/workspace.rs | 27 ++++-- 11 files changed, 410 insertions(+), 71 deletions(-) create mode 100644 crates/common/src/caching.rs create mode 100644 crates/common/src/media_extractor.rs diff --git a/Cargo.lock b/Cargo.lock index df09983..4d21120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,9 +446,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -1254,7 +1254,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "indexmap", "rustc-hash 2.1.2", @@ -1307,6 +1307,7 @@ dependencies = [ "nostr", "nostr-sdk", "qrcode", + "regex", "smallvec", "smol", ] @@ -1806,7 +1807,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "proc-macro2", "quote", @@ -2882,7 +2883,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -2909,6 +2910,7 @@ dependencies = [ "futures-concurrency", "getrandom 0.3.4", "gpui_macros 0.1.0 (git+https://github.com/zed-industries/zed)", + "gpui_shared_string", "gpui_util 0.1.0 (git+https://github.com/zed-industries/zed)", "http_client 0.1.0 (git+https://github.com/zed-industries/zed)", "image", @@ -2994,7 +2996,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -3042,7 +3044,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "async-task", @@ -3096,7 +3098,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3107,7 +3109,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "console_error_panic_hook", "gpui 0.2.2 (git+https://github.com/zed-industries/zed)", @@ -3117,10 +3119,21 @@ dependencies = [ "gpui_windows", ] +[[package]] +name = "gpui_shared_string" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" +dependencies = [ + "derive_more", + "gpui_util 0.1.0 (git+https://github.com/zed-industries/zed)", + "schemars", + "serde", +] + [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "gpui 0.2.2 (git+https://github.com/zed-industries/zed)", @@ -3140,7 +3153,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "log", @@ -3149,7 +3162,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3201,7 +3214,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "bytemuck", @@ -3229,7 +3242,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "collections 0.1.0 (git+https://github.com/zed-industries/zed)", @@ -3526,7 +3539,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "async-compression", @@ -3551,7 +3564,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "rustls", "rustls-platform-verifier", @@ -4221,9 +4234,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lmdb-master-sys" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864808e0b19fb6dd3b70ba94ee671b82fce17554cf80aeb0a155c65bb08027df" +checksum = "aaeb9bd22e73bd1babffff614994b341e9b2008de7bb73bf1f7e9154f1978f8b" dependencies = [ "cc", "doxygen-rs", @@ -4406,7 +4419,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "bindgen", @@ -4672,7 +4685,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.44.1" -source = "git+https://github.com/rust-nostr/nostr#fd25803fbc9b3fb3207ead372336208c1c60b37b" +source = "git+https://github.com/rust-nostr/nostr#2be7983065ec3af8a1f699869a125a49bc23fd22" dependencies = [ "aes", "base64", @@ -4696,7 +4709,7 @@ dependencies = [ [[package]] name = "nostr-blossom" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#fd25803fbc9b3fb3207ead372336208c1c60b37b" +source = "git+https://github.com/rust-nostr/nostr#2be7983065ec3af8a1f699869a125a49bc23fd22" dependencies = [ "base64", "nostr", @@ -4707,7 +4720,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#fd25803fbc9b3fb3207ead372336208c1c60b37b" +source = "git+https://github.com/rust-nostr/nostr#2be7983065ec3af8a1f699869a125a49bc23fd22" dependencies = [ "async-utility", "futures-core", @@ -4720,7 +4733,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#fd25803fbc9b3fb3207ead372336208c1c60b37b" +source = "git+https://github.com/rust-nostr/nostr#2be7983065ec3af8a1f699869a125a49bc23fd22" dependencies = [ "btreecap", "flatbuffers", @@ -4730,7 +4743,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#fd25803fbc9b3fb3207ead372336208c1c60b37b" +source = "git+https://github.com/rust-nostr/nostr#2be7983065ec3af8a1f699869a125a49bc23fd22" dependencies = [ "nostr", ] @@ -4738,7 +4751,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#fd25803fbc9b3fb3207ead372336208c1c60b37b" +source = "git+https://github.com/rust-nostr/nostr#2be7983065ec3af8a1f699869a125a49bc23fd22" dependencies = [ "indexmap", "lru", @@ -4750,7 +4763,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#fd25803fbc9b3fb3207ead372336208c1c60b37b" +source = "git+https://github.com/rust-nostr/nostr#2be7983065ec3af8a1f699869a125a49bc23fd22" dependencies = [ "async-utility", "flume 0.12.0", @@ -4764,7 +4777,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.44.1" -source = "git+https://github.com/rust-nostr/nostr#fd25803fbc9b3fb3207ead372336208c1c60b37b" +source = "git+https://github.com/rust-nostr/nostr#2be7983065ec3af8a1f699869a125a49bc23fd22" dependencies = [ "async-utility", "async-wsocket", @@ -5280,7 +5293,7 @@ dependencies = [ [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "collections 0.1.0 (git+https://github.com/zed-industries/zed)", "serde", @@ -5555,7 +5568,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.10+spec-1.1.0", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -6000,7 +6013,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "derive_refineable 0.1.0 (git+https://github.com/zed-industries/zed)", ] @@ -6099,7 +6112,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "bytes", @@ -6157,7 +6170,7 @@ dependencies = [ [[package]] name = "rope" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "heapless 0.9.2", "log", @@ -6434,7 +6447,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "async-task", "backtrace", @@ -7047,7 +7060,7 @@ dependencies = [ [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "heapless 0.9.2", "log", @@ -7464,9 +7477,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.51.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", @@ -7621,9 +7634,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.10+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", @@ -8059,7 +8072,7 @@ dependencies = [ [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "async-fs", @@ -8108,7 +8121,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "perf 0.1.0 (git+https://github.com/zed-industries/zed)", "quote", @@ -9936,7 +9949,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "anyhow", "chrono", @@ -9964,7 +9977,7 @@ dependencies = [ [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" dependencies = [ "tracing", "tracing-subscriber", @@ -9980,7 +9993,7 @@ source = "git+https://github.com/zed-industries/zed?rev=56104fb17e6c5849900a4c28 [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#3a5dc8ef6aaed33f7c57f9eeab36bbc71a19bc59" +source = "git+https://github.com/zed-industries/zed#5be9dc1781ef6d2cbfbdbcd417edd0935cbb83a8" [[package]] name = "zune-core" diff --git a/crates/chat/src/message.rs b/crates/chat/src/message.rs index de91023..3234226 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -1,8 +1,8 @@ use std::hash::Hash; use std::ops::Range; -use common::{EventExt, NostrParser}; -use gpui::SharedString; +use common::{EventExt, NostrParser, extract_and_remove_media_urls}; +use gpui::{SharedString, SharedUri}; use nostr_sdk::prelude::*; /// New message. @@ -132,6 +132,8 @@ pub struct RenderedMessage { pub author: PublicKey, /// The content/text of the message pub content: String, + /// List of media URLs in the message + pub media: Vec, /// Message created time as unix timestamp pub created_at: Timestamp, /// List of mentioned public keys in the message @@ -144,11 +146,13 @@ impl From<&Event> for RenderedMessage { fn from(val: &Event) -> Self { let mentions = extract_mentions(&val.content); let replies_to = extract_reply_ids(&val.tags); + let (media, string) = extract_and_remove_media_urls(&val.content); Self { id: val.id, author: val.pubkey, - content: val.content.clone(), + content: string, + media, created_at: val.created_at, mentions, replies_to, @@ -160,12 +164,14 @@ impl From<&UnsignedEvent> for RenderedMessage { fn from(val: &UnsignedEvent) -> Self { let mentions = extract_mentions(&val.content); let replies_to = extract_reply_ids(&val.tags); + let (media, string) = extract_and_remove_media_urls(&val.content); Self { // Event ID must be known id: val.id.unwrap(), author: val.pubkey, - content: val.content.clone(), + content: string, + media, created_at: val.created_at, mentions, replies_to, @@ -177,12 +183,14 @@ impl From<&NewMessage> for RenderedMessage { fn from(val: &NewMessage) -> Self { let mentions = extract_mentions(&val.rumor.content); let replies_to = extract_reply_ids(&val.rumor.tags); + let (media, string) = extract_and_remove_media_urls(&val.rumor.content); Self { // Event ID must be known id: val.rumor.id.unwrap(), author: val.rumor.pubkey, - content: val.rumor.content.clone(), + content: string, + media, created_at: val.rumor.created_at, mentions, replies_to, diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 09dd12c..446cbe3 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -4,14 +4,14 @@ use std::sync::Arc; pub use actions::*; use anyhow::{Context as AnyhowContext, Error}; use chat::{ChatRegistry, Message, RenderedMessage, Room, RoomEvent, SendReport, SendStatus}; -use common::TimestampExt; +use common::{TimestampExt, coop_cache}; 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, StatefulInteractiveElement, - Styled, StyledImage, Subscription, Task, WeakEntity, Window, deferred, div, img, list, px, red, - relative, svg, white, + ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri, + StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, + deferred, div, img, list, px, red, relative, svg, white, }; use itertools::Itertools; use nostr_sdk::prelude::*; @@ -914,7 +914,8 @@ impl ChatPanel { .when(has_replies, |this| { this.children(self.render_message_replies(replies, cx)) }) - .child(rendered_text), + .child(rendered_text) + .child(self.render_media(&message.media, cx)), ), ) .child( @@ -941,6 +942,55 @@ impl ChatPanel { .into_any_element() } + fn render_media(&self, media: &[SharedUri], cx: &Context) -> 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], @@ -1435,6 +1485,7 @@ impl Focusable for ChatPanel { impl Render for ChatPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() + .image_cache(coop_cache(self.id.clone(), 100)) .on_action(cx.listener(Self::on_command)) .size_full() .when(*self.subject_bar.read(cx), |this| { diff --git a/crates/chat_ui/src/text.rs b/crates/chat_ui/src/text.rs index 982ca11..67c7e65 100644 --- a/crates/chat_ui/src/text.rs +++ b/crates/chat_ui/src/text.rs @@ -69,6 +69,7 @@ impl RenderedText { pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement { let code_background = cx.theme().elevated_surface_background; + let color = cx.theme().text_accent; InteractiveText::new( id, @@ -100,6 +101,7 @@ impl RenderedText { } } Highlight::Mention => HighlightStyle { + color: Some(color), underline: Some(UnderlineStyle { thickness: 1.0.into(), ..Default::default() diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 2bac890..670e635 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -20,3 +20,4 @@ log.workspace = true dirs = "5.0" qrcode = "0.14.1" bech32 = "0.11.1" +regex = "1.10" diff --git a/crates/common/src/caching.rs b/crates/common/src/caching.rs new file mode 100644 index 0000000..c7ea380 --- /dev/null +++ b/crates/common/src/caching.rs @@ -0,0 +1,135 @@ +use std::collections::{HashMap, VecDeque}; +use std::mem::take; + +use futures::FutureExt; +use gpui::{ + App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache, + ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash, +}; + +pub fn coop_cache(id: impl Into, max_items: usize) -> CoopImageCacheProvider { + CoopImageCacheProvider { + id: id.into(), + max_items, + } +} + +pub struct CoopImageCacheProvider { + id: ElementId, + max_items: usize, +} + +impl ImageCacheProvider for CoopImageCacheProvider { + fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache { + window + .with_global_id(self.id.clone(), |id, window| { + window.with_element_state(id, |cache, _| { + let cache = cache.unwrap_or_else(|| CoopImageCache::new(self.max_items, cx)); + (cache.clone(), cache) + }) + }) + .into() + } +} + +pub struct CoopImageCache { + max_items: usize, + usage_list: VecDeque, + cache: HashMap, +} + +impl CoopImageCache { + pub fn new(max_items: usize, cx: &mut App) -> Entity { + cx.new(|cx| { + log::info!("Creating CoopImageCache"); + cx.on_release(|this: &mut Self, cx| { + for (ix, (mut image, resource)) in take(&mut this.cache) { + if let Some(Ok(image)) = image.get() { + log::info!("Dropping image {ix}"); + cx.drop_image(image, None); + } + ImageSource::Resource(resource).remove_asset(cx); + } + }) + .detach(); + + CoopImageCache { + max_items, + usage_list: VecDeque::with_capacity(max_items), + cache: HashMap::with_capacity(max_items), + } + }) + } +} + +impl ImageCache for CoopImageCache { + fn load( + &mut self, + resource: &Resource, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> Option, gpui::ImageCacheError>> { + let hash = hash(resource); + + if let Some(item) = self.cache.get_mut(&hash) { + let current_idx = self + .usage_list + .iter() + .position(|item| *item == hash) + .expect("cache has an item usage_list doesn't"); + + self.usage_list.remove(current_idx); + self.usage_list.push_front(hash); + + return item.0.get(); + } + + let load_future = AssetLogger::::load(resource.clone(), cx); + let task = cx.background_executor().spawn(load_future).shared(); + + if self.usage_list.len() >= self.max_items { + log::info!("Image cache is full, evicting oldest item"); + + if let Some(oldest) = self.usage_list.pop_back() { + let mut image = self + .cache + .remove(&oldest) + .expect("usage_list has an item cache doesn't"); + + if let Some(Ok(image)) = image.0.get() { + log::info!("requesting image to be dropped"); + cx.drop_image(image, Some(window)); + } + + ImageSource::Resource(image.1).remove_asset(cx); + } + } + + self.cache.insert( + hash, + ( + gpui::ImageCacheItem::Loading(task.clone()), + resource.clone(), + ), + ); + self.usage_list.push_front(hash); + + let entity = window.current_view(); + + window + .spawn(cx, async move |cx| { + let result = task.await; + + if let Err(err) = result { + log::error!("error loading image into cache: {:?}", err); + } + + cx.on_next_frame(move |_, cx| { + cx.notify(entity); + }); + }) + .detach(); + + None + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 0318b82..8f7bcdb 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,13 +1,17 @@ +pub use caching::*; pub use debounced_delay::*; pub use display::*; pub use event::*; +pub use media_extractor::*; pub use parser::*; pub use paths::*; pub use range::*; +mod caching; mod debounced_delay; mod display; mod event; +mod media_extractor; mod parser; mod paths; mod range; diff --git a/crates/common/src/media_extractor.rs b/crates/common/src/media_extractor.rs new file mode 100644 index 0000000..8f7dee8 --- /dev/null +++ b/crates/common/src/media_extractor.rs @@ -0,0 +1,117 @@ +use gpui::SharedUri; +use regex::Regex; + +/// Extracts media URLs from a string and returns both the extracted URLs +/// and the string with media URLs removed +pub struct MediaExtractor { + image_regex: Regex, + video_regex: Regex, +} + +impl MediaExtractor { + /// Creates a new MediaExtractor with compiled regex patterns + pub fn new() -> Self { + MediaExtractor { + // Match common image extensions + image_regex: Regex::new( + r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#, + ).unwrap(), + // Match common video extensions + video_regex: Regex::new( + r#"(?i)\bhttps?://[^\s<>"']+\.(?:mp4|mov|avi|mkv|webm|flv|wmv|m4v|3gp)(?:\?[^\s<>"']*)?\b"#, + ).unwrap(), + } + } + + /// Extracts all media URLs from a string + pub fn extract_media_urls(&self, text: &str) -> Vec { + let mut urls = Vec::new(); + + // Extract image URLs + for capture in self.image_regex.find_iter(text) { + urls.push(capture.as_str().to_string().into()); + } + + // Extract video URLs + // for capture in self.video_regex.find_iter(text) { + // urls.push(capture.as_str().to_string().into()); + // } + + urls + } + + /// Removes all media URLs from a string and returns the cleaned text + pub fn remove_media_urls(&self, text: &str) -> String { + let mut result = text.to_string(); + + // Remove image URLs + result = self.image_regex.replace_all(&result, "").to_string(); + + // Remove video URLs + // result = self.video_regex.replace_all(&result, "").to_string(); + + // Clean up extra whitespace that might result from removal + self.cleanup_text(&result) + } + + /// Extracts media URLs and removes them from the string, returning both + pub fn extract_and_remove(&self, text: &str) -> (Vec, String) { + let urls = self.extract_media_urls(text); + let cleaned_text = self.remove_media_urls(text); + (urls, cleaned_text) + } + + /// Helper function to clean up text after URL removal + fn cleanup_text(&self, text: &str) -> String { + let text = text.trim(); + + // Remove multiple consecutive spaces + let re = Regex::new(r"\s+").unwrap(); + re.replace_all(text, " ").trim().to_string() + } + + /// Validates if a URL is a valid media URL + pub fn is_media_url(&self, url: &str) -> bool { + self.image_regex.is_match(url) || self.video_regex.is_match(url) + } + + /// Categorizes extracted URLs into images and videos + pub fn categorize_urls(&self, urls: &[SharedUri]) -> (Vec, Vec) { + let mut images = Vec::new(); + let mut videos = Vec::new(); + + for url in urls { + if self.image_regex.is_match(url) { + images.push(url.clone()); + } else if self.video_regex.is_match(url) { + videos.push(url.clone()); + } + } + + (images, videos) + } +} + +impl Default for MediaExtractor { + fn default() -> Self { + Self::new() + } +} + +/// Convenience function for one-time extraction and removal +pub fn extract_and_remove_media_urls(text: &str) -> (Vec, String) { + let extractor = MediaExtractor::new(); + extractor.extract_and_remove(text) +} + +/// Convenience function for just extracting media URLs +pub fn extract_media_urls(text: &str) -> Vec { + let extractor = MediaExtractor::new(); + extractor.extract_media_urls(text) +} + +/// Convenience function for just removing media URLs +pub fn remove_media_urls(text: &str) -> String { + let extractor = MediaExtractor::new(); + extractor.remove_media_urls(text) +} diff --git a/crates/state/src/constants.rs b/crates/state/src/constants.rs index 296b498..56a1d74 100644 --- a/crates/state/src/constants.rs +++ b/crates/state/src/constants.rs @@ -15,6 +15,9 @@ pub const KEYRING: &str = "Coop Safe Storage"; /// Default timeout for subscription pub const TIMEOUT: u64 = 2; +/// Default image cache size +pub const IMAGE_CACHE_SIZE: usize = 20; + /// Default delay for searching pub const FIND_DELAY: u64 = 600; diff --git a/desktop/src/sidebar/mod.rs b/desktop/src/sidebar/mod.rs index 3cb3f9f..a9d1b99 100644 --- a/desktop/src/sidebar/mod.rs +++ b/desktop/src/sidebar/mod.rs @@ -4,18 +4,18 @@ use std::time::Duration; use anyhow::{Context as AnyhowContext, Error}; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; -use common::{DebouncedDelay, TimestampExt}; +use common::{DebouncedDelay, TimestampExt, coop_cache}; use entry::RoomEntry; use gpui::prelude::FluentBuilder; use gpui::{ App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, - ParentElement, Render, RetainAllImageCache, SharedString, Styled, Subscription, Task, - UniformListScrollHandle, Window, div, uniform_list, + ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle, + Window, div, uniform_list, }; use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; -use state::{FIND_DELAY, NostrRegistry}; +use state::{FIND_DELAY, IMAGE_CACHE_SIZE, NostrRegistry}; use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT}; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; @@ -39,9 +39,6 @@ pub struct Sidebar { focus_handle: FocusHandle, scroll_handle: UniformListScrollHandle, - /// Image cache - image_cache: Entity, - /// Find input state find_input: Entity, @@ -141,7 +138,6 @@ impl Sidebar { name: "Sidebar".into(), focus_handle: cx.focus_handle(), scroll_handle: UniformListScrollHandle::new(), - image_cache: RetainAllImageCache::new(cx), find_input, find_debouncer: DebouncedDelay::new(), find_results, @@ -507,7 +503,7 @@ impl Render for Sidebar { }; v_flex() - .image_cache(self.image_cache.clone()) + .image_cache(coop_cache("sidebar", IMAGE_CACHE_SIZE)) .size_full() .gap_2() .child( diff --git a/desktop/src/workspace.rs b/desktop/src/workspace.rs index 090f91a..64f17f4 100644 --- a/desktop/src/workspace.rs +++ b/desktop/src/workspace.rs @@ -2,19 +2,19 @@ use std::sync::Arc; use ::settings::AppSettings; use chat::{ChatEvent, ChatRegistry}; -use common::download_dir; +use common::{CoopImageCache, download_dir}; use device::{DeviceEvent, DeviceRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement, - Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div, px, - relative, + Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div, + image_cache, px, relative, }; use nostr_sdk::prelude::*; use person::{PersonRegistry, shorten_pubkey}; use serde::Deserialize; use smallvec::{SmallVec, smallvec}; -use state::{NostrRegistry, StateEvent}; +use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent}; use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry}; use title_bar::TitleBar; use ui::avatar::Avatar; @@ -70,6 +70,9 @@ pub struct Workspace { /// App's Dock Area dock: Entity, + /// App's Image Cache + image_cache: Entity, + /// Event subscriptions _subscriptions: SmallVec<[Subscription; 5]>, } @@ -82,6 +85,7 @@ impl Workspace { let titlebar = cx.new(|_| TitleBar::new()); let dock = cx.new(|cx| DockArea::new(window, cx)); + let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx); let mut subscriptions = smallvec![]; @@ -231,6 +235,7 @@ impl Workspace { Self { titlebar, dock, + image_cache, _subscriptions: subscriptions, } } @@ -848,13 +853,17 @@ impl Render for Workspace { .relative() .size_full() .child( - v_flex() + image_cache(self.image_cache.clone()) .relative() .size_full() - // Title Bar - .child(self.titlebar.clone()) - // Dock - .child(self.dock.clone()), + .child( + v_flex() + .size_full() + // Title Bar + .child(self.titlebar.clone()) + // Dock + .child(self.dock.clone()), + ), ) // Notifications .children(notification_layer)