3 Commits
Author SHA1 Message Date
reya 9a7afbe3e2 wip 2026-11-11 18:46:45 +07:00
reya 2f9559228f wip 2026-11-11 18:00:18 +07:00
reya 7e89f63a7c nip4e: initialize encryption from local database 2026-11-11 15:48:20 +07:00
54 changed files with 1384 additions and 1126 deletions
Generated
+1
View File
@@ -1463,6 +1463,7 @@ dependencies = [
"gpui-pre", "gpui-pre",
"instant", "instant",
"itertools 0.13.0", "itertools 0.13.0",
"log",
"nip60_ui", "nip60_ui",
"nostr-sdk", "nostr-sdk",
"person", "person",
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75C6.89137 2.75 2.75 6.89137 2.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.25 13L12 16.25L8.75 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 7.75V15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 571 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M5.75 9.5L12 15.75L18.25 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 209 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9.5 18.25L15.75 12L9.5 5.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 209 B

-3
View File
@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 341 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 21.25L21.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.75 21.25V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.25H8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 522 B

-3
View File
@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

+16
View File
@@ -0,0 +1,16 @@
<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="M15 10V9" />
<path d="M7.084 14.302a5.12 5.12 0 009.833 0 .24.24 0 00-.235-.302H7.32a.24.24 0 00-.235.302" />
<path d="M9 10V9" />
<circle cx="12" cy="12" r="10" />
</svg>

After

Width:  |  Height:  |  Size: 390 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8.75" r="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="4" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="20" cy="9.80005" r="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.25 16.625V16.5C7.25 13.8766 9.37665 11.75 12 11.75C14.6234 11.75 16.75 13.8766 16.75 16.5V16.625C16.75 17.5225 16.0225 18.25 15.125 18.25H8.875C7.97754 18.25 7.25 17.5225 7.25 16.625Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.25 17.2602H2.75C1.64543 17.2602 0.706551 16.3538 0.919944 15.2701C1.25877 13.5493 2.15049 12.3257 4 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.75 17.2601H21.25C22.3546 17.2601 23.2935 16.3538 23.08 15.27C22.7412 13.5493 21.8495 12.3257 20 11.8301" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-3
View File
@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 604 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M4.75 10.9853V4.75C4.75 3.64543 5.64543 2.75 6.75 2.75H17.25C18.3546 2.75 19.25 3.64543 19.25 4.75V10.9853M9.75 7.75H14.25M12.617 13.5499L19.9415 11.1744C20.5875 10.9649 21.25 11.4465 21.25 12.1256V18.25C21.25 19.3546 20.3546 20.25 19.25 20.25H4.75C3.64543 20.25 2.75 19.3546 2.75 18.25V12.1256C2.75 11.4465 3.41249 10.9649 4.0585 11.1744L11.383 13.5499C11.784 13.68 12.216 13.68 12.617 13.5499Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 576 B

+14
View File
@@ -0,0 +1,14 @@
<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 9.9-1" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 520 B

-3
View File
@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 435 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/><path d="M12 8.75V15.25M8.75 12H15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>

Before

Width:  |  Height:  |  Size: 624 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M17.75 2.75H6.25C5.14543 2.75 4.25 3.64543 4.25 4.75V19.25C4.25 20.3546 5.14543 21.25 6.25 21.25H17.75C18.8546 21.25 19.75 20.3546 19.75 19.25V4.75C19.75 3.64543 18.8546 2.75 17.75 2.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="12.25" r="2.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M16 21C16 18.7909 14.2091 17 12 17C9.79086 17 8 18.7909 8 21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M9.75 6.25H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 696 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="9.25" r="1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 21.25L11.75 9.25H12.25L16.25 21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.5 17.75H14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75693 12.7501C6.08102 10.7234 6.08103 7.77679 7.75693 5.75M16.2431 5.75C17.919 7.77679 17.919 10.7234 16.2431 12.7501" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.06494 2.7574C1.64285 6.40823 1.64502 12.1018 5.07145 15.75M18.9281 2.75C22.3572 6.40053 22.3573 12.0993 18.9285 15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 899 B

+16
View File
@@ -0,0 +1,16 @@
<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="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" x2="19" y1="8" y2="14" />
<line x1="22" x2="16" y1="11" y2="11" />
</svg>

After

Width:  |  Height:  |  Size: 383 B

+2 -2
View File
@@ -155,7 +155,7 @@ impl AutoUpdater {
pub fn status(&self) -> SharedString { pub fn status(&self) -> SharedString {
match &self.status { match &self.status {
UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(), UpdateStatus::Idle | UpdateStatus::UpToDate => "Up to date".into(),
UpdateStatus::Checking => "Checking for updates…".into(), UpdateStatus::Checking => "Checking for updates".into(),
UpdateStatus::Available(version) => format!("Version {version} available").into(), UpdateStatus::Available(version) => format!("Version {version} available").into(),
UpdateStatus::Downloading { downloaded, total } => { UpdateStatus::Downloading { downloaded, total } => {
let total_mb = total.map(|t| t as f64 / 1_048_576.0); let total_mb = total.map(|t| t as f64 / 1_048_576.0);
@@ -165,7 +165,7 @@ impl AutoUpdater {
None => format!("Downloading {downloaded_mb:.1} MB").into(), None => format!("Downloading {downloaded_mb:.1} MB").into(),
} }
} }
UpdateStatus::Installing => "Installing update…".into(), UpdateStatus::Installing => "Installing update".into(),
UpdateStatus::Staged(version) => { UpdateStatus::Staged(version) => {
format!("Version {version} ready — restart to apply").into() format!("Version {version} ready — restart to apply").into()
} }
+1 -1
View File
@@ -172,7 +172,7 @@
<div id="nip07-status" class="status status--checking"> <div id="nip07-status" class="status status--checking">
<div class="status__dot"></div> <div class="status__dot"></div>
<span id="nip07-status-text">Checking extension…</span> <span id="nip07-status-text">Checking extension</span>
</div> </div>
<div class="hint"> <div class="hint">
+1
View File
@@ -10,6 +10,7 @@ speech = { path = "../speech" }
ui = { path = "../ui" } ui = { path = "../ui" }
gpui-kit = { workspace = true, features = ["speech"] } gpui-kit = { workspace = true, features = ["speech"] }
common = { path = "../common" } common = { path = "../common" }
log.workspace = true
person = { path = "../person" } person = { path = "../person" }
chat = { path = "../chat" } chat = { path = "../chat" }
settings = { path = "../settings" } settings = { path = "../settings" }
+159 -102
View File
@@ -12,13 +12,14 @@ use gpui::{
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled, ObjectFit, ParentElement, Render, SharedString, SharedUri, StatefulInteractiveElement, Styled,
StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity, StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, WeakEntity,
Window, div, img, list, px, red, relative, retain_all, svg, white, Window, div, img, list, px, relative, rems, retain_all, svg,
}; };
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{Panel, PanelEvent}; use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_kit::component::menu::DropdownMenu; use gpui_kit::component::menu::{ContextMenuExt as _, DropdownMenu, PopupMenuItem};
use gpui_kit::component::notification::Notification; use gpui_kit::component::notification::Notification;
use gpui_kit::component::scroll::Scrollbar; use gpui_kit::component::scroll::Scrollbar;
use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform}; use gpui_kit::component::speech::{SpeechButton, SpeechEvent, SpeechState, SpeechWaveform};
@@ -35,9 +36,9 @@ use state::{
FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file, FileAttachment, LocalFile, NostrRegistry, PickedFile, download_and_decrypt_file, pick_file,
}; };
use ui::{ use ui::{
Avatar, COMPACT_REACTION_EMOJIS, IconName, MessageActions, MessageMedia, MessageRow, Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS,
REACTION_EMOJIS, ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage, ReactionBar, RenderedText, ReplyPreview, RevealText, WelcomeMessage, group_reactions,
group_reactions, opens_run, rendered_text, opens_run, react_menu, rendered_text,
}; };
use crate::file::*; use crate::file::*;
@@ -674,6 +675,36 @@ impl ChatPanel {
} }
} }
/// Read a message aloud, tracking its reveal state until utterance end.
fn speak_message(&mut self, id: &EventId, window: &mut Window, cx: &mut Context<Self>) {
let Some(text) = self.message(id).map(|message| message.preview()) else {
return;
};
match self.speaker.speak(&text) {
Ok(duration) => {
self.speech_reveal = Some((*id, duration));
// Re-enable speaking when no end event ever arrives
let session = (*id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
self.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => window.push_notification(Notification::error(error.to_string()), cx),
}
}
fn remove_reply(&mut self, id: &EventId, cx: &mut Context<Self>) { fn remove_reply(&mut self, id: &EventId, cx: &mut Context<Self>) {
self.replies_to.update(cx, |this, cx| { self.replies_to.update(cx, |this, cx| {
this.remove(id); this.remove(id);
@@ -1026,7 +1057,9 @@ impl ChatPanel {
.bg(cx.theme().secondary) .bg(cx.theme().secondary)
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.text_xs() .text_xs()
.child(div().size_1p5().rounded_full().bg(gpui::green())) .child(
div().size_1p5().rounded_full().bg(cx.theme().success),
)
.child(SharedString::from(url.to_string())), .child(SharedString::from(url.to_string())),
); );
} }
@@ -1044,7 +1077,7 @@ impl ChatPanel {
window.open_dialog(cx, move |this, _window, cx| { window.open_dialog(cx, move |this, _window, cx| {
let relays = profile.messaging_relays(); let relays = profile.messaging_relays();
this.title("Messaging Relays") this.title("Messaging relays")
.close_button(true) .close_button(true)
.child(v_flex().gap_1().children({ .child(v_flex().gap_1().children({
let mut items = vec![]; let mut items = vec![];
@@ -1058,7 +1091,7 @@ impl ChatPanel {
.bg(cx.theme().secondary) .bg(cx.theme().secondary)
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.text_xs() .text_xs()
.child(div().size_1p5().rounded_full().bg(gpui::green())) .child(div().size_1p5().rounded_full().bg(cx.theme().success))
.child(SharedString::from(url.to_string())), .child(SharedString::from(url.to_string())),
); );
} }
@@ -1101,7 +1134,7 @@ impl ChatPanel {
svg() svg()
.path("brand/coop.svg") .path("brand/coop.svg")
.size_12() .size_12()
.text_color(cx.theme().list_active), .text_color(cx.theme().accent),
) )
.child(PRIVATE_NOTICE) .child(PRIVATE_NOTICE)
.into_any_element() .into_any_element()
@@ -1126,7 +1159,7 @@ impl ChatPanel {
.rounded_full() .rounded_full()
.bg(cx.theme().warning.opacity(0.15)) .bg(cx.theme().warning.opacity(0.15))
.text_color(cx.theme().warning) .text_color(cx.theme().warning)
.child(Icon::new(IconName::Warning).small()), .child(Icon::new(IconName::TriangleAlert).small()),
) )
.child( .child(
div() div()
@@ -1195,7 +1228,11 @@ impl ChatPanel {
cx, cx,
) )
}) })
.element(ix.into(), window, cx); .element(
SharedString::from(format!("text-{}", message.id.to_hex())).into(),
window,
cx,
);
self.render_text_message(ix, message, text, show_author, cx) self.render_text_message(ix, message, text, show_author, cx)
} else { } else {
@@ -1205,7 +1242,7 @@ impl ChatPanel {
fn render_text_message( fn render_text_message(
&self, &self,
ix: usize, _ix: usize,
message: &Message, message: &Message,
rendered_text: AnyElement, rendered_text: AnyElement,
show_author: bool, show_author: bool,
@@ -1237,7 +1274,7 @@ impl ChatPanel {
None => rendered_text, None => rendered_text,
}; };
MessageRow::new(ix) let row = MessageRow::new(SharedString::from(format!("message-{}", id.to_hex())))
.show_author(show_author) .show_author(show_author)
.hide_avatar(hide_avatar) .hide_avatar(hide_avatar)
.avatar( .avatar(
@@ -1267,16 +1304,6 @@ impl ChatPanel {
this.child(self.render_message_file(&id, file, cx)) this.child(self.render_message_file(&id, file, cx))
}) })
.when(has_reactions, |this| this.child(reactions)) .when(has_reactions, |this| this.child(reactions))
.overlay(
div()
.group_hover("", |this| this.bg(cx.theme().list_active))
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(gpui::transparent_black()),
)
.overlay(self.render_actions(&id, &pk, cx)) .overlay(self.render_actions(&id, &pk, cx))
.on_mouse_down( .on_mouse_down(
MouseButton::Middle, MouseButton::Middle,
@@ -1286,7 +1313,76 @@ impl ChatPanel {
) )
.on_double_click(cx.listener(move |this, _, _window, cx| { .on_double_click(cx.listener(move |this, _, _window, cx| {
this.reply_to(&id, cx); this.reply_to(&id, cx);
})) }));
let panel = cx.weak_entity();
let speaking = self.speech_reveal.is_some();
div()
.id(SharedString::from(format!("message-menu-{}", id.to_hex())))
.w_full()
.child(row)
.context_menu(move |menu, window, cx| {
let menu = menu.item({
let panel = panel.clone();
let id = id;
PopupMenuItem::new("Reply").on_click(move |_, _window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| {
panel.reply_to(&id, cx);
}) {
log::warn!("starting a reply failed: {error}");
}
})
});
let menu = menu.submenu("React", window, cx, {
let panel = panel.clone();
move |menu, _window, _cx| {
let panel = panel.clone();
react_menu(menu, REACTION_EMOJIS, &[], move |emoji, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.send_reaction(emoji, &id, window, cx);
}) {
log::warn!("sending a reaction failed: {error}");
}
})
}
});
menu.item({
let panel = panel.clone();
let id = id;
PopupMenuItem::new("Copy message").on_click(move |_, _window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| {
panel.copy_message(&id, cx);
}) {
log::warn!("copying a message failed: {error}");
}
})
})
.item({
let panel = panel.clone();
let id = id;
let speaking = speaking;
PopupMenuItem::new("Read aloud")
.disabled(speaking)
.on_click(move |_, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.speak_message(&id, window, cx);
}) {
log::warn!("reading the message aloud failed: {error}");
}
})
})
.separator()
.menu("Copy author", Box::new(Command::Copy(pk)))
.menu("Seen on", Box::new(Command::Trace(id)))
})
.into_any_element() .into_any_element()
} }
@@ -1297,14 +1393,19 @@ impl ChatPanel {
) -> impl IntoIterator<Item = impl IntoElement> { ) -> impl IntoIterator<Item = impl IntoElement> {
let mut items = Vec::with_capacity(replies.len()); let mut items = Vec::with_capacity(replies.len());
for (ix, id) in replies.iter().enumerate() { for id in replies.iter() {
let Some(message) = self.message(id) else { let Some(message) = self.message(id) else {
continue; continue;
}; };
let author = self.profile(&message.author, cx); let author = self.profile(&message.author, cx);
items.push( items.push(
ReplyPreview::new(ix, author.name(), message.preview()).on_click({ ReplyPreview::new(
SharedString::from(format!("reply-{}", id.to_hex())),
author.name(),
message.preview(),
)
.on_click({
let id = *id; let id = *id;
cx.listener(move |this, _event, _window, _cx| { cx.listener(move |this, _event, _window, _cx| {
this.scroll_to(&id); this.scroll_to(&id);
@@ -1335,7 +1436,7 @@ impl ChatPanel {
} else if failed { } else if failed {
SharedString::from("• Error") SharedString::from("• Error")
} else if pending { } else if pending {
SharedString::from("• Sending...") SharedString::from("• Sending")
} else { } else {
SharedString::from("• Unknown") SharedString::from("• Unknown")
}; };
@@ -1360,7 +1461,7 @@ impl ChatPanel {
}) })
.unwrap_or_default(); .unwrap_or_default();
this.title(SharedString::from("Sent Reports")) this.title(SharedString::from("Sent reports"))
.close_button(true) .close_button(true)
.child(v_flex().gap_4().children(items)) .child(v_flex().gap_4().children(items))
}); });
@@ -1515,34 +1616,7 @@ impl ChatPanel {
let actions = actions.speech( let actions = actions.speech(
speaking, speaking,
cx.listener(move |this, _event, window, cx| { cx.listener(move |this, _event, window, cx| {
let text = this.message(&speech_id).map(|message| message.preview()); this.speak_message(&speech_id, window, cx);
if let Some(text) = text {
match this.speaker.speak(&text) {
Ok(duration) => {
this.speech_reveal = Some((speech_id, duration));
// Re-enable speaking when no end event ever arrives
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
this.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
}
}), }),
); );
@@ -1574,8 +1648,12 @@ impl ChatPanel {
.items_center() .items_center()
.justify_center() .justify_center()
.rounded_full() .rounded_full()
.bg(red()) .bg(cx.theme().danger)
.child(Icon::new(IconName::Close).size_2().text_color(white())), .child(
Icon::new(IconName::Close)
.size_2()
.text_color(cx.theme().danger_foreground),
),
) )
.on_click({ .on_click({
let url = url.clone(); let url = url.clone();
@@ -1620,7 +1698,7 @@ impl ChatPanel {
img(local.image_source()) img(local.image_source())
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.h(px(250.)) .h(rems(16.))
.object_fit(ObjectFit::Cover) .object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius_lg), .rounded(cx.theme().radius_lg),
) )
@@ -1655,7 +1733,7 @@ impl ChatPanel {
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.child(Icon::new(IconName::Lock).text_color(cx.theme().primary)) .child(Icon::new(IconName::Lock).text_color(cx.theme().accent))
.child( .child(
v_flex() v_flex()
.min_w_0() .min_w_0()
@@ -1727,8 +1805,12 @@ impl ChatPanel {
.rounded_full() .rounded_full()
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.bg(gpui::green()) .bg(cx.theme().success)
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())), .child(
Icon::new(IconName::Lock)
.size_2()
.text_color(cx.theme().success_foreground),
),
) )
.on_click({ .on_click({
let url = file.url.clone(); let url = file.url.clone();
@@ -1756,47 +1838,20 @@ impl ChatPanel {
if let Some(text) = self.message(id) { if let Some(text) = self.message(id) {
let profile = self.directory.read(cx).person(&text.author, cx); let profile = self.directory.read(cx).person(&text.author, cx);
div() ReplyPreview::new(
.w_full() SharedString::from(format!("reply-{}", id.to_hex())),
.pl_2() profile.name(),
.border_l_2() text.preview(),
.border_color(cx.theme().list_active)
.child(
h_flex()
.justify_between()
.child(
div()
.flex()
.items_baseline()
.gap_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Replying to:")
.child(div().text_color(cx.theme().primary).child(profile.name())),
) )
.child( .on_remove({
Button::new("remove-reply")
.icon(IconName::Close)
.xsmall()
.ghost()
.on_click({
let id = text.id; let id = text.id;
cx.listener(move |this, _, _, cx| { cx.listener(move |this, _, _, cx| {
this.remove_reply(&id, cx); this.remove_reply(&id, cx);
}) })
}), })
), .into_any_element()
)
.child(
div()
.w_full()
.text_xs()
.text_ellipsis()
.line_clamp(1)
.child(text.preview()),
)
} else { } else {
div() div().into_any_element()
} }
} }
@@ -1883,7 +1938,8 @@ impl ChatPanel {
.unwrap_or_default(); .unwrap_or_default();
Button::new("emoji") Button::new("emoji")
.icon(IconName::Emoji) .icon(IconName::FaceGrinning)
.tooltip("Emoji")
.ghost() .ghost()
.disabled(disabled) .disabled(disabled)
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| { .dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
@@ -1931,7 +1987,7 @@ impl Panel for ChatPanel {
Some(vec![ Some(vec![
Button::new("subject") Button::new("subject")
.icon(IconName::Input) .icon(IconName::TextCursorInput)
.tooltip("Change subject") .tooltip("Change subject")
.small() .small()
.ghost() .ghost()
@@ -1978,7 +2034,7 @@ impl Render for ChatPanel {
.child(Input::new(&self.subject_input)) .child(Input::new(&self.subject_input))
.child( .child(
Button::new("change") Button::new("change")
.icon(IconName::CheckCircle) .icon(IconName::CircleCheck)
.label("Change") .label("Change")
.secondary() .secondary()
.disabled(self.uploading) .disabled(self.uploading)
@@ -2093,9 +2149,10 @@ impl Render for ChatPanel {
.child(self.render_config_menu(dictating, window, cx)) .child(self.render_config_menu(dictating, window, cx))
.child( .child(
Button::new("send") Button::new("send")
.icon(IconName::PaperPlaneFill) .icon(IconName::SendHorizontal)
.tooltip("Send")
.disabled(dictating || self.uploading) .disabled(dictating || self.uploading)
.ghost() .primary()
.on_click(cx.listener(move |this, _ev, window, cx| { .on_click(cx.listener(move |this, _ev, window, cx| {
this.send_text_message(window, cx); this.send_text_message(window, cx);
})), })),
+58 -68
View File
@@ -1,17 +1,19 @@
use gpui::{ use gpui::{
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px, AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div,
relative, rems,
}; };
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::description_list::{DescriptionItem, DescriptionList};
use gpui_kit::component::tag::Tag;
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
use nip29::{Group, GroupMetadata}; use nip29::{Group, GroupMetadata};
use ui::Avatar; use ui::Avatar;
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) { pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |this, _window, cx| { window.open_dialog(cx, move |this, _window, cx| {
this.max_h(px(600.)) this.max_h(rems(37.5))
.close_button(true) .close_button(true)
.title("About Group") .title("About group")
.child(body(&group, cx)) .child(body(&group, cx))
}); });
} }
@@ -23,6 +25,7 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
let group = entity.read(cx); let group = entity.read(cx);
let mut body = v_flex().gap_3().text_xs().child(header(group, cx)); let mut body = v_flex().gap_3().text_xs().child(header(group, cx));
let mut items = Vec::new();
if let Some(metadata) = group.metadata() { if let Some(metadata) = group.metadata() {
if let Some(about) = metadata.about.as_ref() { if let Some(about) = metadata.about.as_ref() {
@@ -36,70 +39,75 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
let flags = flags(metadata); let flags = flags(metadata);
if !flags.is_empty() { if !flags.is_empty() {
body = body.child(section( items.push(
"Flags", DescriptionItem::new("Flags").value(wrap(
wrap(flags.into_iter().map(|f| chip(f, cx))), flags
cx, .into_iter()
)); .map(|flag| Tag::secondary().child(flag).into_any_element()),
)),
);
} }
if let Some(parent) = metadata.parent.as_ref() { if let Some(parent) = metadata.parent.as_ref() {
body = body.child(section( items.push(
"Parent", DescriptionItem::new("Parent").value(
div() div()
.child(SharedString::from(parent.as_str().to_owned())) .child(SharedString::from(parent.as_str().to_owned()))
.into_any_element(), .into_any_element(),
cx, ),
)); );
} }
if !metadata.children.is_empty() { if !metadata.children.is_empty() {
body = body.child(section( items.push(
"Children", DescriptionItem::new("Children").value(wrap(metadata.children.iter().map(
wrap( |child| {
metadata Tag::secondary()
.children .child(SharedString::from(child.as_str().to_owned()))
.iter() .into_any_element()
.map(|child| chip(child.as_str().to_owned(), cx)), },
), ))),
cx, );
));
} }
if let Some(kinds) = metadata.supported_kinds.as_ref() { if let Some(kinds) = metadata.supported_kinds.as_ref() {
let items: Vec<AnyElement> = kinds let values: Vec<AnyElement> = kinds
.iter() .iter()
.map(|kind| chip(kind.to_string(), cx)) .map(|kind| {
Tag::secondary()
.child(SharedString::from(kind.to_string()))
.into_any_element()
})
.collect(); .collect();
body = body.child(section( items.push(
"Supported kinds", DescriptionItem::new("Supported kinds").value(if values.is_empty() {
if items.is_empty() {
div() div()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child("None") .child("None")
.into_any_element() .into_any_element()
} else { } else {
wrap(items) wrap(values)
}, }),
cx, );
));
} }
} }
let roles = group.roles().roles(); let roles = group.roles().roles();
if !roles.is_empty() { if !roles.is_empty() {
body = body.child(section( items.push(
"Roles", DescriptionItem::new("Roles").value(wrap(roles.iter().map(|role| {
wrap(roles.iter().map(|role| chip(role.name.clone(), cx))), Tag::secondary()
cx, .child(SharedString::from(role.name.clone()))
)); .into_any_element()
}))),
);
} }
let pins = group.pins().pins(); let pins = group.pins().pins();
if !pins.is_empty() { if !pins.is_empty() {
body = body.child(section( items.push(
"Pinned", DescriptionItem::new("Pinned").value(
v_flex() v_flex()
.gap_1() .gap_1()
.children( .children(
@@ -114,8 +122,17 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.into_any_element(), .into_any_element(),
cx, ),
)); );
}
if !items.is_empty() {
body = body.child(
DescriptionList::vertical()
.bordered(false)
.columns(1)
.children(items),
);
} }
body.into_any_element() body.into_any_element()
@@ -157,20 +174,6 @@ fn header(group: &Group, cx: &App) -> AnyElement {
.into_any_element() .into_any_element()
} }
fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_medium()
.text_color(cx.theme().muted_foreground)
.child(label),
)
.child(content)
.into_any_element()
}
fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement { fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
h_flex() h_flex()
.flex_wrap() .flex_wrap()
@@ -179,19 +182,6 @@ fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
.into_any_element() .into_any_element()
} }
fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
div()
.px_2()
.py_1()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary)
.text_color(cx.theme().foreground)
.text_xs()
.line_height(relative(1.))
.child(label.into())
.into_any_element()
}
fn flags(metadata: &GroupMetadata) -> Vec<&'static str> { fn flags(metadata: &GroupMetadata) -> Vec<&'static str> {
let mut flags = Vec::new(); let mut flags = Vec::new();
+37 -3
View File
@@ -3,9 +3,10 @@ use common::TimestampExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled, App, AppContext, ClipboardItem, Entity, IntoElement, ParentElement, SharedString, Styled,
WeakEntity, Window, div, px, WeakEntity, Window, div, px, rems,
}; };
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::input::InputState; use gpui_kit::component::input::InputState;
use gpui_kit::component::notification::Notification; use gpui_kit::component::notification::Notification;
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex};
@@ -24,7 +25,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
}); });
window.open_dialog(cx, move |this, _window, cx| { window.open_dialog(cx, move |this, _window, cx| {
this.max_h(px(600.)) this.max_h(rems(37.5))
.close_button(true) .close_button(true)
.title("Invites") .title("Invites")
.child(body(group.clone(), input.clone(), cx)) .child(body(group.clone(), input.clone(), cx))
@@ -126,17 +127,50 @@ fn copy_button(group: &WeakEntity<Group>, code: &str) -> Button {
fn revoke_button(group: &WeakEntity<Group>, id: nostr_sdk::prelude::EventId, code: &str) -> Button { fn revoke_button(group: &WeakEntity<Group>, id: nostr_sdk::prelude::EventId, code: &str) -> Button {
let group = group.clone(); let group = group.clone();
let code = code.to_owned();
Button::new(SharedString::from(format!("invite-revoke-{code}"))) Button::new(SharedString::from(format!("invite-revoke-{code}")))
.label("Revoke") .label("Revoke")
.ghost() .ghost()
.small() .small()
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| {
confirm_revoke(group.clone(), id, code.clone(), window, cx);
})
}
/// Confirm revoking an invite, then delete it and surface any relay error.
fn confirm_revoke(
group: WeakEntity<Group>,
id: nostr_sdk::prelude::EventId,
code: String,
window: &mut Window,
cx: &mut App,
) {
window.open_alert_dialog(cx, move |this, _window, _cx| {
let group = group.clone();
this.confirm()
.width(px(360.))
.title(format!("Revoke invite {code}?"))
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Revoke")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_xs()
.child("Nobody will be able to ask to join with this code."),
)
.on_ok(move |_event, window, cx| {
match group.update(cx, |group, cx| group.delete_event(id, cx)) { match group.update(cx, |group, cx| group.delete_event(id, cx)) {
Ok(task) => report(window, cx, task), Ok(task) => report(window, cx, task),
Err(error) => log::warn!("nip29: revoking an invite failed: {error}"), Err(error) => log::warn!("nip29: revoking an invite failed: {error}"),
} }
true
}) })
});
} }
fn create_row(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement { fn create_row(group: WeakEntity<Group>, input: Entity<InputState>, cx: &App) -> impl IntoElement {
+122 -35
View File
@@ -8,7 +8,7 @@ use gpui::{
Action, Anchor, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, Action, Anchor, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
FocusHandle, Focusable, FollowMode, InteractiveElement, IntoElement, ListAlignment, FocusHandle, Focusable, FollowMode, InteractiveElement, IntoElement, ListAlignment,
ListScrollEvent, ListState, ParentElement, Render, SharedString, Styled, Subscription, Task, ListScrollEvent, ListState, ParentElement, Render, SharedString, Styled, Subscription, Task,
WeakEntity, Window, div, list, px, WeakEntity, Window, div, list, px, rems,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
@@ -27,7 +27,7 @@ use person::Directory;
use serde::Deserialize; use serde::Deserialize;
use settings::AppSettings; use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use speech::speaker::Speaker; use speech::speaker::{END_GRACE, Speaker};
use state::{NostrRegistry, pick_file, upload_file}; use state::{NostrRegistry, pick_file, upload_file};
use ui::{Avatar, REACTION_EMOJIS, RenderedText, RevealText, WelcomeMessage, rendered_text}; use ui::{Avatar, REACTION_EMOJIS, RenderedText, RevealText, WelcomeMessage, rendered_text};
use util::{attachment_name, display_name, opens_run, pin_label, report}; use util::{attachment_name, display_name, opens_run, pin_label, report};
@@ -167,7 +167,7 @@ pub fn actions(group: WeakEntity<Group>, cx: &App) -> AnyElement {
) )
.child( .child(
Button::new("group-delete") Button::new("group-delete")
.icon(IconName::CircleX) .icon(IconName::Trash)
.tooltip("Delete group") .tooltip("Delete group")
.ghost() .ghost()
.small() .small()
@@ -261,17 +261,43 @@ pub fn confirm_delete(group: WeakEntity<Group>, window: &mut Window, cx: &mut Ap
}); });
} }
/// Remove a member from the group and surface any relay error as a notification. /// Confirm removing a member, then remove them and surface any relay error.
pub fn remove_member( pub fn confirm_remove_member(
group: WeakEntity<Group>, group: WeakEntity<Group>,
public_key: PublicKey, public_key: PublicKey,
member: SharedString,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) { ) {
let Some(group_name) = group.read_with(cx, |group, _cx| group.display_name()).ok() else {
return;
};
window.open_alert_dialog(cx, move |this, _window, _cx| {
let group = group.clone();
this.confirm()
.width(px(360.))
.title(format!("Remove {member} from {group_name}?"))
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Remove")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_xs()
.child("The relay will remove them from this group."),
)
.on_ok(move |_event, window, cx| {
match group.update(cx, |group, cx| group.remove_user(public_key, cx)) { match group.update(cx, |group, cx| group.remove_user(public_key, cx)) {
Ok(task) => report(window, cx, task), Ok(task) => report(window, cx, task),
Err(error) => log::warn!("nip29: removing a group member failed: {error}"), Err(error) => log::warn!("nip29: removing a group member failed: {error}"),
} }
true
})
});
} }
/// Grant a member the admin role and surface any relay error as a notification. /// Grant a member the admin role and surface any relay error as a notification.
@@ -559,6 +585,39 @@ impl GroupPanel {
} }
} }
/// Read a message aloud, tracking its reveal state until utterance end.
fn speak(
&mut self,
id: EventId,
text: SharedString,
window: &mut Window,
cx: &mut Context<Self>,
) {
match self.speaker.speak(&text) {
Ok(duration) => {
self.speech_reveal = Some((id, duration));
let session = (id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
self.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => window.push_notification(Notification::error(error.to_string()), cx),
}
}
fn start_reply(&mut self, id: EventId, window: &mut Window, cx: &mut Context<Self>) { fn start_reply(&mut self, id: EventId, window: &mut Window, cx: &mut Context<Self>) {
self.reply_to = Some(id); self.reply_to = Some(id);
self.input.update(cx, |input, cx| { self.input.update(cx, |input, cx| {
@@ -716,18 +775,46 @@ impl GroupPanel {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx)) else { window.open_alert_dialog(cx, move |this, _window, _cx| {
return; let group = group.clone();
this.confirm()
.width(px(360.))
.title("Delete message?")
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Delete")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_xs()
.child("The relay will delete this message for everyone."),
)
.on_ok(move |_event, window, cx| {
let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx))
else {
return true;
}; };
self.tasks.push(cx.spawn_in(window, async move |this, cx| { window
.spawn(cx, async move |cx| -> Result<()> {
if let Err(error) = delete.await { if let Err(error) = delete.await {
this.update_in(cx, |_this, window, cx| { cx.update(|window, cx| {
window.push_notification(Notification::error(error.to_string()), cx); window.push_notification(
Notification::error(error.to_string()),
cx,
);
})?; })?;
} }
Ok(()) Ok(())
})); })
.detach();
true
})
});
} }
fn toggle_pin( fn toggle_pin(
@@ -878,16 +965,11 @@ impl GroupPanel {
.py_1p5() .py_1p5()
.border_b_1() .border_b_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.children(pins.iter().map(|pin| { .children(
div() pins.iter().map(|pin| {
.px_1p5() gpui_kit::component::tag::Tag::secondary().child(pin_label(group, pin))
.py_0p5() }),
.rounded(cx.theme().radius) )
.bg(cx.theme().primary)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(pin_label(group, pin))
}))
.into_any_element() .into_any_element()
} }
@@ -938,7 +1020,7 @@ impl GroupPanel {
.child( .child(
Button::new("load-older") Button::new("load-older")
.label(if loading { .label(if loading {
"Loading earlier messages…" "Loading earlier messages"
} else { } else {
"Load earlier messages" "Load earlier messages"
}) })
@@ -996,7 +1078,7 @@ impl GroupPanel {
row.child("You're in preview mode. Join this group to talk.") row.child("You're in preview mode. Join this group to talk.")
.child( .child(
Button::new("join") Button::new("join")
.label("Request to Join") .label("Request to join")
.small() .small()
.ghost() .ghost()
.on_click(cx.listener(move |this, _event, _window, cx| { .on_click(cx.listener(move |this, _event, _window, cx| {
@@ -1065,7 +1147,11 @@ impl GroupPanel {
cx, cx,
) )
}) })
.element(ix.into(), window, cx), .element(
SharedString::from(format!("text-{}", message.id.to_hex())).into(),
window,
cx,
),
}; };
let admin = nostr let admin = nostr
@@ -1162,7 +1248,7 @@ impl GroupPanel {
.items_end() .items_end()
.child( .child(
Button::new("upload") Button::new("upload")
.icon(ui::IconName::Paperclip) .icon(IconName::Paperclip)
.tooltip("Upload media") .tooltip("Upload media")
.loading(self.uploading) .loading(self.uploading)
.disabled(dictating || self.uploading) .disabled(dictating || self.uploading)
@@ -1202,8 +1288,9 @@ impl GroupPanel {
.child(self.render_emoji_menu(dictating, window, cx)) .child(self.render_emoji_menu(dictating, window, cx))
.child( .child(
Button::new("send") Button::new("send")
.icon(ui::IconName::PaperPlaneFill) .icon(IconName::SendHorizontal)
.ghost() .tooltip("Send")
.primary()
.disabled(dictating || self.uploading) .disabled(dictating || self.uploading)
.on_click(cx.listener(|this, _event, window, cx| { .on_click(cx.listener(|this, _event, window, cx| {
this.send(window, cx); this.send(window, cx);
@@ -1234,7 +1321,8 @@ impl GroupPanel {
.unwrap_or_default(); .unwrap_or_default();
Button::new("emoji") Button::new("emoji")
.icon(ui::IconName::Emoji) .icon(IconName::FaceGrinning)
.tooltip("Emoji")
.ghost() .ghost()
.disabled(disabled) .disabled(disabled)
.dropdown_menu_with_anchor(Anchor::BottomLeft, move |this, _window, _cx| { .dropdown_menu_with_anchor(Anchor::BottomLeft, move |this, _window, _cx| {
@@ -1289,7 +1377,7 @@ impl GroupPanel {
.child( .child(
div() div()
.flex_shrink_0() .flex_shrink_0()
.max_w(px(140.)) .max_w(rems(8.75))
.truncate() .truncate()
.font_semibold() .font_semibold()
.text_color(cx.theme().foreground) .text_color(cx.theme().foreground)
@@ -1305,7 +1393,7 @@ impl GroupPanel {
) )
.child( .child(
Button::new("cancel-reply") Button::new("cancel-reply")
.icon(ui::IconName::Close) .icon(IconName::Close)
.tooltip("Cancel reply") .tooltip("Cancel reply")
.ghost() .ghost()
.small() .small()
@@ -1319,8 +1407,7 @@ impl GroupPanel {
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> { fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
self.attachments self.attachments
.iter() .iter()
.enumerate() .map(|url| {
.map(|(ix, url)| {
let url = url.clone(); let url = url.clone();
h_flex() h_flex()
@@ -1336,13 +1423,13 @@ impl GroupPanel {
.child(Icon::new(IconName::Link).xsmall()) .child(Icon::new(IconName::Link).xsmall())
.child( .child(
div() div()
.max_w(px(200.)) .max_w(rems(12.5))
.truncate() .truncate()
.child(attachment_name(&url)), .child(attachment_name(&url)),
) )
.child( .child(
Button::new(SharedString::from(format!("remove-attachment-{ix}"))) Button::new(SharedString::from(format!("remove-attachment-{}", url)))
.icon(ui::IconName::Close) .icon(IconName::Close)
.tooltip("Remove") .tooltip("Remove")
.ghost() .ghost()
.xsmall() .xsmall()
+53 -41
View File
@@ -3,19 +3,17 @@ use chat_ui::Command;
use common::TimestampExt; use common::TimestampExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, AnyElement, App, ClipboardItem, Entity, InteractiveElement, IntoElement, ParentElement,
WeakEntity, div, SharedString, Styled, WeakEntity, div,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem}; use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::notification::Notification; use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex};
use nip29::{Activity, Group, Reaction}; use nip29::{Activity, Group, Reaction};
use nostr_sdk::prelude::{EventId, Kind}; use nostr_sdk::prelude::{EventId, Kind, ToBech32};
use person::Directory; use person::Directory;
use settings::AppSettings; use settings::AppSettings;
use speech::speaker::END_GRACE;
use state::NostrRegistry; use state::NostrRegistry;
use ui::{ use ui::{
Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS, Avatar, COMPACT_REACTION_EMOJIS, MessageActions, MessageMedia, MessageRow, REACTION_EMOJIS,
@@ -35,7 +33,7 @@ pub(crate) struct Actions {
} }
pub(crate) fn render( pub(crate) fn render(
ix: usize, _ix: usize,
message: &Message, message: &Message,
content: AnyElement, content: AnyElement,
show_author: bool, show_author: bool,
@@ -60,7 +58,10 @@ pub(crate) fn render(
nostr.read(cx).current_user(), nostr.read(cx).current_user(),
); );
let row = MessageRow::new(ix) let row = MessageRow::new(SharedString::from(format!(
"message-{}",
message.id.to_hex()
)))
.show_author(show_author) .show_author(show_author)
.hide_avatar(hide_avatar) .hide_avatar(hide_avatar)
.avatar( .avatar(
@@ -129,36 +130,16 @@ pub(crate) fn render(
SharedString::from(format!("message-copy-{id}")), SharedString::from(format!("message-copy-{id}")),
message.preview(), message.preview(),
) )
.speech(speaking, move |_event, _window, cx| { .speech(speaking, {
let speech_text = speech_text.clone();
move |_event, _window, cx| {
if let Err(error) = speech_panel.update_in(cx, |panel, window, cx| { if let Err(error) = speech_panel.update_in(cx, |panel, window, cx| {
match panel.speaker.speak(&speech_text) { panel.speak(speech_id, speech_text.clone(), window, cx);
Ok(duration) => {
panel.speech_reveal = Some((speech_id, duration));
let session = (speech_id, duration);
let timer = cx.background_executor().timer(duration + END_GRACE);
panel.reveal_timeout = Some(cx.spawn(async move |this, cx| {
timer.await;
this.update(cx, |this, cx| {
if this.speech_reveal.as_ref() == Some(&session) {
this.speech_reveal = None;
cx.notify();
}
})?;
Ok(())
}));
cx.notify();
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
}) { }) {
log::warn!("nip29: reading the message aloud failed: {error}"); log::warn!("nip29: reading the message aloud failed: {error}");
} }
}
}); });
let row = row.overlay(bar.dropdown(move |menu, _window, _cx| { let row = row.overlay(bar.dropdown(move |menu, _window, _cx| {
@@ -189,9 +170,11 @@ pub(crate) fn render(
let panel = panel.clone(); let panel = panel.clone();
PopupMenuItem::new("Reply").on_click(move |_event, _window, cx| { PopupMenuItem::new("Reply").on_click(move |_event, _window, cx| {
let _ = panel.update_in(cx, |panel, window, cx| { if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.start_reply(id, window, cx); panel.start_reply(id, window, cx);
}); }) {
log::warn!("nip29: starting a reply failed: {error}");
}
}) })
}) })
.submenu("React", window, cx, { .submenu("React", window, cx, {
@@ -211,6 +194,29 @@ pub(crate) fn render(
} }
}) })
} }
})
.item({
let panel = panel.clone();
let speech_text = speech_text.clone();
let speech_id = id;
PopupMenuItem::new("Read aloud")
.disabled(speaking)
.on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.speak(speech_id, speech_text.clone(), window, cx);
}) {
log::warn!("nip29: reading the message aloud failed: {error}");
}
})
})
.item({
let author = author;
PopupMenuItem::new("Copy author").on_click(move |_event, _window, cx| {
let npub = author.to_bech32().unwrap_or_default();
cx.write_to_clipboard(ClipboardItem::new_string(npub));
})
}); });
if !admin { if !admin {
@@ -246,7 +252,7 @@ pub(crate) fn render(
} }
pub(crate) fn render_activity( pub(crate) fn render_activity(
ix: usize, _ix: usize,
activity: &Activity, activity: &Activity,
persons: &Entity<Directory>, persons: &Entity<Directory>,
cx: &App, cx: &App,
@@ -255,12 +261,14 @@ pub(crate) fn render_activity(
let hide_avatar = AppSettings::get_hide_avatar(cx); let hide_avatar = AppSettings::get_hide_avatar(cx);
h_flex() h_flex()
.id(ix) .id(SharedString::from(format!(
"activity-{}",
activity.id.to_hex()
)))
.w_full() .w_full()
.gap_2() .gap_2()
.py_1() .py_1()
.px_3() .px_3()
.hover(|this| this.bg(cx.theme().list_hover))
.child(badge(activity, cx)) .child(badge(activity, cx))
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
this.child( this.child(
@@ -337,7 +345,7 @@ fn replies(
let mut items = Vec::with_capacity(targets.len()); let mut items = Vec::with_capacity(targets.len());
for (ix, id) in targets.iter().enumerate() { for id in targets.iter() {
let context = entity.read_with(cx, |group, cx| { let context = entity.read_with(cx, |group, cx| {
let message = group.message(*id)?; let message = group.message(*id)?;
let author = persons.read(cx).person(&message.author, cx); let author = persons.read(cx).person(&message.author, cx);
@@ -356,7 +364,11 @@ fn replies(
let panel = panel.clone(); let panel = panel.clone();
items.push( items.push(
ReplyPreview::new(ix, author, preview) ReplyPreview::new(
SharedString::from(format!("reply-{}", id.to_hex())),
author,
preview,
)
.on_click(move |_event, _window, cx| { .on_click(move |_event, _window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| { if let Err(error) = panel.update(cx, |panel, cx| {
panel.scroll_to(&id, cx); panel.scroll_to(&id, cx);
+11 -7
View File
@@ -11,7 +11,7 @@ use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::input::InputState; use gpui_kit::component::input::InputState;
use gpui_kit::component::notification::Notification; use gpui_kit::component::notification::Notification;
use gpui_kit::component::switch::Switch; use gpui_kit::component::switch::Switch;
use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use nip29::{Group, GroupId, GroupMetadata}; use nip29::{Group, GroupId, GroupMetadata};
use nostr_sdk::prelude::RelayUrl; use nostr_sdk::prelude::RelayUrl;
use ui::settings_row; use ui::settings_row;
@@ -100,7 +100,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
let supported = *livekit.read(cx) != Some(false); let supported = *livekit.read(cx) != Some(false);
this.close_button(true) this.close_button(true)
.title("Edit Group") .title("Edit group")
.button_props( .button_props(
DialogButtonProps::default() DialogButtonProps::default()
.cancel_text("Cancel") .cancel_text("Cancel")
@@ -161,11 +161,14 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
.child(settings_row( .child(settings_row(
"LiveKit", "LiveKit",
if supported { if supported {
"Announce audio and video support." "Announce audio and video support"
} else { } else {
"The relay doesn't announce LiveKit support." "The relay doesn't announce LiveKit support."
}, },
Switch::new("livekit").checked(current.livekit).on_click({ Switch::new("livekit")
.checked(current.livekit)
.disabled(!supported)
.on_click({
let flags = flags.clone(); let flags = flags.clone();
let livekit = livekit.clone(); let livekit = livekit.clone();
@@ -293,10 +296,10 @@ fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
.flex_shrink_0() .flex_shrink_0()
.gap_1() .gap_1()
.when(ix > 0, |this| { .when(ix > 0, |this| {
this.child(move_button(children.clone(), ix, ix - 1, "up")) this.child(move_button(children.clone(), child, ix, ix - 1, "up"))
}) })
.when(ix + 1 < list.len(), |this| { .when(ix + 1 < list.len(), |this| {
this.child(move_button(children.clone(), ix, ix + 1, "down")) this.child(move_button(children.clone(), child, ix, ix + 1, "down"))
}); });
section = section.child( section = section.child(
@@ -319,11 +322,12 @@ fn children_section(children: &Entity<Vec<GroupId>>, cx: &App) -> AnyElement {
fn move_button( fn move_button(
children: Entity<Vec<GroupId>>, children: Entity<Vec<GroupId>>,
child: &GroupId,
ix: usize, ix: usize,
target: usize, target: usize,
direction: &'static str, direction: &'static str,
) -> Button { ) -> Button {
Button::new(SharedString::from(format!("child-{direction}-{ix}"))) Button::new(SharedString::from(format!("child-{direction}-{child}")))
.label(if direction == "up" { "Up" } else { "Down" }) .label(if direction == "up" { "Up" } else { "Down" })
.ghost() .ghost()
.small() .small()
+53 -3
View File
@@ -121,7 +121,7 @@ impl DeviceRegistry {
// Subscribe to nostr state events // Subscribe to nostr state events
cx.observe(&settings, move |this, settings, cx| { cx.observe(&settings, move |this, settings, cx| {
if settings.read(cx).is_nip4e_enabled(cx) { if settings.read(cx).is_nip4e_enabled(cx) {
this.get_announcement(cx); this.init_encryption(cx);
}; };
}), }),
); );
@@ -135,6 +135,11 @@ impl DeviceRegistry {
this.resync.store(false, Ordering::Relaxed); this.resync.store(false, Ordering::Relaxed);
// The in-flight request and client key belong to the old account // The in-flight request and client key belong to the old account
this.app_keys = None; this.app_keys = None;
// The encryption signer belongs to the old account
this.signer.update(cx, |signer, cx| {
*signer = None;
cx.notify();
});
if this.pending_request.take().is_some() { if this.pending_request.take().is_some() {
// The old approval must not target the new account's signer // The old approval must not target the new account's signer
@@ -148,7 +153,7 @@ impl DeviceRegistry {
} }
this.handle_notifications(cx); this.handle_notifications(cx);
this.get_announcement(cx); this.init_encryption(cx);
} }
}), }),
); );
@@ -372,6 +377,33 @@ impl DeviceRegistry {
} }
} }
/// Initialize encryption from the local database before reconciling with relays
fn init_encryption(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
self.tasks.push(cx.spawn(async move |this, cx| {
let Ok(keys) = get_keys(&client, &signer).await else {
// No local keys; the announcement reconciliation will guide setup
this.update(cx, |this, cx| this.get_announcement(cx))?;
return Ok(());
};
this.update(cx, |this, cx| {
// Relay reconciliation may have set the signer already
if this.signer.read(cx).is_none() {
this.set_signer(keys, cx);
this.wait_for_request(cx);
}
this.get_announcement(cx);
})?;
Ok(())
}));
}
/// Get device announcement for current user /// Get device announcement for current user
pub fn get_announcement(&mut self, cx: &mut Context<Self>) { pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -409,8 +441,11 @@ impl DeviceRegistry {
} }
if !announcement_existed.load(Ordering::Acquire) { if !announcement_existed.load(Ordering::Acquire) {
this.update(cx, |_this, cx| { this.update(cx, |this, cx| {
// The device already initialized encryption from the local database
if this.signer.read(cx).is_none() {
cx.emit(DeviceEvent::NotSet); cx.emit(DeviceEvent::NotSet);
}
})?; })?;
} }
@@ -581,8 +616,11 @@ impl DeviceRegistry {
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
if let Ok(keys) = task.await { if let Ok(keys) = task.await {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
// The local database may have initialized this device already
if this.signer.read(cx).is_none() {
this.set_signer(keys, cx); this.set_signer(keys, cx);
this.wait_for_request(cx); this.wait_for_request(cx);
}
this.rotate_if_due(cx); this.rotate_if_due(cx);
})?; })?;
} else { } else {
@@ -761,6 +799,18 @@ impl DeviceRegistry {
let identity = signer.get_public_key_async().await?; let identity = signer.get_public_key_async().await?;
let keys = decrypt_approval(&client, &app_keys, identity, &event).await?; let keys = decrypt_approval(&client, &app_keys, identity, &event).await?;
// Persist the transferred key so restarts initialize from the local database
if let Err(e) = set_keys(
&client,
&signer,
&keys.secret_key().to_secret_hex(),
Timestamp::now(),
)
.await
{
log::error!("Failed to persist the transferred encryption key: {e}");
}
Ok(keys) Ok(keys)
}); });
+36 -9
View File
@@ -2,9 +2,10 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px}; use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, div, px};
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::input::{Input, InputState}; use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::input::{InputState, NumberInput};
use gpui_kit::component::notification::Notification; use gpui_kit::component::notification::Notification;
use gpui_kit::component::{WindowExt, h_flex, v_flex}; use gpui_kit::component::{WindowExt, h_flex, v_flex};
use nip60::WalletRegistry; use nip60::WalletRegistry;
@@ -20,7 +21,12 @@ pub fn open_nutzap(
recipient: PublicKey, recipient: PublicKey,
target: Option<EventId>, target: Option<EventId>,
) { ) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)")); let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Amount (sats)")
.step(1.)
.min(1.)
});
let send: SubmitFn = { let send: SubmitFn = {
let input = input.clone(); let input = input.clone();
@@ -33,27 +39,48 @@ pub fn open_nutzap(
} }
}; };
window.open_alert_dialog(cx, move |this, _window, _cx| {
this.confirm()
.width(px(360.))
.title(format!("Send {amount} sats?"))
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Send")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_xs()
.child("A nutzap cannot be undone once sent."),
)
.on_ok(move |_event, window, cx| {
let wallet = WalletRegistry::global(cx); let wallet = WalletRegistry::global(cx);
wallet.update(cx, |registry, cx| { wallet.update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, target, None, cx); registry.send_nutzap(recipient, amount, target, None, cx);
}); });
// Close the confirmation, then the amount dialog beneath it.
window.close_dialog(cx);
window.close_dialog(cx);
true true
}) })
});
// The amount dialog stays open beneath the confirmation.
false
})
}; };
window.open_dialog(cx, move |this, _window, _cx| { window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(380.)) this.width(px(380.))
.title("Send Nutzap") .title("Send eCash")
.on_ok({ .on_ok({
let send = send.clone(); let send = send.clone();
move |event, window, cx| send(event, window, cx) move |event, window, cx| send(event, window, cx)
}) })
.child( .child(
v_flex() v_flex().gap_3().child(NumberInput::new(&input)).child(
.gap_3()
.child(Input::new(&input).cleanable(true))
.child(
h_flex() h_flex()
.gap_1() .gap_1()
.justify_end() .justify_end()
+65 -33
View File
@@ -6,15 +6,16 @@ use common::TimestampExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement as _, IntoElement, ParentElement, Render, SharedString, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px,
StatefulInteractiveElement as _, Styled, Subscription, Task, Window, div, px, relative, relative, uniform_list,
uniform_list,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants as _};
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::dock::{Panel, PanelEvent}; use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput}; use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::list::ListItem;
use gpui_kit::component::notification::Notification; use gpui_kit::component::notification::Notification;
use gpui_kit::component::separator::Separator; use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ use gpui_kit::component::{
@@ -125,7 +126,7 @@ impl WalletPanel {
.border_dashed() .border_dashed()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.rounded(cx.theme().radius_lg) .rounded(cx.theme().radius_lg)
.child("No transactions yet.") .child("No transactions yet")
.into_any_element(); .into_any_element();
} }
@@ -227,7 +228,7 @@ impl SendDialog {
}), }),
cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| { cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event { if let InputEvent::PressEnter { .. } = event {
this.send(window, cx); this.confirm_send(window, cx);
} }
}), }),
]; ];
@@ -413,23 +414,45 @@ impl SendDialog {
} }
} }
/// Validate the selection and amount, then schedule the nutzap. /// Validate the form, then confirm before the irreversible nutzap.
fn send(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool { fn confirm_send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(recipient) = self.selected else { let Some(recipient) = self.selected else {
self.set_error("Select a contact to send to", cx); self.set_error("Select a contact to send to", cx);
return false; return;
}; };
let Some(amount) = parse_amount(&self.amount_input, cx) else { let Some(amount) = parse_amount(&self.amount_input, cx) else {
self.set_error("Enter an amount in sats", cx); self.set_error("Enter an amount in sats", cx);
return false; return;
}; };
let recipient_name = self.directory.read(cx).person(&recipient, cx).name();
window.open_alert_dialog(cx, move |this, _window, _cx| {
this.confirm()
.width(px(360.))
.title(format!("Send {amount} sats to {recipient_name}?"))
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Send")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_xs()
.child("A nutzap cannot be undone once sent."),
)
.on_ok(move |_event, window, cx| {
WalletRegistry::global(cx).update(cx, |registry, cx| { WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, None, None, cx); registry.send_nutzap(recipient, amount, None, None, cx);
}); });
// Close the confirmation, then the send dialog beneath it.
window.close_dialog(cx);
window.close_dialog(cx);
true true
})
});
} }
fn clear_input(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn clear_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -468,28 +491,28 @@ impl SendDialog {
let selected = self.selected == Some(public_key); let selected = self.selected == Some(public_key);
let id = SharedString::from(format!("contact-{}", public_key.to_hex())); let id = SharedString::from(format!("contact-{}", public_key.to_hex()));
h_flex() let mut item = ListItem::new(id)
.id(id) .accessibility_label(profile.name())
.selected(selected)
.cursor_pointer()
.w_full() .w_full()
.h_8() .h_8()
.px_2() .px_2()
.gap_2()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.text_sm() .text_sm()
.cursor_pointer() .on_click(cx.listener(move |this, _event, _window, cx| {
.hover(|this| this.bg(cx.theme().list_hover)) this.toggle_selection(&public_key, cx);
.when(selected, |this| this.bg(cx.theme().list_active)) }));
.when(!AppSettings::get_hide_avatar(cx), |this| {
this.child( if !AppSettings::get_hide_avatar(cx) {
item = item.child(
Avatar::new(profile.avatar()) Avatar::new(profile.avatar())
.seed(profile.avatar_seed()) .seed(profile.avatar_seed())
.small(), .small(),
) );
}) }
.child(div().min_w_0().truncate().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| { item.child(div().flex_1().min_w_0().truncate().child(profile.name()))
this.toggle_selection(&public_key, cx);
}))
.into_any_element() .into_any_element()
}) })
.collect() .collect()
@@ -507,12 +530,10 @@ impl SendDialog {
.gap_1() .gap_1()
.child( .child(
h_flex() h_flex()
.id(id)
.gap_1() .gap_1()
.p_0p5() .p_0p5()
.rounded_full() .rounded_full()
.bg(cx.theme().secondary) .bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().primary))
.child( .child(
Avatar::new(profile.avatar()) Avatar::new(profile.avatar())
.seed(profile.avatar_seed()) .seed(profile.avatar_seed())
@@ -526,10 +547,18 @@ impl SendDialog {
.line_height(relative(1.0)) .line_height(relative(1.0))
.child(profile.name()), .child(profile.name()),
) )
.child(
Button::new(id)
.icon(IconName::Close)
.tooltip("Remove recipient")
.accessibility_label("Remove recipient")
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _event, _window, cx| { .on_click(cx.listener(move |this, _event, _window, cx| {
this.selected = None; this.selected = None;
cx.notify(); cx.notify();
})), })),
),
) )
.into_any_element() .into_any_element()
} }
@@ -602,9 +631,7 @@ impl Render for SendDialog {
.label("Send") .label("Send")
.primary() .primary()
.on_click(cx.listener(move |this, _event, window, cx| { .on_click(cx.listener(move |this, _event, window, cx| {
if this.send(window, cx) { this.confirm_send(window, cx);
window.close_dialog(cx);
}
})), })),
), ),
) )
@@ -613,8 +640,13 @@ impl Render for SendDialog {
/// Create a lightning invoice or redeem a pasted cashu token /// Create a lightning invoice or redeem a pasted cashu token
fn open_receive_dialog(window: &mut Window, cx: &mut App) { fn open_receive_dialog(window: &mut Window, cx: &mut App) {
let amount_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)")); let amount_input = cx.new(|cx| {
let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…")); InputState::new(window, cx)
.placeholder("Amount (sats)")
.step(1.)
.min(1.)
});
let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu"));
let create_invoice: SubmitFn = { let create_invoice: SubmitFn = {
let wallet = WalletRegistry::global(cx); let wallet = WalletRegistry::global(cx);
@@ -678,7 +710,7 @@ fn open_receive_dialog(window: &mut Window, cx: &mut App) {
h_flex() h_flex()
.gap_1() .gap_1()
.w_full() .w_full()
.child(Input::new(&amount_input).cleanable(true)) .child(NumberInput::new(&amount_input))
.child( .child(
Button::new("invoice") Button::new("invoice")
.label("Invoice") .label("Invoice")
+10 -9
View File
@@ -1,6 +1,6 @@
use gpui::{ use gpui::{
App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Rems, RenderOnce, SharedString,
SharedString, StyleRefinement, Styled, Window, div, px, StyleRefinement, Styled, Window, div, rems,
}; };
use gpui_base::AvatarImage; use gpui_base::AvatarImage;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
@@ -115,7 +115,7 @@ impl RenderOnce for Avatar {
#[derive(IntoElement)] #[derive(IntoElement)]
struct PixelAvatar { struct PixelAvatar {
seed: u64, seed: u64,
side: Pixels, side: Rems,
style: StyleRefinement, style: StyleRefinement,
} }
@@ -187,13 +187,14 @@ impl RenderOnce for PixelAvatar {
} }
/// The rendered side length of an avatar at `size`. /// The rendered side length of an avatar at `size`.
fn side_length(size: Size) -> Pixels { fn side_length(size: Size) -> Rems {
match size { match size {
Size::XSmall => px(16.), Size::XSmall => rems(1.),
Size::Small => px(24.), Size::Small => rems(1.5),
Size::Medium => px(48.), Size::Medium => rems(3.),
Size::Large => px(80.), Size::Large => rems(5.),
Size::Size(size) => size, // Custom sizes arrive in px; assume the 16px default rem.
Size::Size(size) => rems(size.as_f32() / 16.),
} }
} }
-75
View File
@@ -1,75 +0,0 @@
use gpui::SharedString;
use gpui_kit::assets::IconNamed;
/// Coop icons kept from before the gpui-kit migration; absent from the kit bundle.
#[derive(Clone, Copy)]
pub enum IconName {
ArrowDownCircle,
ArrowLeft,
Book,
CaretDown,
CaretRight,
CheckCircle,
Close,
Compass,
Door,
Emoji,
Group,
Input,
Invite,
Link,
Lock,
Message,
Paperclip,
PaperPlaneFill,
Plus,
PlusChat,
Profile,
Relay,
Speech,
Search,
Settings,
Settings2,
Unlock,
Upload,
Warning,
Wallet,
}
impl IconNamed for IconName {
fn path(self) -> SharedString {
match self {
Self::ArrowDownCircle => "icons/arrow-down-circle.svg",
Self::ArrowLeft => "icons/arrow-left.svg",
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::Paperclip => "icons/paperclip.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::Speech => "icons/speech.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Unlock => "icons/unlock.svg",
Self::Upload => "icons/upload.svg",
Self::Warning => "icons/warning.svg",
Self::Wallet => "icons/wallet.svg",
}
.into()
}
}
-2
View File
@@ -1,13 +1,11 @@
pub use avatar::Avatar; pub use avatar::Avatar;
pub use form::*; pub use form::*;
pub use icon::IconName;
pub use message::*; pub use message::*;
pub use text::*; pub use text::*;
pub use window_drag::title_bar_drag_handlers; pub use window_drag::title_bar_drag_handlers;
pub mod avatar; pub mod avatar;
pub mod form; pub mod form;
pub mod icon;
pub mod message; pub mod message;
pub mod text; pub mod text;
pub mod window_drag; pub mod window_drag;
+46 -10
View File
@@ -7,7 +7,7 @@ use gpui::{
Animation, AnimationExt as _, AnyElement, App, ClickEvent, Context, ElementId, Animation, AnimationExt as _, AnyElement, App, ClickEvent, Context, ElementId,
InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ObjectFit, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ObjectFit,
ParentElement as _, RenderOnce, SharedString, SharedUri, StatefulInteractiveElement as _, ParentElement as _, RenderOnce, SharedString, SharedUri, StatefulInteractiveElement as _,
StyleRefinement, Styled, StyledImage, Window, div, img, px, StyleRefinement, Styled, StyledImage, Window, div, img, rems,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::button::{Button, ButtonVariants as _};
@@ -199,10 +199,10 @@ impl RenderOnce for MessageRow {
if show_author { if show_author {
match avatar { match avatar {
Some(avatar) => this.child(avatar), Some(avatar) => this.child(avatar),
None => this.child(div().flex_shrink_0().w(px(24.))), None => this.child(div().flex_shrink_0().w_6()),
} }
} else { } else {
this.child(div().flex_shrink_0().w(px(24.))) this.child(div().flex_shrink_0().w_6())
} }
}) })
.child( .child(
@@ -212,7 +212,7 @@ impl RenderOnce for MessageRow {
.min_w_0() .min_w_0()
.flex_initial() .flex_initial()
.overflow_hidden() .overflow_hidden()
.text_size(px(15.)) .text_base()
.when(show_author, |this| { .when(show_author, |this| {
this.child( this.child(
h_flex() h_flex()
@@ -251,6 +251,7 @@ pub struct ReplyPreview {
author: SharedString, author: SharedString,
preview: SharedString, preview: SharedString,
on_click: Option<ClickListener>, on_click: Option<ClickListener>,
on_remove: Option<ClickListener>,
} }
impl ReplyPreview { impl ReplyPreview {
@@ -266,6 +267,7 @@ impl ReplyPreview {
author: author.into(), author: author.into(),
preview: preview.into(), preview: preview.into(),
on_click: None, on_click: None,
on_remove: None,
} }
} }
@@ -278,6 +280,16 @@ impl ReplyPreview {
self.on_click = Some(Box::new(listener)); self.on_click = Some(Box::new(listener));
self self
} }
/// Show a remove button, calling the handler when it is clicked.
#[must_use]
pub fn on_remove(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_remove = Some(Box::new(handler));
self
}
} }
impl Styled for ReplyPreview { impl Styled for ReplyPreview {
@@ -294,6 +306,7 @@ impl RenderOnce for ReplyPreview {
author, author,
preview, preview,
on_click, on_click,
on_remove,
} = self; } = self;
div() div()
@@ -304,8 +317,28 @@ impl RenderOnce for ReplyPreview {
.border_color(cx.theme().list_active) .border_color(cx.theme().list_active)
.text_xs() .text_xs()
.refine_style(&style) .refine_style(&style)
.child(
h_flex()
.w_full()
.child(
div()
.flex_1()
.min_w_0()
.child(div().font_semibold().child(author)) .child(div().font_semibold().child(author))
.child(div().w_full().text_ellipsis().line_clamp(1).child(preview)) .child(div().w_full().text_ellipsis().line_clamp(1).child(preview)),
)
.when_some(on_remove, |this, on_remove| {
this.child(
Button::new("remove-reply")
.icon(IconName::X)
.xsmall()
.ghost()
.tooltip("Remove reply")
.flex_shrink_0()
.on_click(on_remove),
)
}),
)
.hover(|this| this.bg(cx.theme().list_hover)) .hover(|this| this.bg(cx.theme().list_hover))
.when_some(on_click, |this, on_click| this.on_click(on_click)) .when_some(on_click, |this, on_click| this.on_click(on_click))
.into_any_element() .into_any_element()
@@ -350,7 +383,7 @@ impl RenderOnce for MessageMedia {
img(only.clone()) img(only.clone())
.border_1() .border_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.h(px(250.)) .h(rems(16.))
.object_fit(ObjectFit::Cover) .object_fit(ObjectFit::Cover)
.rounded(cx.theme().radius_lg), .rounded(cx.theme().radius_lg),
) )
@@ -365,8 +398,11 @@ impl RenderOnce for MessageMedia {
.flex_wrap() .flex_wrap()
.gap_2() .gap_2()
.refine_style(&style) .refine_style(&style)
.children(media.into_iter().enumerate().map(|(ix, item)| { .children(media.into_iter().map(|item| {
div().id(format!("media-{ix}")).flex_shrink_0().child( div()
.id(SharedString::from(format!("media-{}", item)))
.flex_shrink_0()
.child(
img(item) img(item)
.h_32() .h_32()
.border_1() .border_1()
@@ -461,7 +497,7 @@ impl RenderOnce for ReactionBar {
.when(!group.mine, |this| this.border_color(cx.theme().border)) .when(!group.mine, |this| this.border_color(cx.theme().border))
.when(group.image.is_none(), |this| this.child(group.emoji)) .when(group.image.is_none(), |this| this.child(group.emoji))
.when_some(group.image, |this, image| { .when_some(group.image, |this, image| {
this.child(img(image).size(px(16.)).flex_shrink_0()) this.child(img(image).size_4().flex_shrink_0())
}) })
.child(SharedString::from(group.count.to_string())) .child(SharedString::from(group.count.to_string()))
})) }))
@@ -732,7 +768,7 @@ impl RenderOnce for MessageActions {
.when_some(speech, |this, (disabled, speech)| { .when_some(speech, |this, (disabled, speech)| {
this.child( this.child(
Button::new("speech") Button::new("speech")
.icon(crate::IconName::Speech) .icon(IconName::Speech)
.tooltip("Read aloud") .tooltip("Read aloud")
.small() .small()
.ghost() .ghost()
+13 -20
View File
@@ -2,17 +2,18 @@ use anyhow::Error;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString, App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
Styled, Subscription, Task, Window, div, px, relative, Styled, Subscription, Task, Window, div, px,
}; };
use gpui_kit::assets::IconName;
use gpui_kit::component::alert::{Alert, AlertVariant};
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_kit::component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_kit::component::form::{field, v_form}; use gpui_kit::component::form::{field, v_form};
use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::{ActiveTheme, Disableable, WindowExt, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, v_flex};
use instant::Duration; use instant::Duration;
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use state::{NostrRegistry, StateEvent}; use state::{NostrRegistry, StateEvent};
use ui::IconName;
pub fn open(window: &mut Window, cx: &mut App) { pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx)); let import = cx.new(|cx| ImportIdentity::new(window, cx));
@@ -277,7 +278,7 @@ impl ImportIdentity {
DialogFooter::new().child( DialogFooter::new().child(
Button::new("unlock") Button::new("unlock")
.primary() .primary()
.icon(IconName::Unlock) .icon(IconName::LockOpen)
.label("Unlock") .label("Unlock")
.loading(self.loading) .loading(self.loading)
.disabled(self.loading) .disabled(self.loading)
@@ -345,26 +346,18 @@ impl Render for ImportIdentity {
}) })
.when(bunker_warning, |this| { .when(bunker_warning, |this| {
this.child( this.child(
div() Alert::new("bunker-warning", BUNKER_WARN)
.p_2() .with_variant(AlertVariant::Warning)
.rounded(cx.theme().radius_lg) .icon(IconName::TriangleAlert)
.bg(cx.theme().warning.opacity(0.15)) .small(),
.text_xs()
.text_color(cx.theme().warning)
.line_height(relative(1.2))
.child(div().child(BUNKER_WARN)),
) )
}) })
.when(key_warning, |this| { .when(key_warning, |this| {
this.child( this.child(
div() Alert::new("key-warning", KEY_WARN)
.p_2() .with_variant(AlertVariant::Warning)
.rounded(cx.theme().radius_lg) .icon(IconName::TriangleAlert)
.bg(cx.theme().warning.opacity(0.15)) .small(),
.text_xs()
.text_color(cx.theme().warning)
.line_height(relative(1.2))
.child(div().child(KEY_WARN)),
) )
}), }),
) )
+1 -1
View File
@@ -11,7 +11,7 @@ use nip29::{GroupReference, GroupsRegistry};
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>; type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
pub fn open(window: &mut Window, cx: &mut App) { pub fn open(window: &mut Window, cx: &mut App) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("naddr1…")); let input = cx.new(|cx| InputState::new(window, cx).placeholder("naddr1"));
let join: SubmitFn = { let join: SubmitFn = {
let input = input.clone(); let input = input.clone();
+12 -10
View File
@@ -13,6 +13,7 @@ use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle}; use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::list::ListItem;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::Directory; use person::Directory;
@@ -20,8 +21,6 @@ use settings::AppSettings;
use state::NostrRegistry; use state::NostrRegistry;
use ui::Avatar; use ui::Avatar;
use crate::nav::Nav;
/// Maximum number of members a chat can have. /// Maximum number of members a chat can have.
const MAX_MEMBERS: usize = 3; const MAX_MEMBERS: usize = 3;
@@ -325,27 +324,30 @@ impl NewChat {
contacts: &[PublicKey], contacts: &[PublicKey],
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Vec<AnyElement> { ) -> Vec<AnyElement> {
let start = range.start;
contacts contacts
.get(range) .get(range)
.into_iter() .into_iter()
.flatten() .flatten()
.enumerate() .map(|public_key| {
.map(|(offset, public_key)| {
let profile = self.directory.read(cx).person(public_key, cx); let profile = self.directory.read(cx).person(public_key, cx);
let public_key = *public_key; let public_key = *public_key;
Nav::new(start + offset) ListItem::new(public_key.to_hex())
.label(profile.name()) .accessibility_label(profile.name())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.text_sm() .text_sm()
.when(!AppSettings::get_hide_avatar(cx), |this| { .when(!AppSettings::get_hide_avatar(cx), |this| {
this.prefix( this.child(
div().flex_shrink_0().child(
Avatar::new(profile.avatar()) Avatar::new(profile.avatar())
.seed(profile.avatar_seed()) .seed(profile.avatar_seed())
.small(), .small(),
),
) )
}) })
.child(div().flex_1().min_w_0().truncate().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| { .on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_member(&public_key, cx); this.toggle_member(&public_key, cx);
})) }))
@@ -369,7 +371,7 @@ impl NewChat {
.p_0p5() .p_0p5()
.rounded_full() .rounded_full()
.bg(cx.theme().secondary) .bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().primary)) .hover(|this| this.bg(cx.theme().secondary_hover))
.child( .child(
Avatar::new(profile.avatar()) Avatar::new(profile.avatar())
.seed(profile.avatar_seed()) .seed(profile.avatar_seed())
+111 -133
View File
@@ -1,20 +1,22 @@
use anyhow::Error; use anyhow::Error;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Div, Entity, FocusHandle, InteractiveElement as _, IntoElement, AnyElement, App, AppContext, Context, Div, Entity, InteractiveElement as _, IntoElement,
ParentElement, Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, ParentElement, Render, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
Subscription, Task, WeakEntity, Window, div, px, rems, Subscription, Task, WeakEntity, Window, div, px, rems,
}; };
use gpui_base::{Popup, Select};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle}; use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants}; use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_kit::component::searchable_list::{SearchableListDelegate, SearchableListItem};
use gpui_kit::component::select::{Select, SelectEvent, SelectState};
use gpui_kit::component::separator::Separator; use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch; use gpui_kit::component::switch::Switch;
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{
ActiveTheme, Disableable, IndexPath, Sizable, WindowExt, h_flex, v_flex,
};
use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry}; use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings; use settings::AppSettings;
@@ -72,13 +74,10 @@ struct NewGroup {
name_input: Entity<InputState>, name_input: Entity<InputState>,
about_input: Entity<TextareaState>, about_input: Entity<TextareaState>,
custom_relay_input: Entity<InputState>, custom_relay_input: Entity<InputState>,
/// The relays offered in the relay select.
relays: Vec<RelayUrl>,
/// The selected relay; `None` selects the custom relay field. /// The selected relay; `None` selects the custom relay field.
selected_relay: Option<RelayUrl>, selected_relay: Option<RelayUrl>,
/// Whether the relay select popup is open. /// The relay select; its options end with the custom relay choice.
relay_select_open: bool, relay_select: Entity<SelectState<RelayDelegate>>,
relay_options_focus: FocusHandle,
/// Keep the group in the NIP-44 encrypted section of the user's group list. /// Keep the group in the NIP-44 encrypted section of the user's group list.
list_private: bool, list_private: bool,
/// The uploaded group picture, ready to be published with the metadata. /// The uploaded group picture, ready to be published with the metadata.
@@ -113,8 +112,6 @@ impl NewGroup {
.placeholder("What is this group about?") .placeholder("What is this group about?")
}); });
let relay_options_focus = cx.focus_handle();
let relays: Vec<RelayUrl> = DEFAULT_RELAYS let relays: Vec<RelayUrl> = DEFAULT_RELAYS
.iter() .iter()
.filter_map(|url| match RelayUrl::parse(url) { .filter_map(|url| match RelayUrl::parse(url) {
@@ -127,9 +124,37 @@ impl NewGroup {
.collect(); .collect();
let selected_relay = relays.first().cloned(); let selected_relay = relays.first().cloned();
let mut options: Vec<RelayOption> = relays
.iter()
.map(|relay| RelayOption {
label: relay_label(relay),
value: Some(relay.clone()),
})
.collect();
options.push(RelayOption {
label: "Custom relay".into(),
value: None,
});
let registry = GroupsRegistry::global(cx); let registry = GroupsRegistry::global(cx);
let mut subscriptions = vec![]; let mut subscriptions = vec![];
let delegate = RelayDelegate { options };
let selected_index = delegate.position(&selected_relay);
let relay_select = cx.new(|cx| SelectState::new(delegate, selected_index, window, cx));
subscriptions.push(cx.subscribe_in(
&relay_select,
window,
|this, _select, event, _window, cx| {
let SelectEvent::Confirm(value) = event;
if let Some(choice) = value {
this.select_relay(choice.clone(), cx);
}
},
));
subscriptions.push(cx.subscribe_in( subscriptions.push(cx.subscribe_in(
&registry, &registry,
window, window,
@@ -165,10 +190,8 @@ impl NewGroup {
name_input, name_input,
about_input, about_input,
custom_relay_input, custom_relay_input,
relays,
selected_relay, selected_relay,
relay_select_open: false, relay_select,
relay_options_focus,
list_private: false, list_private: false,
picture: None, picture: None,
uploading: false, uploading: false,
@@ -275,20 +298,6 @@ impl NewGroup {
cx.notify(); cx.notify();
} }
fn set_relay_select_open(&mut self, open: bool, cx: &mut Context<Self>) {
self.relay_select_open = open;
cx.notify();
}
fn activate_relay_option(&mut self, index: usize, cx: &mut Context<Self>) {
if index < self.relays.len() {
self.select_relay(Some(self.relays[index].clone()), cx);
} else {
self.select_relay(None, cx);
}
self.set_relay_select_open(false, cx);
}
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) { fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
self.uploading = uploading; self.uploading = uploading;
cx.notify(); cx.notify();
@@ -339,7 +348,7 @@ impl NewGroup {
} }
} }
fn render_relay(&self, selected: &str, cx: &mut Context<Self>) -> impl IntoElement { fn render_relay(&self, cx: &mut Context<Self>) -> impl IntoElement {
if let Some(parent) = &self.parent { if let Some(parent) = &self.parent {
return v_flex().gap_1p5().child(field_label("Relay", cx)).child( return v_flex().gap_1p5().child(field_label("Relay", cx)).child(
div() div()
@@ -352,108 +361,13 @@ impl NewGroup {
); );
} }
let this = cx.entity().downgrade();
let open = self.relay_select_open;
let trigger = h_flex()
.id("relay-trigger")
.justify_between()
.w_full()
.h_8()
.px_3()
.rounded(cx.theme().radius)
.text_xs()
.cursor_default()
.bg(if open {
cx.theme().list_active
} else {
cx.theme().secondary
})
.when(!open, |this| {
this.hover(|this| this.bg(cx.theme().primary))
.active(|this| this.bg(cx.theme().list_active))
})
.on_click(cx.listener(move |this, _, _window, cx| {
this.relay_select_open = !open;
cx.notify();
}))
.child(selected.to_owned())
.child(
Icon::new(IconName::ChevronDown)
.small()
.text_color(cx.theme().muted_foreground),
);
let select = Select::new("relay-select")
.open(open)
.on_open_change({
let this = this.clone();
move |open, _, cx| {
let _ = this.update(cx, |this, cx| {
this.set_relay_select_open(open, cx);
});
}
})
.content_focus_handle(&self.relay_options_focus)
.accessibility_label("Relay")
.accessibility_value(selected.to_owned())
.w_full()
.child(trigger);
let options = v_flex()
.id("relay-options")
.occlude()
.track_focus(&self.relay_options_focus)
.key_context("Select")
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
this.set_relay_select_open(false, cx);
}))
.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.shadow_md()
.rounded(cx.theme().radius_lg)
.p_1()
.gap_y_1()
.min_w(rems(8.))
.text_color(cx.theme().foreground)
.children(self.relays.iter().enumerate().map(|(index, relay)| {
h_flex()
.id(SharedString::from(format!("option-{relay}")))
.px_1p5()
.py_1()
.rounded(cx.theme().radius)
.text_xs()
.cursor_default()
.hover(|this| this.bg(cx.theme().primary))
.child(relay_label(relay))
.on_click(cx.listener(move |this, _, _, cx| {
this.activate_relay_option(index, cx);
}))
}))
.child(Separator::horizontal())
.child(
h_flex()
.id("relay-add")
.px_1p5()
.py_1()
.rounded(cx.theme().radius)
.text_xs()
.cursor_default()
.child("Custom relay")
.hover(|this| this.bg(cx.theme().primary))
.on_click(cx.listener(|this, _, _, cx| {
this.activate_relay_option(this.relays.len(), cx);
})),
);
v_flex() v_flex()
.gap_1p5() .gap_1p5()
.child(field_label("Relay", cx)) .child(field_label("Relay", cx))
.when(self.selected_relay.is_none(), |this| { .when(self.selected_relay.is_none(), |this| {
this.child(Input::new(&self.custom_relay_input)) this.child(Input::new(&self.custom_relay_input))
}) })
.child(Popup::new("relay-popup", select).when(open, |this| this.content(options))) .child(Select::new(&self.relay_select).accessibility_label("Relay"))
} }
fn render_access(&self, cx: &mut Context<Self>) -> impl IntoElement { fn render_access(&self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -567,12 +481,6 @@ impl NewGroup {
impl Render for NewGroup { impl Render for NewGroup {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let selected = self
.selected_relay
.as_ref()
.map(relay_label)
.unwrap_or_else(|| "Add relay".into());
let web_url = self let web_url = self
.effective_relay(cx) .effective_relay(cx)
.ok() .ok()
@@ -583,7 +491,7 @@ impl Render for NewGroup {
.child( .child(
v_flex() v_flex()
.id("group-dialog-content") .id("group-dialog-content")
.h(px(500.)) .h(rems(31.25))
.gap_3() .gap_3()
.when_some(self.error.clone(), |this, error| { .when_some(self.error.clone(), |this, error| {
this.child(self.render_error(&error, web_url.as_deref(), cx)) this.child(self.render_error(&error, web_url.as_deref(), cx))
@@ -614,7 +522,7 @@ impl Render for NewGroup {
), ),
) )
.child(input_field("Name", Input::new(&self.name_input), cx)) .child(input_field("Name", Input::new(&self.name_input), cx))
.child(self.render_relay(&selected, cx)) .child(self.render_relay(cx))
.child(input_field("About", Textarea::new(&self.about_input), cx)) .child(input_field("About", Textarea::new(&self.about_input), cx))
.child(self.render_access(cx)) .child(self.render_access(cx))
.child(self.render_list_setting(cx)) .child(self.render_list_setting(cx))
@@ -646,6 +554,76 @@ impl Render for NewGroup {
} }
} }
/// One choice in the relay select: a fixed relay, or the custom relay field.
#[derive(Clone)]
struct RelayOption {
label: SharedString,
value: Option<RelayUrl>,
}
impl SearchableListItem for RelayOption {
type Value = Option<RelayUrl>;
fn title(&self) -> SharedString {
self.label.clone()
}
fn value(&self) -> &Self::Value {
&self.value
}
/// The trigger reads "Add relay" while the custom relay field is active.
fn display_title(&self) -> Option<AnyElement> {
(self.value.is_none()).then(|| "Add relay".into_any_element())
}
}
/// Relay options with a separator before the trailing custom relay choice.
struct RelayDelegate {
options: Vec<RelayOption>,
}
impl SearchableListDelegate for RelayDelegate {
type Item = RelayOption;
fn items_count(&self, _section: usize) -> usize {
self.options.len()
}
fn item(&self, ix: IndexPath) -> Option<&RelayOption> {
self.options.get(ix.row)
}
fn position<V>(&self, value: &V) -> Option<IndexPath>
where
Self::Item: SearchableListItem<Value = V>,
V: PartialEq,
{
self.options
.iter()
.position(|option| option.value() == value)
.map(IndexPath::new)
}
fn render_item(
&self,
_ix: IndexPath,
item: &RelayOption,
_checked: bool,
_window: &mut Window,
_cx: &mut App,
) -> Option<AnyElement> {
(item.value.is_none()).then(|| {
v_flex()
.w_full()
.gap_1()
.child(Separator::horizontal())
.child(item.title())
.into_any_element()
})
}
}
fn input_field(text: &'static str, input: impl IntoElement, cx: &App) -> Div { fn input_field(text: &'static str, input: impl IntoElement, cx: &App) -> Div {
v_flex().gap_1p5().child(field_label(text, cx)).child(input) v_flex().gap_1p5().child(field_label(text, cx)).child(input)
} }
+3 -3
View File
@@ -72,7 +72,7 @@ impl ProfileDialog {
let bio_input = cx.new(|cx| { let bio_input = cx.new(|cx| {
TextareaState::new(window, cx) TextareaState::new(window, cx)
.auto_grow(3, 8) .auto_grow(3, 8)
.placeholder("A short introduce about you.") .placeholder("A short introduction about you")
}); });
let directory = Directory::new(cx); let directory = Directory::new(cx);
@@ -438,7 +438,7 @@ impl Render for ProfileDialog {
Button::new("update") Button::new("update")
.icon(IconName::SquarePen) .icon(IconName::SquarePen)
.label("Update") .label("Update")
.primary() .outline()
.on_click(cx.listener(move |this, _ev, window, cx| { .on_click(cx.listener(move |this, _ev, window, cx| {
this.toggle_edit(window, cx); this.toggle_edit(window, cx);
})), })),
@@ -449,7 +449,7 @@ impl Render for ProfileDialog {
Button::new("message") Button::new("message")
.icon(IconName::MessageSquare) .icon(IconName::MessageSquare)
.label("Message") .label("Message")
.primary() .outline()
.on_click(cx.listener(move |this, _ev, window, cx| { .on_click(cx.listener(move |this, _ev, window, cx| {
this.message(window, cx); this.message(window, cx);
})), })),
+27 -17
View File
@@ -8,10 +8,9 @@ use gpui::{
WeakEntity, Window, div, rems, WeakEntity, Window, div, rems,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; use gpui_kit::component::menu::{ContextMenu, ContextMenuExt as _, DropdownMenu, PopupMenuItem};
use gpui_kit::component::scroll::ScrollableElement; use gpui_kit::component::scroll::ScrollableElement;
use gpui_kit::component::tab::{Tab, TabBar}; use gpui_kit::component::tab::{Tab, TabBar};
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
@@ -174,7 +173,7 @@ impl RelayManager {
}) })
} }
fn gossip_rows(&self, cx: &mut Context<Self>) -> Vec<Stateful<Div>> { fn gossip_rows(&self, cx: &mut Context<Self>) -> Vec<ContextMenu<Stateful<Div>>> {
let mut rows = Vec::new(); let mut rows = Vec::new();
for (relay, metadata) in self.gossip.relays.iter() { for (relay, metadata) in self.gossip.relays.iter() {
@@ -245,7 +244,7 @@ impl RelayManager {
}) })
} }
fn inbox_rows(&self, cx: &mut Context<Self>) -> Vec<Stateful<Div>> { fn inbox_rows(&self, cx: &mut Context<Self>) -> Vec<ContextMenu<Stateful<Div>>> {
let mut rows = Vec::new(); let mut rows = Vec::new();
for relay in self.inbox.relays.iter() { for relay in self.inbox.relays.iter() {
@@ -353,7 +352,7 @@ impl GossipRelays {
let metadata = self.metadata; let metadata = self.metadata;
if !value.starts_with("ws") { if !value.starts_with("ws") {
self.set_error("Relay URl is invalid", window, cx); self.set_error("Relay URL is invalid", window, cx);
return; return;
} }
@@ -365,7 +364,7 @@ impl GossipRelays {
cx.notify(); cx.notify();
} }
} else { } else {
self.set_error("Relay URl is invalid", window, cx); self.set_error("Relay URL is invalid", window, cx);
} }
} }
@@ -497,7 +496,7 @@ impl InboxRelays {
let value = self.input.read(cx).value().to_string(); let value = self.input.read(cx).value().to_string();
if !value.starts_with("ws") { if !value.starts_with("ws") {
self.set_error("Relay URl is invalid", window, cx); self.set_error("Relay URL is invalid", window, cx);
return; return;
} }
@@ -509,7 +508,7 @@ impl InboxRelays {
cx.notify(); cx.notify();
} }
} else { } else {
self.set_error("Relay URl is invalid", window, cx); self.set_error("Relay URL is invalid", window, cx);
} }
} }
@@ -635,8 +634,9 @@ fn relay_row(
badge: Option<SharedString>, badge: Option<SharedString>,
on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
cx: &App, cx: &App,
) -> Stateful<Div> { ) -> ContextMenu<Stateful<Div>> {
let label = SharedString::from(url.to_string()); let label = SharedString::from(url.to_string());
let on_remove = std::rc::Rc::new(on_remove);
h_flex() h_flex()
.id(label.clone()) .id(label.clone())
@@ -646,7 +646,17 @@ fn relay_row(
.px_2() .px_2()
.justify_between() .justify_between()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.bg(cx.theme().sidebar) .bg(cx.theme().secondary)
.context_menu({
let on_remove = on_remove.clone();
move |menu, _window, _cx| {
menu.item(PopupMenuItem::new("Remove").on_click({
let on_remove = on_remove.clone();
move |event, window, cx| on_remove(event, window, cx)
}))
}
})
.child( .child(
h_flex() h_flex()
.gap_1() .gap_1()
@@ -654,11 +664,8 @@ fn relay_row(
.child(label) .child(label)
.when_some(badge, |this, badge| { .when_some(badge, |this, badge| {
this.child( this.child(
div() gpui_kit::component::tag::Tag::secondary()
.p_0p5() .xsmall()
.rounded_xs()
.text_xs()
.font_medium()
.child(badge), .child(badge),
) )
}), }),
@@ -667,10 +674,13 @@ fn relay_row(
Button::new("remove") Button::new("remove")
.icon(IconName::X) .icon(IconName::X)
.xsmall() .xsmall()
.ghost() .danger()
.invisible() .invisible()
.group_hover("", |this| this.visible()) .group_hover("", |this| this.visible())
.on_click(on_remove), .on_click({
let on_remove = on_remove.clone();
move |event, window, cx| on_remove(event, window, cx)
}),
) )
} }
+5 -5
View File
@@ -4,8 +4,8 @@ use anyhow::Error;
use common::TimestampExt; use common::TimestampExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render, App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list, Styled, Subscription, Task, Window, div, relative, rems, uniform_list,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
@@ -313,7 +313,6 @@ impl Screening {
.gap_1p5() .gap_1p5()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.text_xs() .text_xs()
.hover(|this| this.bg(cx.theme().primary))
.child( .child(
Avatar::new(profile.avatar()) Avatar::new(profile.avatar())
.seed(profile.avatar_seed()) .seed(profile.avatar_seed())
@@ -325,7 +324,7 @@ impl Screening {
items items
}) })
.h(px(300.)), .h(rems(18.75)),
), ),
) )
}); });
@@ -545,6 +544,7 @@ impl Render for Screening {
.child( .child(
Button::new("mutuals") Button::new("mutuals")
.icon(IconName::Info) .icon(IconName::Info)
.tooltip("Show mutual contacts")
.xsmall() .xsmall()
.ghost() .ghost()
.disabled(mutuals == 0) .disabled(mutuals == 0)
@@ -571,7 +571,7 @@ fn status_badge(status: Option<bool>, cx: &App) -> Div {
if let Some(status) = status { if let Some(status) = status {
this.child(Icon::new(IconName::CircleCheck).small().text_color({ this.child(Icon::new(IconName::CircleCheck).small().text_color({
if status { if status {
cx.theme().primary cx.theme().success
} else { } else {
cx.theme().muted_foreground cx.theme().muted_foreground
} }
+14 -13
View File
@@ -93,7 +93,7 @@ impl Preferences {
}); });
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…")); let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint"));
let subscriptions = vec![ let subscriptions = vec![
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| { Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
@@ -446,7 +446,7 @@ impl Preferences {
impl Render for Preferences { impl Render for Preferences {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const SCREENING: &str = "Show an screening dialog to verify the unknown sender."; const SCREENING: &str = "Show a screening dialog to verify the unknown sender.";
const WOT: &str = "Treat people followed by your contacts as known senders."; const WOT: &str = "Treat people followed by your contacts as known senders.";
const AVATAR: &str = "Hide all avatar pictures to improve performance."; const AVATAR: &str = "Hide all avatar pictures to improve performance.";
const APPEARANCE: &str = const APPEARANCE: &str =
@@ -475,7 +475,7 @@ impl Render for Preferences {
let background_label = match &panel_background_path { let background_label = match &panel_background_path {
Some(path) => path.display().to_string(), Some(path) => path.display().to_string(),
None => String::from("Default Background"), None => String::from("Default background"),
}; };
v_flex() v_flex()
@@ -497,7 +497,7 @@ impl Render for Preferences {
cx, cx,
)) ))
.child(settings_row( .child(settings_row(
"Web of Trust", "Web of trust",
WOT, WOT,
Switch::new("wot-auto-trust") Switch::new("wot-auto-trust")
.checked(wot_auto_trust) .checked(wot_auto_trust)
@@ -536,33 +536,33 @@ impl Render for Preferences {
}), }),
cx, cx,
)) ))
.child(self.render_theme_choice("theme-light", "Light Theme", LIGHT_THEME, false, cx)) .child(self.render_theme_choice("theme-light", "Light theme", LIGHT_THEME, false, cx))
.child(self.render_theme_choice("theme-dark", "Dark Theme", DARK_THEME, true, cx)) .child(self.render_theme_choice("theme-dark", "Dark theme", DARK_THEME, true, cx))
.child(Separator::horizontal()) .child(Separator::horizontal())
.child( .child(
v_flex() v_flex()
.w_full() .w_full()
.gap_3() .gap_3()
.child(settings_row( .child(settings_row(
"Font Size", "Font size",
FONT_SIZE, FONT_SIZE,
NumberInput::new(&self.font_size_input).w_24(), NumberInput::new(&self.font_size_input).w_24(),
cx, cx,
)) ))
.child(settings_row( .child(settings_row(
"Corner Radius", "Corner radius",
RADIUS, RADIUS,
NumberInput::new(&self.radius_input).w_24(), NumberInput::new(&self.radius_input).w_24(),
cx, cx,
)) ))
.child(settings_row( .child(settings_row(
"Large Corner Radius", "Large corner radius",
RADIUS_LG, RADIUS_LG,
NumberInput::new(&self.radius_lg_input).w_24(), NumberInput::new(&self.radius_lg_input).w_24(),
cx, cx,
)) ))
.child(settings_row( .child(settings_row(
"Focus Ring", "Focus ring",
FOCUS_RING, FOCUS_RING,
Switch::new("focus-ring") Switch::new("focus-ring")
.checked(theme.focus_ring) .checked(theme.focus_ring)
@@ -624,7 +624,7 @@ impl Render for Preferences {
) )
.child(Separator::horizontal()) .child(Separator::horizontal())
.child(settings_row( .child(settings_row(
"Decoupling Encryption Key", "Decoupling encryption key",
NIP4E, NIP4E,
Switch::new("nip4e") Switch::new("nip4e")
.checked(nip4e) .checked(nip4e)
@@ -634,7 +634,7 @@ impl Render for Preferences {
cx, cx,
)) ))
.child(settings_row( .child(settings_row(
"Rotate Encryption Key", "Rotate encryption key",
NIP4E_ROTATION, NIP4E_ROTATION,
Switch::new("nip4e_rotation") Switch::new("nip4e_rotation")
.checked(nip4e_rotation_days.is_some()) .checked(nip4e_rotation_days.is_some())
@@ -654,7 +654,7 @@ impl Render for Preferences {
.child(self.render_dictation_shortcut(window, cx)) .child(self.render_dictation_shortcut(window, cx))
.child(Separator::horizontal()) .child(Separator::horizontal())
.child(settings_block( .child(settings_block(
"Media Upload Service", "Media upload service",
MEDIA, MEDIA,
h_flex() h_flex()
.gap_1() .gap_1()
@@ -662,6 +662,7 @@ impl Render for Preferences {
.child( .child(
Button::new("update-file-server") Button::new("update-file-server")
.icon(IconName::Check) .icon(IconName::Check)
.tooltip("Update")
.ghost() .ghost()
.size_8() .size_8()
.on_click(cx.listener(move |this, _ev, window, cx| { .on_click(cx.listener(move |this, _ev, window, cx| {
+1 -1
View File
@@ -678,7 +678,7 @@ impl TabGroupRenderer for CoopTabGroupSkin {
h_flex() h_flex()
.id("tabs-inner") .id("tabs-inner")
.relative() .relative()
.gap(px(4.)) .gap_1()
.overflow_x_scroll() .overflow_x_scroll()
.lock_scroll_axis() .lock_scroll_axis()
.track_scroll(&self.scroll_handle) .track_scroll(&self.scroll_handle)
@@ -4,6 +4,7 @@ use gpui::{
StatefulInteractiveElement, Styled, Window, div, px, StatefulInteractiveElement, Styled, Window, div, px,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::component::tooltip::Tooltip;
use gpui_kit::component::{ActiveTheme as _, Icon, Sizable as _, h_flex}; use gpui_kit::component::{ActiveTheme as _, Icon, Sizable as _, h_flex};
use super::TAB_BAR_HEIGHT; use super::TAB_BAR_HEIGHT;
@@ -53,6 +54,15 @@ impl ControlIcon {
} }
} }
fn label(&self) -> &'static str {
match self {
Self::Minimize => "Minimize",
Self::Restore => "Restore",
Self::Maximize => "Maximize",
Self::Close => "Close",
}
}
fn window_control_area(&self) -> gpui::WindowControlArea { fn window_control_area(&self) -> gpui::WindowControlArea {
match self { match self {
Self::Minimize => gpui::WindowControlArea::Min, Self::Minimize => gpui::WindowControlArea::Min,
@@ -102,6 +112,8 @@ impl RenderOnce for ControlIcon {
let active_bg = self.active_bg(cx); let active_bg = self.active_bg(cx);
let icon = self.clone(); let icon = self.clone();
let label = self.label();
div() div()
.id(self.id()) .id(self.id())
.flex() .flex()
@@ -112,6 +124,7 @@ impl RenderOnce for ControlIcon {
.content_center() .content_center()
.items_center() .items_center()
.text_color(cx.theme().foreground) .text_color(cx.theme().foreground)
.tooltip(move |window, cx| Tooltip::new(label).build(window, cx))
.hover(|style| style.bg(hover_bg).text_color(hover_fg)) .hover(|style| style.bg(hover_bg).text_color(hover_fg))
.active(|style| style.bg(active_bg).text_color(hover_fg)) .active(|style| style.bg(active_bg).text_color(hover_fg))
.when(is_windows, |this| { .when(is_windows, |this| {
+10 -4
View File
@@ -11,12 +11,13 @@ use chat::{ChatEvent, ChatRegistry};
use common::download_dir; use common::download_dir;
use gpui::{ use gpui::{
Action, App, AppContext, Context, Entity, Focusable, InteractiveElement, IntoElement, Action, App, AppContext, Context, Entity, Focusable, InteractiveElement, IntoElement,
ParentElement, Pixels, Render, SharedString, Styled, Subscription, Task, Window, div, px, ParentElement, Pixels, Render, SharedString, Styled, Subscription, Task, Window, div, px, rems,
}; };
use gpui_kit::component::button::Button; use gpui_kit::component::button::{Button, ButtonVariant};
use gpui_kit::component::command::{ use gpui_kit::component::command::{
Command as CommandPalette, CommandGroup, CommandItem, CommandState, Command as CommandPalette, CommandGroup, CommandItem, CommandState,
}; };
use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle}; use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
use gpui_kit::component::notification::{Notification, NotificationType}; use gpui_kit::component::notification::{Notification, NotificationType};
use gpui_kit::component::{ use gpui_kit::component::{
@@ -36,7 +37,6 @@ use crate::sidebar::Sidebar;
mod dialogs; mod dialogs;
mod dock; mod dock;
mod nav;
mod panels; mod panels;
mod sidebar; mod sidebar;
@@ -480,7 +480,7 @@ impl Workspace {
.bordered(false) .bordered(false)
.rounded(cx.theme().radius_lg) .rounded(cx.theme().radius_lg)
.placeholder("Search themes...") .placeholder("Search themes...")
.max_h(px(420.)) .max_h(rems(26.25))
.on_confirm(move |index, _, cx| { .on_confirm(move |index, _, cx| {
if let Some(name) = Self::theme_name_at(index, cx) { if let Some(name) = Self::theme_name_at(index, cx) {
AppSettings::select_theme(&name, cx); AppSettings::select_theme(&name, cx);
@@ -618,6 +618,12 @@ impl Workspace {
.child(SharedString::from(ENC_WARN)), .child(SharedString::from(ENC_WARN)),
), ),
) )
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Reset key")
.ok_variant(ButtonVariant::Danger),
)
.on_ok(move |_ev, _window, cx| { .on_ok(move |_ev, _window, cx| {
ent.update(cx, |this, cx| { ent.update(cx, |this, cx| {
this.set_announcement(Keys::generate(), cx); this.set_announcement(Keys::generate(), cx);
-142
View File
@@ -1,142 +0,0 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div,
};
use gpui_kit::component::{ActiveTheme, Selectable, StyledExt as _, h_flex};
/// A single navigation row, such as an entry in a sidebar list.
#[derive(IntoElement)]
pub struct Nav {
id: ElementId,
interactivity: Interactivity,
style: StyleRefinement,
prefix: Option<AnyElement>,
label: SharedString,
suffix: Option<AnyElement>,
selected: bool,
clickable: bool,
center_prefix: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl Nav {
pub fn new(id: impl Into<ElementId>) -> Self {
let id = id.into();
let mut interactivity = Interactivity::default();
interactivity.element_id = Some(id.clone());
Self {
id,
interactivity,
style: StyleRefinement::default(),
prefix: None,
label: SharedString::default(),
suffix: None,
selected: false,
clickable: false,
center_prefix: false,
on_click: None,
}
}
/// Marks the row as clickable even without an `on_click` handler, keeping the hover affordance.
pub fn clickable(mut self, clickable: bool) -> Self {
self.clickable = clickable;
self
}
/// Whether to center the prefix in the avatar-width column.
pub fn center_prefix(mut self, center: bool) -> Self {
self.center_prefix = center;
self
}
/// Sets the element shown before the label, such as an avatar or icon.
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Sets the row's label.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = label.into();
self
}
/// Sets the element shown after the label, such as a timestamp or badge.
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl Selectable for Nav {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl InteractiveElement for Nav {
fn interactivity(&mut self) -> &mut Interactivity {
&mut self.interactivity
}
}
impl Styled for Nav {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Nav {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some() || self.clickable;
h_flex()
.id(self.id)
.h_8()
.w_full()
.px_2()
.gap_2()
.rounded(cx.theme().radius)
.when_some(self.prefix, |this, prefix| {
this.child(
h_flex()
.flex_shrink_0()
.when(self.center_prefix, |this| this.w_6().justify_center())
.child(prefix),
)
})
.child(div().flex_1().min_w_0().truncate().child(self.label))
.when_some(self.suffix, |this, suffix| {
this.child(div().flex_shrink_0().child(suffix))
})
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().list_hover))
.when(self.selected, |this| this.bg(cx.theme().list_active))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
})
.refine_style(&self.style)
}
}
+4 -4
View File
@@ -296,7 +296,7 @@ impl BrowsePanel {
.max_w_64() .max_w_64()
.px_1() .px_1()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.bg(cx.theme().sidebar) .bg(cx.theme().secondary)
.child(Input::new(&self.input).appearance(false).flex_1()) .child(Input::new(&self.input).appearance(false).flex_1())
.child( .child(
Button::new("relay-browse") Button::new("relay-browse")
@@ -376,7 +376,7 @@ impl BrowsePanel {
.cursor_pointer() .cursor_pointer()
.border_1() .border_1()
.border_color(transparent_black()) .border_color(transparent_black())
.bg(cx.theme().sidebar) .bg(cx.theme().group_box)
.hover(|this| { .hover(|this| {
this.bg(cx.theme().secondary) this.bg(cx.theme().secondary)
.border_color(cx.theme().border) .border_color(cx.theme().border)
@@ -510,12 +510,12 @@ impl Render for BrowsePanel {
} else { } else {
let text = if discover { let text = if discover {
if self.discovering { if self.discovering {
"Discovering groups…" "Discovering groups"
} else { } else {
"No groups found." "No groups found."
} }
} else if self.loading { } else if self.loading {
"Loading groups…" "Loading groups"
} else { } else {
"No groups found." "No groups found."
}; };
+25 -3
View File
@@ -12,6 +12,7 @@ use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{Panel, PanelEvent}; use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState}; use gpui_kit::component::input::{Input, InputEvent, InputState};
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::scroll::ScrollableElement; use gpui_kit::component::scroll::ScrollableElement;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration; use instant::Duration;
@@ -229,6 +230,7 @@ impl ContactListPanel {
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> { fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
let mut items = Vec::new(); let mut items = Vec::new();
let panel = cx.weak_entity();
for (ix, public_key) in self.contacts.iter().enumerate() { for (ix, public_key) in self.contacts.iter().enumerate() {
let profile = self.directory.read(cx).person(public_key, cx); let profile = self.directory.read(cx).person(public_key, cx);
@@ -243,7 +245,24 @@ impl ContactListPanel {
.px_2() .px_2()
.justify_between() .justify_between()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().primary)) .hover(|this| this.bg(cx.theme().list_hover))
.context_menu({
let panel = panel.clone();
let public_key = *public_key;
move |menu, _window, _cx| {
menu.item(PopupMenuItem::new("Remove").on_click({
let panel = panel.clone();
move |_, _window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| {
panel.remove(&public_key, cx);
}) {
log::warn!("removing a contact failed: {error}");
}
}
}))
}
})
.child( .child(
h_flex() h_flex()
.gap_2() .gap_2()
@@ -256,7 +275,10 @@ impl ContactListPanel {
.child(profile.name()), .child(profile.name()),
) )
.child( .child(
Button::new("remove_{ix}") Button::new(SharedString::from(format!(
"remove-{}",
public_key.to_hex()
)))
.icon(IconName::X) .icon(IconName::X)
.xsmall() .xsmall()
.ghost() .ghost()
@@ -285,7 +307,7 @@ impl ContactListPanel {
.rounded(cx.theme().radius_lg) .rounded(cx.theme().radius_lg)
.text_xs() .text_xs()
.text_align(TextAlign::Center) .text_align(TextAlign::Center)
.child(SharedString::from("Please add some relays.")) .child(SharedString::from("No contacts yet"))
} }
} }
+36 -16
View File
@@ -13,6 +13,7 @@ use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use gpui_kit::component::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use gpui_kit::component::list::ListItem;
use gpui_kit::component::scroll::ScrollableElement; use gpui_kit::component::scroll::ScrollableElement;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -23,7 +24,6 @@ use ui::Avatar;
use super::browse; use super::browse;
use crate::dock::add_panel_to; use crate::dock::add_panel_to;
use crate::nav::Nav;
use crate::sidebar::open_room; use crate::sidebar::open_room;
/// How many requests and follow ups to show per section. /// How many requests and follow ups to show per section.
@@ -213,17 +213,17 @@ impl GreeterPanel {
} else { } else {
requests requests
.iter() .iter()
.enumerate() .map(|(room, preview)| {
.map(|(index, (room, preview))| {
let room = room.clone(); let room = room.clone();
let dock = dock.clone(); let dock = dock.clone();
let name = room.read(cx).display_name(cx); let name = room.read(cx).display_name(cx);
let seed = room.read(cx).display_image_seed(cx); let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago(); let created_at = room.read(cx).created_at.to_ago();
let id = room.read(cx).id;
let preview = preview.clone(); let preview = preview.clone();
h_flex() h_flex()
.id(format!("request-{index}")) .id(format!("request-{id}"))
.group("") .group("")
.w_full() .w_full()
.h_9() .h_9()
@@ -280,8 +280,8 @@ impl GreeterPanel {
), ),
) )
.child( .child(
Button::new(format!("response-{index}")) Button::new(format!("response-{id}"))
.label("Response") .label("Respond")
.xsmall() .xsmall()
.ghost() .ghost()
.invisible() .invisible()
@@ -332,17 +332,17 @@ impl GreeterPanel {
let rows: Vec<AnyElement> = self let rows: Vec<AnyElement> = self
.follow_ups .follow_ups
.iter() .iter()
.enumerate() .map(|(room, preview)| {
.map(|(index, (room, preview))| {
let room = room.clone(); let room = room.clone();
let dock = dock.clone(); let dock = dock.clone();
let name = room.read(cx).display_name(cx); let name = room.read(cx).display_name(cx);
let seed = room.read(cx).display_image_seed(cx); let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago(); let created_at = room.read(cx).created_at.to_ago();
let id = room.read(cx).id;
let preview = preview.clone(); let preview = preview.clone();
h_flex() h_flex()
.id(format!("follow-up-{index}")) .id(format!("follow-up-{id}"))
.group("") .group("")
.w_full() .w_full()
.h_9() .h_9()
@@ -399,7 +399,7 @@ impl GreeterPanel {
), ),
) )
.child( .child(
Button::new(format!("reply-{index}")) Button::new(format!("reply-{id}"))
.label("Reply") .label("Reply")
.xsmall() .xsmall()
.ghost() .ghost()
@@ -457,9 +457,19 @@ impl GreeterPanel {
v_flex() v_flex()
.w_full() .w_full()
.child( .child(
Nav::new("browse-group") ListItem::new("browse-group")
.prefix(Icon::new(IconName::Compass)) .accessibility_label("Browse Public Groups")
.label("Browse Public Groups") .h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(div().flex_shrink_0().child(Icon::new(IconName::Compass)))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.child("Browse Public Groups"),
)
.text_sm() .text_sm()
.on_click(cx.listener(|this, _, window, cx| { .on_click(cx.listener(|this, _, window, cx| {
let panel = browse::init(this.dock.clone(), window, cx); let panel = browse::init(this.dock.clone(), window, cx);
@@ -473,9 +483,19 @@ impl GreeterPanel {
})), })),
) )
.child( .child(
Nav::new("browse-posts") ListItem::new("browse-posts")
.prefix(Icon::new(IconName::Link)) .accessibility_label("Browse Nostr via Jumble")
.label("Browse Nostr via Jumble") .h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(div().flex_shrink_0().child(Icon::new(IconName::Link)))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.child("Browse Nostr via Jumble"),
)
.text_sm() .text_sm()
.on_click(|_, _, cx| { .on_click(|_, _, cx| {
cx.open_url(JUMBLE_URL); cx.open_url(JUMBLE_URL);
+43 -18
View File
@@ -6,18 +6,19 @@ use gpui::{
AnyElement, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement, AnyElement, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement,
ParentElement, SharedString, Styled, WeakEntity, div, px, relative, uniform_list, ParentElement, SharedString, Styled, WeakEntity, div, px, relative, uniform_list,
}; };
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::list::ListItem;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem}; use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
use nip29::Group; use nip29::Group;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings; use settings::AppSettings;
use state::NostrRegistry; use state::NostrRegistry;
use ui::{Avatar, IconName}; use ui::Avatar;
use super::Sidebar; use super::Sidebar;
use crate::dialogs::profile; use crate::dialogs::profile;
use crate::nav::Nav;
/// A collapsible section of the group view. /// A collapsible section of the group view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -222,19 +223,25 @@ impl Sidebar {
GroupSection::Members => self.members_open, GroupSection::Members => self.members_open,
}; };
let icon = if open { let icon = if open {
IconName::CaretDown IconName::ChevronDown
} else { } else {
IconName::CaretRight IconName::ChevronRight
}; };
let label = section.label(); let label = section.label();
Nav::new(label) ListItem::new(label)
.label(label) .accessibility_label(label)
.suffix( .h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(div().flex_1().min_w_0().truncate().child(label))
.suffix(move |_, cx| {
div().flex_shrink_0().child(
Icon::new(icon) Icon::new(icon)
.small() .small()
.text_color(cx.theme().muted_foreground), .text_color(cx.theme().muted_foreground),
) )
})
.text_xs() .text_xs()
.font_semibold() .font_semibold()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
@@ -262,6 +269,7 @@ impl Sidebar {
let group = group.clone(); let group = group.clone();
let public_key = *public_key; let public_key = *public_key;
let member = person.name();
let member_is_admin = !roles.is_empty(); let member_is_admin = !roles.is_empty();
let is_self = NostrRegistry::global(cx) let is_self = NostrRegistry::global(cx)
@@ -269,31 +277,40 @@ impl Sidebar {
.current_user() .current_user()
.is_some_and(|me| me == public_key); .is_some_and(|me| me == public_key);
let nav = Nav::new(public_key.to_hex()) let item = ListItem::new(public_key.to_hex())
.label(person.name()) .accessibility_label(member.clone())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.text_sm() .text_sm()
.clickable(true)
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
this.prefix( this.child(
div().flex_shrink_0().child(
Avatar::new(person.avatar()) Avatar::new(person.avatar())
.seed(person.avatar_seed()) .seed(person.avatar_seed())
.small(), .small(),
),
) )
}) })
.child(div().flex_1().min_w_0().truncate().child(person.name()))
.when(!roles.is_empty(), |this| { .when(!roles.is_empty(), |this| {
this.suffix( let roles = SharedString::from(roles.join(", "));
this.suffix(move |_, cx| {
div().flex_shrink_0().child(
div() div()
.font_normal() .font_normal()
.truncate() .truncate()
.text_xs() .text_xs()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child(SharedString::from(roles.join(", "))), .child(roles.clone()),
) )
})
}); });
let row = div() let row = div()
.id(public_key.to_hex()) .id(public_key.to_hex())
.w_full() .w_full()
.child(nav) .child(item)
.context_menu(move |menu, _window, _cx| { .context_menu(move |menu, _window, _cx| {
let menu = menu let menu = menu
.item( .item(
@@ -326,13 +343,21 @@ impl Sidebar {
}) })
}); });
menu.separator() menu.separator().item({
.item(PopupMenuItem::new("Remove").on_click({ let member = member.clone();
PopupMenuItem::new("Remove").on_click({
let group = group.clone(); let group = group.clone();
move |_event, window, cx| { move |_event, window, cx| {
nip29_ui::remove_member(group.clone(), public_key, window, cx); nip29_ui::confirm_remove_member(
group.clone(),
public_key,
member.clone(),
window,
cx,
);
} }
})) })
})
}); });
row.into_any_element() row.into_any_element()
+21 -11
View File
@@ -7,12 +7,14 @@ use gpui::{
}; };
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use gpui::{AnyElement, Entity}; use gpui::{AnyElement, Entity};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use gpui_kit::component::Disableable; use gpui_kit::component::Disableable;
use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants};
use gpui_kit::component::clipboard::Clipboard; use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::list::ListItem;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::popover::Popover; use gpui_kit::component::popover::Popover;
use gpui_kit::component::separator::Separator; use gpui_kit::component::separator::Separator;
@@ -21,12 +23,11 @@ use nostr_sdk::prelude::*;
use person::shorten_pubkey; use person::shorten_pubkey;
use settings::AppSettings; use settings::AppSettings;
use state::{CLIENT_NAME, NostrRegistry}; use state::{CLIENT_NAME, NostrRegistry};
use ui::{Avatar, IconName}; use ui::Avatar;
use super::Sidebar; use super::Sidebar;
use crate::Command; use crate::Command;
use crate::dock::TAB_BAR_HEIGHT; use crate::dock::TAB_BAR_HEIGHT;
use crate::nav::Nav;
impl Sidebar { impl Sidebar {
pub(super) fn render_user( pub(super) fn render_user(
@@ -101,7 +102,7 @@ impl Sidebar {
) )
.child( .child(
Button::new("sidebar-new") Button::new("sidebar-new")
.icon(IconName::PlusChat) .icon(IconName::MessageSquarePlus)
.tooltip("New") .tooltip("New")
.ghost() .ghost()
.small() .small()
@@ -211,7 +212,7 @@ impl Sidebar {
}) })
} }
pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context<Self>) -> Div { pub(super) fn render_footer(&self, loading: bool, cx: &mut Context<Self>) -> Div {
v_flex() v_flex()
.p_2() .p_2()
.flex_shrink_0() .flex_shrink_0()
@@ -220,10 +221,19 @@ impl Sidebar {
.justify_start() .justify_start()
.when(!loading, |this| { .when(!loading, |this| {
this.child( this.child(
Nav::new("settings") ListItem::new("settings")
.prefix(Icon::new(IconName::Settings).small()) .accessibility_label("Settings")
.center_prefix(true) .h_8()
.label("Settings") .rounded(cx.theme().radius)
.cursor_pointer()
.child(
div()
.w_6()
.justify_center()
.flex_shrink_0()
.child(Icon::new(IconName::Settings).small()),
)
.child(div().flex_1().min_w_0().truncate().child("Settings"))
.text_sm() .text_sm()
.on_click(|_, window, cx| { .on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::ShowSettings), cx) window.dispatch_action(Box::new(Command::ShowSettings), cx)
@@ -243,7 +253,7 @@ impl Sidebar {
.when(!up_to_date, |this| { .when(!up_to_date, |this| {
this.child( this.child(
Button::new("update") Button::new("update")
.icon(IconName::ArrowDownCircle) .icon(IconName::CircleArrowDown)
.tooltip(status) .tooltip(status)
.small() .small()
.ghost() .ghost()
@@ -253,7 +263,7 @@ impl Sidebar {
.when(staged, |this| { .when(staged, |this| {
this.child( this.child(
Button::new("restart") Button::new("restart")
.icon(IconName::ArrowDownCircle) .icon(IconName::CircleArrowDown)
.tooltip("Quit and relaunch into the installed update") .tooltip("Quit and relaunch into the installed update")
.small() .small()
.ghost() .ghost()
@@ -274,7 +284,7 @@ fn open_logout_dialog(window: &mut Window, cx: &mut App) {
window.open_alert_dialog(cx, |alert, _, _| { window.open_alert_dialog(cx, |alert, _, _| {
alert alert
.title("Log Out?") .title("Log out")
.description(DESCRIPTION) .description(DESCRIPTION)
.button_props( .button_props(
DialogButtonProps::default() DialogButtonProps::default()
+56 -33
View File
@@ -2,19 +2,18 @@ use std::rc::Rc;
use chat::{ChatRegistry, RoomKind}; use chat::{ChatRegistry, RoomKind};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{AnyElement, App, Context, IntoElement, ParentElement, Styled, div, uniform_list};
AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, uniform_list, use gpui_kit::assets::IconName;
};
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::badge::Badge;
use gpui_kit::component::list::ListItem;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Icon, Sizable, h_flex, v_flex};
use nip29::GroupsRegistry; use nip29::GroupsRegistry;
use settings::AppSettings; use settings::AppSettings;
use ui::IconName;
use super::Sidebar; use super::Sidebar;
use super::rows::SidebarRow; use super::rows::SidebarRow;
use crate::Command; use crate::Command;
use crate::nav::Nav;
/// A collapsible section of the sidebar's main list. /// A collapsible section of the sidebar's main list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -38,6 +37,7 @@ impl Sidebar {
pub(super) fn render_list(&mut self, cx: &mut Context<Self>) -> AnyElement { pub(super) fn render_list(&mut self, cx: &mut Context<Self>) -> AnyElement {
let requests_open = self.requests_open; let requests_open = self.requests_open;
let contacts_open = self.contacts_open && !requests_open; let contacts_open = self.contacts_open && !requests_open;
let new_requests = self.new_requests;
let rows = Rc::new(self.rows_for(cx)); let rows = Rc::new(self.rows_for(cx));
// Whether to show the create button for the contact picker // Whether to show the create button for the contact picker
let create_button = contacts_open && !self.selected_contacts.is_empty(); let create_button = contacts_open && !self.selected_contacts.is_empty();
@@ -69,9 +69,17 @@ impl Sidebar {
.justify_start() .justify_start()
.when(cfg!(not(target_arch = "wasm32")), |this| { .when(cfg!(not(target_arch = "wasm32")), |this| {
this.child( this.child(
Nav::new("wallet") ListItem::new("wallet")
.prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small()) .accessibility_label("Wallet")
.label("Wallet") .h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(
div().flex_shrink_0().child(
Icon::new(gpui_kit::assets::IconName::Wallet).small(),
),
)
.child(div().flex_1().min_w_0().truncate().child("Wallet"))
.text_sm() .text_sm()
.on_click(|_, window, cx| { .on_click(|_, window, cx| {
window.dispatch_action(Box::new(crate::Command::ShowWallet), cx) window.dispatch_action(Box::new(crate::Command::ShowWallet), cx)
@@ -79,40 +87,46 @@ impl Sidebar {
) )
}) })
.child( .child(
Nav::new("browse") ListItem::new("browse")
.prefix(Icon::new(IconName::Compass).small()) .accessibility_label("Browse")
.label("Browse") .h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(
div()
.flex_shrink_0()
.child(Icon::new(IconName::Compass).small()),
)
.child(div().flex_1().min_w_0().truncate().child("Browse"))
.text_sm() .text_sm()
.on_click(|_, window, cx| { .on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::ShowBrowse), cx) window.dispatch_action(Box::new(Command::ShowBrowse), cx)
}), }),
) )
.child( .child(
Nav::new("reqs") ListItem::new("reqs")
.prefix(Icon::new(IconName::Invite).small()) .accessibility_label("Requests")
.label("Requests") .h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(
div()
.flex_shrink_0()
.child(Icon::new(IconName::UserPlus).small()),
)
.child(div().flex_1().min_w_0().truncate().child("Requests"))
.text_sm() .text_sm()
.when(request_count > 0, |this| { .when(request_count > 0, |this| {
this.suffix( this.suffix(move |_, cx| {
div().flex_shrink_0().child(
h_flex() h_flex()
.gap_1() .gap_1()
.child( .child(
h_flex() Badge::new()
.py_0p5() .count(request_count)
.px_1() .child(div().size_4()),
.min_w_6()
.justify_center()
.text_xs()
.font_medium()
.text_color(cx.theme().muted_foreground)
.text_center()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary)
.child(SharedString::from(
request_count.to_string(),
)),
) )
.when(self.new_requests && !requests_open, |this| { .when(new_requests && !requests_open, |this| {
this.child( this.child(
div() div()
.size_1() .size_1()
@@ -122,15 +136,24 @@ impl Sidebar {
}), }),
) )
}) })
})
.when(requests_open, |this| this.bg(cx.theme().list_active)) .when(requests_open, |this| this.bg(cx.theme().list_active))
.on_click(cx.listener(|this, _event, _window, cx| { .on_click(cx.listener(|this, _event, _window, cx| {
this.toggle_requests(cx); this.toggle_requests(cx);
})), })),
) )
.child( .child(
Nav::new("contacts") ListItem::new("contacts")
.prefix(Icon::new(IconName::Book).small()) .accessibility_label("Contacts")
.label("Contacts") .h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(
div()
.flex_shrink_0()
.child(Icon::new(IconName::Book).small()),
)
.child(div().flex_1().min_w_0().truncate().child("Contacts"))
.text_sm() .text_sm()
.when(contacts_open, |this| this.bg(cx.theme().list_active)) .when(contacts_open, |this| this.bg(cx.theme().list_active))
.on_click(cx.listener(|this, _event, window, cx| { .on_click(cx.listener(|this, _event, window, cx| {
+87 -51
View File
@@ -5,23 +5,25 @@ use common::TimestampExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement, AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement,
SharedString, Styled, WeakEntity, Window, div, px, SharedString, Styled, WeakEntity, Window, div,
}; };
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::dialog::DialogButtonProps;
use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle}; use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
use gpui_kit::component::list::ListItem;
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem}; use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex}; use gpui_kit::component::tag::Tag;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex};
use nip29::Group; use nip29::Group;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings; use settings::AppSettings;
use ui::{Avatar, IconName}; use ui::Avatar;
use super::Sidebar; use super::Sidebar;
use super::list::ListSection; use super::list::ListSection;
use super::search::MessageHit; use super::search::MessageHit;
use crate::dialogs::{profile, screening}; use crate::dialogs::{profile, screening};
use crate::nav::Nav;
/// Open a room in the dock, screening new requests when enabled. /// Open a room in the dock, screening new requests when enabled.
pub(crate) fn open_room( pub(crate) fn open_room(
@@ -49,7 +51,7 @@ pub(crate) fn open_room(
.button_props( .button_props(
DialogButtonProps::default() DialogButtonProps::default()
.cancel_text("Ignore") .cancel_text("Ignore")
.ok_text("Response"), .ok_text("Respond"),
) )
.on_cancel(move |_event, window, cx| { .on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx); window.dispatch_action(Box::new(ClosePanel), cx);
@@ -92,13 +94,10 @@ impl Sidebar {
) -> Vec<AnyElement> { ) -> Vec<AnyElement> {
let hide_avatar = AppSettings::get_hide_avatar(cx); let hide_avatar = AppSettings::get_hide_avatar(cx);
rows.get(range.clone()) rows.get(range)
.into_iter() .into_iter()
.flatten() .flatten()
.enumerate() .map(|row| {
.map(|(offset, row)| {
let index = range.start + offset;
let element = match row { let element = match row {
SidebarRow::Section(section) => { SidebarRow::Section(section) => {
let section = *section; let section = *section;
@@ -111,18 +110,24 @@ impl Sidebar {
}; };
let icon = if open { let icon = if open {
IconName::CaretDown IconName::ChevronDown
} else { } else {
IconName::CaretRight IconName::ChevronRight
}; };
Nav::new(label) ListItem::new(label)
.label(label) .accessibility_label(label)
.suffix( .h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.child(div().flex_1().min_w_0().truncate().child(label))
.suffix(move |_, cx| {
div().flex_shrink_0().child(
Icon::new(icon) Icon::new(icon)
.small() .small()
.text_color(cx.theme().muted_foreground), .text_color(cx.theme().muted_foreground),
) )
})
.text_xs() .text_xs()
.font_semibold() .font_semibold()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
@@ -143,8 +148,11 @@ impl Sidebar {
SidebarRow::Heading(label) => { SidebarRow::Heading(label) => {
let label = *label; let label = *label;
Nav::new(label) ListItem::new(label)
.label(label) .accessibility_label(label)
.h_8()
.rounded(cx.theme().radius)
.child(div().flex_1().min_w_0().truncate().child(label))
.text_xs() .text_xs()
.font_semibold() .font_semibold()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
@@ -171,29 +179,28 @@ impl Sidebar {
let dock = self.dock.clone(); let dock = self.dock.clone();
let room = room.clone(); let room = room.clone();
let nav = Nav::new(index) let item = ListItem::new(id)
.label(name) .accessibility_label(name.clone())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.text_sm() .text_sm()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
this.prefix( this.child(
div().flex_shrink_0().child(
Avatar::new(room.read(cx).display_image(cx)) Avatar::new(room.read(cx).display_image(cx))
.seed(seed) .seed(seed)
.small(), .small(),
),
) )
}) })
.suffix( .child(div().flex_1().min_w_0().truncate().child(name))
.suffix(move |_, cx| {
div().flex_shrink_0().child(
h_flex() h_flex()
.gap_1() .gap_1()
.when(spam, |this| { .when(spam, |this| {
this.child( this.child(Tag::danger().xsmall().child("Spam"))
div()
.px_1()
.rounded(cx.theme().radius)
.bg(cx.theme().danger.opacity(0.15))
.text_size(px(8.0))
.text_color(cx.theme().danger)
.child("SPAM"),
)
}) })
.when(!spam, |this| { .when(!spam, |this| {
this.child( this.child(
@@ -201,18 +208,19 @@ impl Sidebar {
.font_normal() .font_normal()
.text_xs() .text_xs()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child(created_at), .child(created_at.clone()),
) )
}), }),
) )
})
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx); open_room(&room, &dock, window, cx);
}); });
div() div()
.id(index) .id(id)
.w_full() .w_full()
.child(nav) .child(item)
.context_menu(move |menu, _window, _cx| { .context_menu(move |menu, _window, _cx| {
let menu = menu.item( let menu = menu.item(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }) PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
@@ -241,16 +249,22 @@ impl Sidebar {
let sidebar = cx.entity().downgrade(); let sidebar = cx.entity().downgrade();
let public_key = public_key.to_owned(); let public_key = public_key.to_owned();
Nav::new(index) ListItem::new(public_key.to_hex())
.label(person.name()) .accessibility_label(person.name())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.text_sm() .text_sm()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
this.prefix( this.child(
div().flex_shrink_0().child(
Avatar::new(person.avatar()) Avatar::new(person.avatar())
.seed(person.avatar_seed()) .seed(person.avatar_seed())
.small(), .small(),
),
) )
}) })
.child(div().flex_1().min_w_0().truncate().child(person.name()))
.selected(selected) .selected(selected)
.on_click(move |_event, _window, cx| { .on_click(move |_event, _window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| { if let Err(error) = sidebar.update(cx, |this, cx| {
@@ -265,16 +279,22 @@ impl Sidebar {
let person = self.directory.read(cx).person(public_key, cx); let person = self.directory.read(cx).person(public_key, cx);
let public_key = public_key.to_owned(); let public_key = public_key.to_owned();
Nav::new(index) ListItem::new(public_key.to_hex())
.label(person.name()) .accessibility_label(person.name())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.text_sm() .text_sm()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
this.prefix( this.child(
div().flex_shrink_0().child(
Avatar::new(person.avatar()) Avatar::new(person.avatar())
.seed(person.avatar_seed()) .seed(person.avatar_seed())
.small(), .small(),
),
) )
}) })
.child(div().flex_1().min_w_0().truncate().child(person.name()))
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| {
profile::open(public_key, window, cx); profile::open(public_key, window, cx);
}) })
@@ -298,25 +318,33 @@ impl Sidebar {
.or_else(|| group.read(cx).elsewhere().map(|_| "Moved")) .or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
.map(SharedString::from); .map(SharedString::from);
let nav = Nav::new(index) let item = ListItem::new(tag.clone())
.label(name) .accessibility_label(name.clone())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.text_sm() .text_sm()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
this.prefix( this.child(
div().flex_shrink_0().child(
Avatar::new(group.read(cx).display_image()) Avatar::new(group.read(cx).display_image())
.seed(seed) .seed(seed)
.small(), .small(),
),
) )
}) })
.child(div().flex_1().min_w_0().truncate().child(name))
.when_some(suffix, |this, suffix| { .when_some(suffix, |this, suffix| {
this.suffix( this.suffix(move |_, cx| {
div().flex_shrink_0().child(
div() div()
.font_normal() .font_normal()
.text_xs() .text_xs()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child(suffix), .child(suffix.clone()),
) )
}) })
})
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| {
crate::dock::add_panel_to( crate::dock::add_panel_to(
&dock, &dock,
@@ -334,9 +362,9 @@ impl Sidebar {
}); });
div() div()
.id(index) .id(tag.clone())
.w_full() .w_full()
.child(nav) .child(item)
.context_menu(move |menu, _window, _cx| { .context_menu(move |menu, _window, _cx| {
menu.item( menu.item(
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }) PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" })
@@ -371,24 +399,32 @@ impl Sidebar {
let room = hit.room.clone(); let room = hit.room.clone();
let content = hit.content.clone(); let content = hit.content.clone();
Nav::new(index) ListItem::new(hit.author.to_hex())
.label(content) .accessibility_label(content.clone())
.h_8()
.rounded(cx.theme().radius)
.cursor_pointer()
.text_sm() .text_sm()
.font_medium() .font_medium()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
this.prefix( this.child(
div().flex_shrink_0().child(
Avatar::new(person.avatar()) Avatar::new(person.avatar())
.seed(person.avatar_seed()) .seed(person.avatar_seed())
.small(), .small(),
),
) )
}) })
.suffix( .child(div().flex_1().min_w_0().truncate().child(content))
.suffix(move |_, cx| {
div().flex_shrink_0().child(
div() div()
.font_normal() .font_normal()
.text_xs() .text_xs()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child(detail), .child(detail.clone()),
) )
})
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| {
open_room(&room, &dock, window, cx); open_room(&room, &dock, window, cx);
}) })
+1 -1
View File
@@ -309,7 +309,7 @@ impl Sidebar {
public_key: *public_key, public_key: *public_key,
})); }));
} }
None => rows.push(SidebarRow::Empty("Searching...")), None => rows.push(SidebarRow::Empty("Searching")),
} }
} }