feat: adopt the native OS theme #66

Merged
reya merged 14 commits from feat/native-theme into master 2026-10-23 04:07:12 +00:00
24 changed files with 398 additions and 296 deletions
Showing only changes of commit c3c8142f06 - Show all commits
Generated
+3
View File
@@ -8207,10 +8207,13 @@ dependencies = [
name = "ui"
version = "2.0.0-alpha"
dependencies = [
"common",
"gpui-base",
"gpui-kit",
"gpui-pre",
"log",
"nostr-sdk",
"person",
"smallvec",
"uuid",
"web-time",
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.9231L10.5625 15.75L15.25 8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75" stroke="black" stroke-width="1.5" stroke-linecap="round"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 191 B

After

Width:  |  Height:  |  Size: 201 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M19 1.75V8.25M15.75 5H22.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M10.75 9.9C10.75 11.0046 10.0784 11.75 9.25 11.75C8.42157 11.75 7.75 11.0046 7.75 9.9C7.75 8.79543 8.42157 8 9.25 8C10.0784 8 10.75 8.79543 10.75 9.9Z" fill="currentColor"/><path d="M16.25 9.9C16.25 11.0046 15.5784 11.75 14.75 11.75C13.9216 11.75 13.25 11.0046 13.25 9.9C13.25 8.79543 13.9216 8 14.75 8C15.5784 8 16.25 8.79543 16.25 9.9Z" fill="currentColor"/><path d="M16.1123 14.8493C16.1942 14.7105 16.2249 14.545 16.192 14.3857C16.1592 14.2263 16.0665 14.0867 15.933 13.9968C15.7996 13.9069 15.6354 13.8733 15.4754 13.9028C15.3154 13.9321 15.1736 14.0226 15.0757 14.1507C15.0008 14.2469 14.9237 14.3367 14.8415 14.4241C14.1096 15.2083 13.061 15.628 12.0035 15.625C10.946 15.6265 9.8972 15.2055 9.16254 14.4222C9.08002 14.3348 9.00261 14.2451 8.92738 14.1491C8.8291 14.0214 8.68699 13.9313 8.52686 13.9024C8.36679 13.8735 8.20268 13.9076 8.06954 13.9979C7.9364 14.0882 7.84406 14.2281 7.81174 14.3875C7.77938 14.547 7.81054 14.7123 7.89293 14.8509C7.97731 14.99 8.06686 15.1223 8.16553 15.2526C9.04297 16.4311 10.5292 17.1343 12.0024 17.125C13.4754 17.1367 14.965 16.4342 15.8405 15.2521C15.939 15.1215 16.0282 14.9888 16.1123 14.8493Z" fill="currentColor"/><path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M11.75 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V15.25C21.25 16.3546 20.3546 17.25 19.25 17.25H11.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.75 6.75H4.75C3.64543 6.75 2.75 7.64543 2.75 8.75V15.25C2.75 16.3546 3.64543 17.25 4.75 17.25H5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.75 3.75V20.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

+2 -13
View File
@@ -1,14 +1,3 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9.75027 5.52371L10.7168 4.55722C13.1264 2.14759 17.0332 2.14759 19.4428 4.55722C21.8524 6.96684 21.8524 10.8736 19.4428 13.2832L18.4742 14.2519M5.52886 9.74513L4.55722 10.7168C2.14759 13.1264 2.1476 17.0332 4.55722 19.4428C6.96684 21.8524 10.8736 21.8524 13.2832 19.4428L14.2478 18.4782M9.5 14.5L14.5 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 359 B

After

Width:  |  Height:  |  Size: 462 B

+2 -13
View File
@@ -1,14 +1,3 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 309 B

After

Width:  |  Height:  |  Size: 626 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.66936 5.12886C2.12122 3.64104 3.6759 2.24953 5.09409 2.95862L20.0468 10.435C21.3366 11.0799 21.3366 12.9205 20.0468 13.5655L5.09409 21.0418C3.67589 21.7509 2.12122 20.3594 2.66936 18.8715L4.92467 12.75H9.25021C9.66442 12.75 10.0002 12.4142 10.0002 12C10.0002 11.5858 9.66442 11.25 9.25021 11.25H4.92452L2.66936 5.12886Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

+1 -1
View File
@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 5.25V12M12 12V18.75M12 12H5.25M12 12H18.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M12 6.75V12M12 12V17.25M12 12H6.75M12 12H17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 203 B

After

Width:  |  Height:  |  Size: 203 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M13 19.25H5.95C4.82989 19.25 4.26984 19.25 3.84202 19.032C3.46569 18.8403 3.15973 18.5343 2.96799 18.158C2.75 17.7302 2.75 17.1701 2.75 16.05V7.95C2.75 6.82989 2.75 6.26984 2.96799 5.84202C3.15973 5.46569 3.46569 5.15973 3.84202 4.96799C4.26984 4.75 4.8299 4.75 5.95 4.75H18.05C19.1701 4.75 19.7302 4.75 20.158 4.96799C20.5343 5.15973 20.8403 5.46569 21.032 5.84202C21.25 6.26984 21.25 6.8299 21.25 7.95V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3 5.63635L10.9761 10.3898C11.6069 10.7657 12.3931 10.7657 13.0239 10.3898L21 5.63635" stroke="currentColor" stroke-width="1.5"/><path d="M21.8148 15.375L21.1669 15.7491M16.1856 18.625L16.8335 18.2509M21.8147 18.625L21.1669 18.251M16.1855 15.375L16.8335 15.7491M19.0002 20.25L19.0002 19.4375M19.0002 13.75V14.5625M21.1669 17C21.1669 16.6053 21.0613 16.2352 20.8769 15.9165C20.5022 15.269 19.8021 14.8333 19.0002 14.8333C18.1983 14.8333 17.4981 15.269 17.1235 15.9165C16.9391 16.2352 16.8335 16.6053 16.8335 17C16.8335 17.3947 16.9391 17.7648 17.1235 18.0835C17.4982 18.731 18.1983 19.1667 19.0002 19.1667C19.8021 19.1667 20.5022 18.731 20.8769 18.0835C21.0613 17.7648 21.1669 17.3947 21.1669 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1 -15
View File
@@ -1,15 +1 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3v12" />
<path d="m17 8-5-5-5 5" />
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V11.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3.75 16.25L6.58579 13.4142C7.36683 12.6332 8.63317 12.6332 9.41421 13.4142L12 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M14.5 11.5C15.6046 11.5 16.5 10.6046 16.5 9.5C16.5 8.39543 15.6046 7.5 14.5 7.5C13.3954 7.5 12.5 8.39543 12.5 9.5C12.5 10.6046 13.3954 11.5 14.5 11.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M15.75 18.5H21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M18.5 15.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 849 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M11.3121 12.3511L11.0582 7.9983C11.0266 7.45662 11.4574 7 12 7C12.5426 7 12.9734 7.45662 12.9418 7.9983L12.6879 12.3511C12.6666 12.7154 12.365 13 12 13C11.635 13 11.3334 12.7154 11.3121 12.3511Z" fill="currentColor"/><circle cx="11.9999" cy="15.8998" r="1.1" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 445 B

+1 -59
View File
@@ -1,16 +1,13 @@
use std::hash::Hash;
use std::ops::Range;
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls};
use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls, nevent_refs};
use gpui::{SharedString, SharedUri};
use nostr_sdk::prelude::*;
use state::FileAttachment;
pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
const SCHEME_WITH_COLON: &str = "nostr:";
const NEVENT_PREFIX: &str = "nevent1";
/// Rendered message.
#[derive(Debug, Clone)]
pub struct Message {
@@ -248,61 +245,6 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
.collect()
}
/// A `nevent` reference found in a content string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeventRef {
/// Range of the reference, including the optional `nostr:` scheme
pub range: Range<usize>,
/// Compact display label, like `https://njump.me/nevent1...d9sp`
pub label: String,
}
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
let mut references = Vec::new();
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
// Only lowercase references are matched, matching NIP-19 encodings in practice
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
match_start - SCHEME_WITH_COLON.len()
} else {
match_start
};
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
if content[..start].ends_with(char::is_alphanumeric) {
continue;
}
let end = match_start
+ content[match_start..]
.bytes()
.take_while(u8::is_ascii_alphanumeric)
.count();
// The bech32 token excludes the optional `nostr:` scheme
let bech32 = &content[match_start..end];
if Nip19Event::from_bech32(bech32).is_err() {
continue;
}
references.push(NeventRef {
range: start..end,
label: nevent_label(bech32),
});
}
references
}
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
fn nevent_label(bech32: &str) -> String {
match bech32.len() {
0..=4 => format!("https://njump.me/{bech32}"),
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
}
}
/// Extracts all reply (ids) from the event tags.
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
let mut replies_to = vec![];
+42 -98
View File
@@ -15,7 +15,6 @@ use gpui::{
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
retain_all, svg, white,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{Panel, PanelEvent};
@@ -34,16 +33,13 @@ use smallvec::{SmallVec, smallvec};
use state::{
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
};
use ui::{
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow,
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, WelcomeMessage, group_reactions,
opens_run, rendered_text,
};
use crate::file::*;
use crate::message::{
Avatar, MessageMedia, MessageRow, ReactionBar, ReplyPreview, WelcomeMessage, group_reactions,
opens_run,
};
use crate::text::RenderedText;
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
/// Notice shown when a conversation has no messages, and above the message list.
const PRIVATE_NOTICE: &str =
@@ -60,8 +56,6 @@ static CUSTOM_EMOJI_RE: LazyLock<Regex> =
mod actions;
mod file;
pub mod message;
pub mod text;
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
cx.new(|cx| ChatPanel::new(room, window, cx))
@@ -1050,7 +1044,7 @@ impl ChatPanel {
.rounded_full()
.bg(cx.theme().warning.opacity(0.15))
.text_color(cx.theme().warning)
.child(Icon::new(IconName::TriangleAlert).small()),
.child(Icon::new(IconName::Warning).small()),
)
.child(
div()
@@ -1108,9 +1102,12 @@ impl ChatPanel {
.rendered_texts_by_id
.entry(message.id)
.or_insert_with(|| {
text::rendered_text(
rendered_text(
&message.content,
&message.mentions,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
@@ -1392,83 +1389,34 @@ impl ChatPanel {
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_lg)
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.children({
let mut items = vec![];
let mut actions = MessageActions::new();
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);
})
}),
);
}
for emoji in COMPACT_REACTION_EMOJIS {
let id = *id;
actions = actions.reaction(
emoji,
cx.listener(move |this, _event, window, cx| {
this.send_reaction(emoji, &id, window, cx);
}),
);
}
items
let reply_id = *id;
let copy_id = *id;
let dropdown_id = *id;
let public_key = *public_key;
actions
.reply(cx.listener(move |this, _event, _window, cx| {
this.reply_to(&reply_id, cx);
}))
.copy(cx.listener(move |this, _event, _window, cx| {
this.copy_message(&copy_id, cx);
}))
.dropdown(move |menu, _window, _cx| {
menu.menu("Copy author", Box::new(Command::Copy(public_key)))
.menu("Seen on", Box::new(Command::Trace(dropdown_id)))
})
.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 {
@@ -1494,7 +1442,7 @@ impl ChatPanel {
.justify_center()
.rounded_full()
.bg(red())
.child(Icon::new(IconName::X).size_2().text_color(white())),
.child(Icon::new(IconName::Close).size_2().text_color(white())),
)
.on_click({
let url = url.clone();
@@ -1695,7 +1643,7 @@ impl ChatPanel {
)
.child(
Button::new("remove-reply")
.icon(IconName::X)
.icon(IconName::Close)
.xsmall()
.ghost()
.on_click({
@@ -1746,7 +1694,6 @@ impl ChatPanel {
.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);
@@ -1792,9 +1739,8 @@ impl ChatPanel {
.unwrap_or_default();
Button::new("emoji")
.icon(IconName::Sticker)
.icon(IconName::Emoji)
.ghost()
.large()
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
let menu = REACTION_EMOJIS.iter().fold(this, |this, emoji| {
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
@@ -1840,7 +1786,7 @@ impl Panel for ChatPanel {
Some(vec![
Button::new("subject")
.icon(IconName::TextCursorInput)
.icon(IconName::Input)
.tooltip("Change subject")
.small()
.ghost()
@@ -1885,7 +1831,7 @@ impl Render for ChatPanel {
.child(Input::new(&self.subject_input).small())
.child(
Button::new("change")
.icon(IconName::CircleCheck)
.icon(IconName::CheckCircle)
.label("Change")
.secondary()
.disabled(self.uploading)
@@ -1950,7 +1896,6 @@ impl Render for ChatPanel {
.loading(self.uploading)
.disabled(self.uploading)
.ghost()
.large()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.upload(window, cx);
})),
@@ -1964,10 +1909,9 @@ impl Render for ChatPanel {
.child(self.render_config_menu(window, cx))
.child(
Button::new("send")
.icon(IconName::SendHorizontal)
.icon(IconName::PaperPlaneFill)
.disabled(self.uploading)
.ghost()
.large()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.send_text_message(window, cx);
})),
+56
View File
@@ -4,6 +4,62 @@ use nostr::prelude::*;
const BECH32_SEPARATOR: u8 = b'1';
const SCHEME_WITH_COLON: &str = "nostr:";
const NEVENT_PREFIX: &str = "nevent1";
/// A `nevent` reference found in a content string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NeventRef {
/// Range of the reference, including the optional `nostr:` scheme
pub range: Range<usize>,
/// Compact display label, like `https://njump.me/nevent1...d9sp`
pub label: String,
}
/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content.
pub fn nevent_refs(content: &str) -> Vec<NeventRef> {
let mut references = Vec::new();
for (match_start, _) in content.match_indices(NEVENT_PREFIX) {
// Only lowercase references are matched, matching NIP-19 encodings in practice
let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) {
match_start - SCHEME_WITH_COLON.len()
} else {
match_start
};
// Skip matches glued to word characters, like the `nevent1` in `xnevent1...`
if content[..start].ends_with(char::is_alphanumeric) {
continue;
}
let end = match_start
+ content[match_start..]
.bytes()
.take_while(u8::is_ascii_alphanumeric)
.count();
// The bech32 token excludes the optional `nostr:` scheme
let bech32 = &content[match_start..end];
if Nip19Event::from_bech32(bech32).is_err() {
continue;
}
references.push(NeventRef {
range: start..end,
label: nevent_label(bech32),
});
}
references
}
/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`.
fn nevent_label(bech32: &str) -> String {
match bech32.len() {
0..=4 => format!("https://njump.me/{bech32}"),
_ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]),
}
}
/// Nostr parsed token with its range in the original text
#[derive(Debug, Clone, PartialEq, Eq)]
+8 -9
View File
@@ -1,8 +1,6 @@
use std::collections::BTreeMap;
use anyhow::Result;
use chat_ui::message::WelcomeMessage;
use chat_ui::text::{RenderedText, rendered_text};
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
@@ -26,7 +24,7 @@ use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload};
use ui::Avatar;
use ui::{Avatar, RenderedText, WelcomeMessage, rendered_text};
use util::{attachment_name, display_name, opens_run, pin_label, report};
mod details;
@@ -987,7 +985,10 @@ impl GroupPanel {
.or_insert_with(|| {
rendered_text(
&message.content,
&message.mentions,
message
.mentions
.iter()
.map(|mention| (mention.range.clone(), mention.public_key)),
&message.emojis,
&directory,
cx,
@@ -1065,7 +1066,6 @@ impl GroupPanel {
.loading(self.uploading)
.disabled(self.uploading)
.ghost()
.large()
.on_click(cx.listener(|this, _event, window, cx| {
this.upload(window, cx);
})),
@@ -1073,10 +1073,9 @@ impl GroupPanel {
.child(Textarea::new(&self.input).appearance(false).flex_1())
.child(
Button::new("send")
.icon(IconName::SendHorizontal)
.icon(ui::IconName::PaperPlaneFill)
.tooltip("Send")
.ghost()
.large()
.on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx);
})),
@@ -1141,7 +1140,7 @@ impl GroupPanel {
)
.child(
Button::new("cancel-reply")
.icon(IconName::X)
.icon(ui::IconName::Close)
.tooltip("Cancel reply")
.ghost()
.small()
@@ -1178,7 +1177,7 @@ impl GroupPanel {
)
.child(
Button::new(SharedString::from(format!("remove-attachment-{ix}")))
.icon(IconName::X)
.icon(ui::IconName::Close)
.tooltip("Remove")
.ghost()
.xsmall()
+34 -67
View File
@@ -1,5 +1,4 @@
use chat::Message;
use chat_ui::message::{MessageMedia, MessageRow, ReactionBar, ReplyPreview, group_reactions};
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -8,19 +7,20 @@ use gpui::{
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem};
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
use nip29::{Activity, Group, Reaction};
use nostr_sdk::prelude::{EventId, Kind};
use person::Directory;
use settings::AppSettings;
use state::NostrRegistry;
use ui::Avatar;
use ui::{
Avatar, MessageMedia, MessageRow, REACTION_EMOJIS, ReactionBar, ReplyPreview, group_reactions,
react_menu,
};
use crate::GroupPanel;
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
pub(crate) struct Actions {
pub group: WeakEntity<Group>,
pub panel: WeakEntity<GroupPanel>,
@@ -89,6 +89,22 @@ pub(crate) fn render(
let group = actions.group;
let panel = actions.panel;
// Reading the panel here would re-enter its ongoing update, so resolve
// the current user's custom emojis through the shared person directory.
let custom = nostr
.read(cx)
.current_user()
.map(|current_user| {
persons
.read(cx)
.person(&current_user, cx)
.emojis()
.iter()
.map(|(shortcode, _)| format!(":{shortcode}:"))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let row = div()
.id(SharedString::from(format!("message-menu-{}", id.to_hex())))
.w_full()
@@ -107,8 +123,20 @@ pub(crate) fn render(
.submenu("React", window, cx, {
let group = group.clone();
let panel = panel.clone();
let custom = custom.clone();
move |menu, _window, cx| react_menu(menu, group.clone(), panel.clone(), id, cx)
move |menu, _window, _cx| {
let group = group.clone();
let panel = panel.clone();
react_menu(menu, REACTION_EMOJIS, &custom, move |emoji, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
})
}
});
if !admin {
@@ -143,67 +171,6 @@ pub(crate) fn render(
row.into_any_element()
}
fn react_menu(
menu: PopupMenu,
group: WeakEntity<Group>,
panel: WeakEntity<GroupPanel>,
id: EventId,
cx: &App,
) -> PopupMenu {
let menu = REACTIONS.iter().fold(menu, |menu, emoji| {
let emoji = *emoji;
let group = group.clone();
let panel = panel.clone();
menu.item(
PopupMenuItem::new(emoji).on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
}),
)
});
let emojis = panel
.upgrade()
.and_then(|panel| {
let current_user = NostrRegistry::global(cx).read(cx).current_user()?;
Some(
panel
.read(cx)
.directory
.read(cx)
.person(&current_user, cx)
.emojis()
.to_vec(),
)
})
.unwrap_or_default();
if emojis.is_empty() {
return menu;
}
let menu = menu.separator();
emojis.into_iter().fold(menu, |menu, (shortcode, _)| {
let emoji = format!(":{shortcode}:");
let group = group.clone();
let panel = panel.clone();
menu.item(
PopupMenuItem::new(emoji.clone()).on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.clone(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
}),
)
})
}
pub(crate) fn render_activity(
ix: usize,
activity: &Activity,
+1 -1
View File
@@ -35,7 +35,7 @@ pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
};
match (current, previous) {
(Row::Message(current), Row::Message(previous)) => chat_ui::message::opens_run(
(Row::Message(current), Row::Message(previous)) => ui::opens_run(
Some((previous.author, previous.created_at.as_secs())),
(current.author, current.created_at.as_secs()),
),
+3
View File
@@ -6,9 +6,12 @@ publish.workspace = true
[dependencies]
common = { path = "../common" }
person = { path = "../person" }
gpui.workspace = true
gpui-base.workspace = true
gpui-kit.workspace = true
nostr-sdk.workspace = true
web-time = "1.1"
smallvec.workspace = true
log.workspace = true
+22
View File
@@ -9,16 +9,27 @@ pub enum IconName {
Book,
CaretDown,
CaretRight,
CheckCircle,
Close,
Compass,
Door,
Emoji,
Group,
Input,
Invite,
Link,
Lock,
Message,
PaperPlaneFill,
Plus,
PlusChat,
Profile,
Relay,
Search,
Settings,
Settings2,
Upload,
Warning,
}
impl IconNamed for IconName {
@@ -29,16 +40,27 @@ impl IconNamed for IconName {
Self::Book => "icons/book.svg",
Self::CaretDown => "icons/caret-down.svg",
Self::CaretRight => "icons/caret-right.svg",
Self::CheckCircle => "icons/check-circle.svg",
Self::Close => "icons/close.svg",
Self::Compass => "icons/compass.svg",
Self::Door => "icons/door.svg",
Self::Emoji => "icons/emoji.svg",
Self::Group => "icons/group.svg",
Self::Input => "icons/input.svg",
Self::Invite => "icons/invite.svg",
Self::Link => "icons/link.svg",
Self::Lock => "icons/lock.svg",
Self::Message => "icons/message.svg",
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
Self::Plus => "icons/plus.svg",
Self::PlusChat => "icons/plus-chat.svg",
Self::Profile => "icons/profile.svg",
Self::Relay => "icons/relay.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Upload => "icons/upload.svg",
Self::Warning => "icons/warning.svg",
}
.into()
}
+4
View File
@@ -1,7 +1,11 @@
pub use avatar::Avatar;
pub use icon::IconName;
pub use message::*;
pub use text::*;
pub use window_drag::title_bar_drag_handlers;
pub mod avatar;
pub mod icon;
pub mod message;
pub mod text;
pub mod window_drag;
@@ -1,19 +1,25 @@
use std::collections::BTreeMap;
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString, SharedUri,
StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div, img, px,
AnyElement, App, ClickEvent, Context, ElementId, InteractiveElement as _, IntoElement,
MouseButton, MouseDownEvent, ObjectFit, ParentElement as _, RenderOnce, SharedString,
SharedUri, StatefulInteractiveElement as _, StyleRefinement, Styled, StyledImage, Window, div,
img, px,
};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_kit::component::{
ActiveTheme, InteractiveElementExt as _, StyledExt as _, h_flex, v_flex,
ActiveTheme, InteractiveElementExt as _, Sizable as _, StyledExt as _, h_flex, v_flex,
};
use smallvec::SmallVec;
pub use ui::Avatar;
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
type ClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
type SharedClickListener = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
type DropdownBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>;
/// Messages from the same author within this window are grouped into one run.
pub const RUN_WINDOW_SECS: u64 = 300;
@@ -203,6 +209,7 @@ impl RenderOnce for MessageRow {
.min_w_0()
.flex_initial()
.overflow_hidden()
.text_sm()
.when(show_author, |this| {
this.child(
h_flex()
@@ -296,7 +303,7 @@ impl RenderOnce for ReplyPreview {
.refine_style(&style)
.child(div().font_semibold().child(author))
.child(div().w_full().text_ellipsis().line_clamp(1).child(preview))
.hover(|this| this.bg(cx.theme().secondary))
.hover(|this| this.bg(cx.theme().list_hover))
.when_some(on_click, |this, on_click| this.on_click(on_click))
.into_any_element()
}
@@ -553,3 +560,174 @@ impl RenderOnce for WelcomeMessage {
)
}
}
/// Quick reaction emojis offered in message menus.
pub const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
/// Quick reaction emojis shown on a hovered message's action bar.
pub const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
/// Fill a popup menu with reactions to a message, offering `emojis` then the custom ones.
///
/// `react` receives the reaction content: an emoji, or a `:shortcode:` for a custom one.
pub fn react_menu(
menu: PopupMenu,
emojis: &[&'static str],
custom: &[String],
react: impl Fn(&str, &mut Window, &mut App) + Clone + 'static,
) -> PopupMenu {
let menu = emojis.iter().fold(menu, |menu, emoji| {
let emoji = *emoji;
let react = react.clone();
menu.item(PopupMenuItem::new(emoji).on_click(move |_, window, cx| react(emoji, window, cx)))
});
if custom.is_empty() {
return menu;
}
custom.iter().fold(menu.separator(), |menu, emoji| {
let emoji = emoji.clone();
let react = react.clone();
menu.item(
PopupMenuItem::new(emoji.clone())
.on_click(move |_, window, cx| react(&emoji, window, cx)),
)
})
}
/// A hovered message's action bar: quick reactions, reply, copy, and an overflow menu.
#[derive(IntoElement)]
pub struct MessageActions {
style: StyleRefinement,
reactions: SmallVec<[(&'static str, SharedClickListener); 4]>,
reply: Option<SharedClickListener>,
copy: Option<SharedClickListener>,
dropdown: Option<DropdownBuilder>,
}
impl MessageActions {
/// Create an empty action bar.
pub fn new() -> Self {
Self {
style: StyleRefinement::default(),
reactions: SmallVec::new(),
reply: None,
copy: None,
dropdown: None,
}
}
/// Add a quick reaction button.
pub fn reaction(
mut self,
emoji: &'static str,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.reactions.push((emoji, Rc::new(handler)));
self
}
/// Add the reply button.
pub fn reply(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.reply = Some(Rc::new(handler));
self
}
/// Add the copy button.
pub fn copy(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.copy = Some(Rc::new(handler));
self
}
/// Fill the bar's overflow menu.
pub fn dropdown(
mut self,
dropdown: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
self.dropdown = Some(Rc::new(dropdown));
self
}
}
impl Default for MessageActions {
fn default() -> Self {
Self::new()
}
}
impl Styled for MessageActions {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for MessageActions {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
style,
reactions,
reply,
copy,
dropdown,
} = self;
let has_center = reply.is_some() || copy.is_some();
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_lg)
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.children(reactions.into_iter().map(|(emoji, handler)| {
Button::new(emoji)
.label(emoji)
.tooltip(emoji)
.small()
.ghost()
.on_click(move |event, window, cx| handler(event, window, cx))
}))
.when(has_center, |this| {
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
})
.when_some(reply, |this, reply| {
this.child(
Button::new("reply")
.icon(IconName::Reply)
.tooltip("Reply")
.small()
.ghost()
.on_click(move |event, window, cx| reply(event, window, cx)),
)
})
.when_some(copy, |this, copy| {
this.child(
Button::new("copy")
.icon(IconName::Copy)
.tooltip("Copy")
.small()
.ghost()
.on_click(move |event, window, cx| copy(event, window, cx)),
)
})
.when_some(dropdown, |this, dropdown| {
this.child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border))
.child(
Button::new("more")
.icon(IconName::Ellipsis)
.small()
.ghost()
.dropdown_menu(move |menu, window, cx| dropdown(menu, window, cx)),
)
})
.group_hover("", |this| this.visible())
.refine_style(&style)
}
}
@@ -1,9 +1,9 @@
use std::ops::Range;
use chat::{Mention, nevent_refs};
use common::nevent_refs;
use gpui::{AnyElement, App, ElementId, Entity, IntoElement as _, SharedString, SharedUri, Window};
use gpui_kit::component::text::TextView;
use nostr_sdk::prelude::Url;
use nostr_sdk::prelude::{PublicKey, Url};
use person::Directory;
/// A span of the source content, by byte range, that renders as `text` instead.
@@ -121,22 +121,22 @@ fn nevent_bech32(url: &str) -> Option<&str> {
bech32.starts_with("nevent1").then_some(bech32)
}
/// Composes a message's rendered text: mention names, NIP-30 emojis, and nevent links.
///
/// `mentions` are the `(byte range, mentioned public key)` pairs found in `content`.
pub fn rendered_text(
content: &str,
mentions: &[Mention],
mentions: impl IntoIterator<Item = (Range<usize>, PublicKey)>,
emojis: &[(String, Url)],
persons: &Entity<Directory>,
cx: &App,
) -> RenderedText {
let mut replacements = mentions
.iter()
.map(|mention| {
.into_iter()
.map(|(range, public_key)| {
InlineReplacement::new(
mention.range.clone(),
format!(
"@{}",
persons.read(cx).person(&mention.public_key, cx).name()
),
range,
format!("@{}", persons.read(cx).person(&public_key, cx).name()),
)
})
.collect::<Vec<_>>();
+3 -3
View File
@@ -4,7 +4,7 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement,
ParentElement, SharedString, Styled, WeakEntity, div, relative, uniform_list,
ParentElement, SharedString, Styled, WeakEntity, div, px, relative, uniform_list,
};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
@@ -161,13 +161,13 @@ impl Sidebar {
div()
.text_xs()
.truncate()
.line_height(relative(1.2))
.line_height(relative(1.125))
.font_semibold()
.child(name),
)
.child(
div()
.text_xs()
.text_size(px(8.0))
.font_medium()
.text_color(cx.theme().muted_foreground)
.child(total_members),