feat: redesign the sidebar and workspace (#53)
Reviewed-on: #53
|
After Width: | Height: | Size: 669 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 571 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8.75 3.75L6.75 20.25M17.25 3.75L15.25 20.25M3.75 7.75H20.25M20.25 16.25H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 258 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 5.5V18.5H19.25C19.9404 18.5 20.5 17.9404 20.5 17.25V6.75C20.5 6.05964 19.9404 5.5 19.25 5.5H9ZM2 6.75C2 5.23122 3.23122 4 4.75 4H19.25C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.75 4C3.23122 4 2 5.23122 2 6.75V17.25C2 18.7688 3.23122 20 4.75 20H19.25C20.7688 20 22 18.7688 22 17.25V6.75C22 5.23122 20.7688 4 19.25 4H4.75ZM3.5 6.75C3.5 6.05964 4.05964 5.5 4.75 5.5H10.5V18.5H4.75C4.05964 18.5 3.5 17.9404 3.5 17.25V6.75Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M7 9.5C6.44772 9.5 6 9.05228 6 8.5C6 7.94772 6.44772 7.5 7 7.5C7.55228 7.5 8 7.94772 8 8.5C8 9.05228 7.55228 9.5 7 9.5ZM7 13C6.44772 13 6 12.5523 6 12C6 11.4477 6.44772 11 7 11C7.55228 11 8 11.4477 8 12C8 12.5523 7.55228 13 7 13ZM7 16.5C6.44772 16.5 6 16.0523 6 15.5C6 14.9477 6.44772 14.5 7 14.5C7.55228 14.5 8 14.9477 8 15.5C8 16.0523 7.55228 16.5 7 16.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 826 B |
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8.25 5V12V19" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M11.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V17.25C2.75 18.3546 3.64543 19.25 4.75 19.25H11.25M11.25 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H11.25M11.25 4.75V19.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/><path d="M6.25 8.5C6.25 8.91421 6.58579 9.25 7 9.25C7.41421 9.25 7.75 8.91421 7.75 8.5C7.75 8.08579 7.41421 7.75 7 7.75C6.58579 7.75 6.25 8.08579 6.25 8.5ZM6.25 12C6.25 12.4142 6.58579 12.75 7 12.75C7.41421 12.75 7.75 12.4142 7.75 12C7.75 11.5858 7.41421 11.25 7 11.25C6.58579 11.25 6.25 11.5858 6.25 12ZM6.25 15.5C6.25 15.9142 6.58579 16.25 7 16.25C7.41421 16.25 7.75 15.9142 7.75 15.5C7.75 15.0858 7.41421 14.75 7 14.75C6.58579 14.75 6.25 15.0858 6.25 15.5Z" fill="currentColor" stroke="currentColor" stroke-width="0.5"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 931 B |
@@ -31,7 +31,7 @@ fn uses_managed_updates() -> bool {
|
||||
// Allow opting out of in-app updates via an explicit environment variable.
|
||||
|| std::env::var(COOP_UPDATE_EXPLANATION).is_ok()
|
||||
// The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in).
|
||||
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap")
|
||||
|| std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value | value == "snap")
|
||||
}
|
||||
|
||||
/// Initialize the auto-update system.
|
||||
@@ -129,6 +129,11 @@ impl AutoUpdater {
|
||||
matches!(self.status, UpdateStatus::Idle)
|
||||
}
|
||||
|
||||
/// Whether the running version is the newest release, so the status line can be hidden.
|
||||
pub fn up_to_date(&self) -> bool {
|
||||
matches!(self.status, UpdateStatus::Idle | UpdateStatus::UpToDate)
|
||||
}
|
||||
|
||||
/// Whether a verified update is installed and waiting for a restart.
|
||||
pub fn staged(&self) -> bool {
|
||||
matches!(self.status, UpdateStatus::Staged(_))
|
||||
|
||||
@@ -9,7 +9,7 @@ use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||
WeakEntity, Window,
|
||||
WeakEntity,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -37,15 +37,8 @@ impl Global for GlobalChatRegistry {}
|
||||
/// Chat event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ChatEvent {
|
||||
/// An event to open a room by its ID
|
||||
OpenRoom(u64),
|
||||
/// An event to close a room by its ID
|
||||
CloseRoom(u64),
|
||||
/// An event to notify UI about a new chat request
|
||||
Ping,
|
||||
/// No Inbox Relays found, the app is not ready to subscribe messages
|
||||
InboxRelayNotFound,
|
||||
/// An error occurred
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -543,34 +536,16 @@ impl ChatRegistry {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Emit an open room event.
|
||||
///
|
||||
/// If the room is new, add it to the registry.
|
||||
pub fn emit_room(&mut self, room: &Entity<Room>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Get the room's ID.
|
||||
/// Track a room so it is listed and can be looked up by id.
|
||||
pub fn track_room(&mut self, room: &Entity<Room>, cx: &mut Context<Self>) {
|
||||
let id = room.read(cx).id;
|
||||
|
||||
// If the room is new, add it to the registry and index.
|
||||
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
||||
let entity = room.to_owned();
|
||||
e.insert(entity.clone());
|
||||
|
||||
self.rooms.insert(0, entity);
|
||||
}
|
||||
|
||||
// Emit the open room event deferred to avoid re-entrant reads
|
||||
cx.defer_in(window, move |_this, _window, cx| {
|
||||
cx.emit(ChatEvent::OpenRoom(id));
|
||||
});
|
||||
}
|
||||
|
||||
/// Close a room.
|
||||
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.room_index.contains_key(&id) {
|
||||
self.room_index.remove(&id);
|
||||
self.rooms.retain(|r| r.read(cx).id != id);
|
||||
cx.defer_in(window, move |_this, _window, cx| {
|
||||
cx.emit(ChatEvent::CloseRoom(id));
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,4 @@ futures.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
linkify = "0.10.0"
|
||||
pulldown-cmark = "0.13.1"
|
||||
regex = "1"
|
||||
|
||||
|
||||
@@ -31,13 +31,11 @@ use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||
use ui::menu::DropdownMenu;
|
||||
use ui::message::{MessageRow, WelcomeMessage};
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::tooltip::Tooltip;
|
||||
use ui::{
|
||||
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
|
||||
h_flex, v_flex,
|
||||
};
|
||||
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::file::*;
|
||||
use crate::text::RenderedText;
|
||||
@@ -45,6 +43,10 @@ use crate::text::RenderedText;
|
||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||
const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"];
|
||||
|
||||
/// Notice shown when a conversation has no messages, and above the message list.
|
||||
const PRIVATE_NOTICE: &str =
|
||||
"This conversation is private. Only members can see each other's messages.";
|
||||
|
||||
/// Regex matching strings that consist entirely of emoji characters,
|
||||
/// zero-width joiners, variation selectors, and keycap combiners.
|
||||
static EMOJI_RE: LazyLock<Regex> =
|
||||
@@ -126,9 +128,8 @@ impl ChatPanel {
|
||||
let replies_to = cx.new(|_| HashSet::new());
|
||||
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
||||
|
||||
// Define list of messages
|
||||
let messages = Vec::new();
|
||||
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
|
||||
let list_state = ListState::new(messages.len() + 1, ListAlignment::Bottom, px(1024.));
|
||||
|
||||
// Get room id and name
|
||||
let (id, name) = room
|
||||
@@ -577,7 +578,6 @@ impl ChatPanel {
|
||||
where
|
||||
E: Into<Message>,
|
||||
{
|
||||
let old_len = self.messages.len();
|
||||
let msg: Message = m.into();
|
||||
|
||||
if let Err(pos) = self.messages.binary_search(&msg) {
|
||||
@@ -586,7 +586,8 @@ impl ChatPanel {
|
||||
for (i, message) in self.messages.iter().enumerate().skip(pos) {
|
||||
self.message_index.insert(message.id, i);
|
||||
}
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
let len = self.list_state.item_count();
|
||||
self.list_state.splice(len..len, 1);
|
||||
|
||||
if scroll {
|
||||
self.list_state.scroll_to(ListOffset {
|
||||
@@ -657,7 +658,8 @@ impl ChatPanel {
|
||||
/// Scroll to a message by its ID
|
||||
fn scroll_to(&self, id: &EventId) {
|
||||
if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) {
|
||||
self.list_state.scroll_to_reveal_item(ix);
|
||||
// The welcome message occupies the first row, so message rows sit one ahead.
|
||||
self.list_state.scroll_to_reveal_item(ix + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1051,10 +1053,19 @@ impl ChatPanel {
|
||||
cx.open_url(&content);
|
||||
}
|
||||
|
||||
fn render_announcement(&self, cx: &Context<Self>) -> AnyElement {
|
||||
const MSG: &str =
|
||||
"This conversation is private. Only members can see each other's messages.";
|
||||
fn render_welcome(&self, cx: &Context<Self>) -> AnyElement {
|
||||
WelcomeMessage::new("welcome")
|
||||
.icon(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_12()
|
||||
.text_color(cx.theme().ghost_element_background_alt),
|
||||
)
|
||||
.message(PRIVATE_NOTICE)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_announcement(&self, cx: &Context<Self>) -> AnyElement {
|
||||
v_flex()
|
||||
.h_40()
|
||||
.w_full()
|
||||
@@ -1072,7 +1083,7 @@ impl ChatPanel {
|
||||
.size_12()
|
||||
.text_color(cx.theme().ghost_element_active),
|
||||
)
|
||||
.child(MSG)
|
||||
.child(PRIVATE_NOTICE)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -1141,7 +1152,14 @@ impl ChatPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let file = self.messages.get(ix).and_then(|message| {
|
||||
if ix == 0 {
|
||||
return self.render_welcome(cx);
|
||||
}
|
||||
|
||||
// The welcome message occupies the first row, so message rows sit one ahead.
|
||||
let index = ix - 1;
|
||||
|
||||
let file = self.messages.get(index).and_then(|message| {
|
||||
let file = message.file.clone()?;
|
||||
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
|
||||
.then_some((message.id, file))
|
||||
@@ -1151,14 +1169,14 @@ impl ChatPanel {
|
||||
self.load_file(id, file, cx);
|
||||
}
|
||||
|
||||
if let Some(message) = self.messages.get(ix) {
|
||||
if let Some(message) = self.messages.get(index) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let show_author = self.is_group_start(ix);
|
||||
let show_author = self.is_group_start(index);
|
||||
let text = self
|
||||
.rendered_texts_by_id
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| {
|
||||
RenderedText::new(&message.content, &message.mentions, &persons, true, cx)
|
||||
text::rendered_text(&message.content, &message.mentions, &persons, true, cx)
|
||||
})
|
||||
.element(ix.into(), window, cx);
|
||||
|
||||
@@ -1188,68 +1206,38 @@ impl ChatPanel {
|
||||
// Hide avatar setting
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
div()
|
||||
.id(ix)
|
||||
.group("")
|
||||
.relative()
|
||||
.w_full()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.when(!hide_avatar, |this| {
|
||||
if show_author {
|
||||
this.child(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0()
|
||||
.relative()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.menu("Public Key", Box::new(Command::Copy(pk)))
|
||||
.menu("View Relays", Box::new(Command::Relays(pk)))
|
||||
.separator()
|
||||
.menu("View on njump.me", Box::new(Command::Njump(pk)))
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this.child(div().flex_shrink_0().w(px(32.)))
|
||||
}
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.when(show_author, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(div().font_semibold().child(author.name()))
|
||||
.child(message.created_at.to_human_time())
|
||||
.when(has_reports, |this| {
|
||||
this.child(self.render_sent_reports(&id, cx))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(has_replies, |this| {
|
||||
this.children(self.render_message_replies(replies, cx))
|
||||
})
|
||||
.when(message.file.is_none(), |this| this.child(rendered_text))
|
||||
.child(self.render_media(&message.media, cx))
|
||||
.when_some(message.file.as_ref(), |this, file| {
|
||||
this.child(self.render_message_file(&id, file, cx))
|
||||
})
|
||||
.when(has_reactions, |this| {
|
||||
this.child(self.render_reactions(&id, cx))
|
||||
}),
|
||||
),
|
||||
MessageRow::new(ix)
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0()
|
||||
.relative()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.menu("Public Key", Box::new(Command::Copy(pk)))
|
||||
.menu("View Relays", Box::new(Command::Relays(pk)))
|
||||
.separator()
|
||||
.menu("View on njump.me", Box::new(Command::Njump(pk)))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
.author(author.name())
|
||||
.timestamp(message.created_at.to_human_time())
|
||||
.when(has_reports, |this| {
|
||||
this.header_extra(self.render_sent_reports(&id, cx))
|
||||
})
|
||||
.when(has_replies, |this| {
|
||||
this.children(self.render_message_replies(replies, cx))
|
||||
})
|
||||
.when(message.file.is_none(), |this| this.child(rendered_text))
|
||||
.child(self.render_media(&message.media, cx))
|
||||
.when_some(message.file.as_ref(), |this, file| {
|
||||
this.child(self.render_message_file(&id, file, cx))
|
||||
})
|
||||
.when(has_reactions, |this| {
|
||||
this.child(self.render_reactions(&id, cx))
|
||||
})
|
||||
.overlay(
|
||||
div()
|
||||
.group_hover("", |this| this.bg(cx.theme().element_active))
|
||||
.absolute()
|
||||
@@ -1259,7 +1247,7 @@ impl ChatPanel {
|
||||
.h_full()
|
||||
.bg(cx.theme().border_transparent),
|
||||
)
|
||||
.child(self.render_actions(&id, &pk, cx))
|
||||
.overlay(self.render_actions(&id, &pk, cx))
|
||||
.on_mouse_down(
|
||||
MouseButton::Middle,
|
||||
cx.listener(move |this, _, _window, cx| {
|
||||
@@ -1269,7 +1257,6 @@ impl ChatPanel {
|
||||
.on_double_click(cx.listener(move |this, _, _window, cx| {
|
||||
this.reply_to(&id, cx);
|
||||
}))
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -1983,12 +1970,13 @@ impl Panel for ChatPanel {
|
||||
let seed = this.display_image_seed(cx);
|
||||
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(Avatar::new(picture).seed(seed).xsmall())
|
||||
.child(label)
|
||||
.into_any_element()
|
||||
})
|
||||
.unwrap_or(div().child("Unknown").into_any_element())
|
||||
.unwrap_or(div().text_xs().child("Unknown").into_any_element())
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
|
||||
@@ -1,488 +1,28 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use chat::Mention;
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||
};
|
||||
use gpui::{App, Entity};
|
||||
use person::PersonRegistry;
|
||||
use regex::Regex;
|
||||
use theme::ActiveTheme;
|
||||
use ui::markdown::InlineReplacement;
|
||||
pub use ui::markdown::RenderedText;
|
||||
|
||||
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
|
||||
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
Code,
|
||||
InlineCode(bool),
|
||||
Highlight(HighlightStyle),
|
||||
Mention,
|
||||
}
|
||||
|
||||
impl From<HighlightStyle> for Highlight {
|
||||
fn from(style: HighlightStyle) -> Self {
|
||||
Self::Highlight(style)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RenderedText {
|
||||
pub text: SharedString,
|
||||
pub highlights: Vec<(Range<usize>, Highlight)>,
|
||||
pub link_ranges: Vec<Range<usize>>,
|
||||
pub link_urls: Arc<[String]>,
|
||||
}
|
||||
|
||||
impl RenderedText {
|
||||
pub fn new(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
persons: &Entity<PersonRegistry>,
|
||||
markdown: bool,
|
||||
cx: &App,
|
||||
) -> Self {
|
||||
Self::render(content, mentions, markdown, |mention| {
|
||||
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name())
|
||||
})
|
||||
}
|
||||
|
||||
fn render(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
markdown: bool,
|
||||
resolve_mention: impl Fn(&Mention) -> String,
|
||||
) -> Self {
|
||||
let mut text = String::new();
|
||||
let mut highlights = Vec::new();
|
||||
let mut link_ranges = Vec::new();
|
||||
let mut link_urls = Vec::new();
|
||||
|
||||
render_text_mut(
|
||||
content,
|
||||
mentions,
|
||||
&mut text,
|
||||
&mut highlights,
|
||||
&mut link_ranges,
|
||||
&mut link_urls,
|
||||
markdown,
|
||||
resolve_mention,
|
||||
);
|
||||
|
||||
// Trim trailing whitespace and adjust highlight and link ranges.
|
||||
let trimmed_len = text.trim_end().len();
|
||||
|
||||
// Retain highlights and link ranges that are within the trimmed text.
|
||||
if trimmed_len < text.len() {
|
||||
highlights.retain_mut(|(range, _)| {
|
||||
range.end = range.end.min(trimmed_len);
|
||||
range.start < range.end
|
||||
});
|
||||
|
||||
let mut ix = 0;
|
||||
|
||||
while ix < link_ranges.len() {
|
||||
let range = &mut link_ranges[ix];
|
||||
range.end = range.end.min(trimmed_len);
|
||||
if range.start < range.end {
|
||||
ix += 1;
|
||||
} else {
|
||||
link_ranges.remove(ix);
|
||||
link_urls.remove(ix);
|
||||
}
|
||||
}
|
||||
|
||||
text.truncate(trimmed_len);
|
||||
}
|
||||
|
||||
RenderedText {
|
||||
text: SharedString::from(text),
|
||||
link_urls: link_urls.into(),
|
||||
link_ranges,
|
||||
highlights,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||
let code_background = cx.theme().elevated_surface_background;
|
||||
let color = cx.theme().text_accent;
|
||||
let code_font = if cfg!(target_os = "macos") {
|
||||
"Menlo"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Consolas"
|
||||
} else {
|
||||
"monospace"
|
||||
};
|
||||
|
||||
InteractiveText::new(
|
||||
id,
|
||||
StyledText::new(self.text.clone())
|
||||
.with_default_highlights(
|
||||
&window.text_style(),
|
||||
self.highlights.iter().map(|(range, highlight)| {
|
||||
(
|
||||
range.clone(),
|
||||
match highlight {
|
||||
Highlight::Code => HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::InlineCode(link) => {
|
||||
if *link {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
Highlight::Mention => HighlightStyle {
|
||||
color: Some(color),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Highlight(highlight) => *highlight,
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.with_font_family_overrides(self.highlights.iter().filter_map(
|
||||
|(range, highlight)| match highlight {
|
||||
Highlight::Code | Highlight::InlineCode(_) => {
|
||||
Some((range.clone(), code_font.into()))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
)),
|
||||
)
|
||||
.on_click(self.link_ranges.clone(), {
|
||||
let link_urls = self.link_urls.clone();
|
||||
move |ix, _, cx| {
|
||||
let url = &link_urls[ix];
|
||||
if WEB_URL.is_match(url) {
|
||||
cx.open_url(url);
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_text_mut(
|
||||
block: &str,
|
||||
mut mentions: &[Mention],
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
/// Render message `content` to text, replacing mentions with their display names.
|
||||
///
|
||||
/// When `markdown` is set the content is parsed as markdown.
|
||||
pub fn rendered_text(
|
||||
content: &str,
|
||||
mentions: &[Mention],
|
||||
persons: &Entity<PersonRegistry>,
|
||||
markdown: bool,
|
||||
resolve_mention: impl Fn(&Mention) -> String,
|
||||
) {
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
cx: &App,
|
||||
) -> RenderedText {
|
||||
let replacements = mentions
|
||||
.iter()
|
||||
.map(|mention| {
|
||||
InlineReplacement::new(
|
||||
mention.range.clone(),
|
||||
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name()),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut bold_depth = 0;
|
||||
let mut italic_depth = 0;
|
||||
let mut strikethrough_depth = 0;
|
||||
let mut link_url = None;
|
||||
let mut list_stack = Vec::new();
|
||||
let mut code_block = false;
|
||||
|
||||
// Only enable the extensions that make sense for chat messages. Notably this leaves
|
||||
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
|
||||
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
|
||||
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
|
||||
} else {
|
||||
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
|
||||
};
|
||||
|
||||
for (event, source_range) in events {
|
||||
let prev_len = text.len();
|
||||
|
||||
match event {
|
||||
Event::Text(t) => {
|
||||
if code_block {
|
||||
text.push_str(t.as_ref());
|
||||
highlights.push((prev_len..text.len(), Highlight::Code));
|
||||
continue;
|
||||
}
|
||||
|
||||
let t_str = t.as_ref();
|
||||
let mut last_processed = 0;
|
||||
|
||||
while let Some(mention) = mentions.first() {
|
||||
if mention.range.start >= source_range.end {
|
||||
break;
|
||||
}
|
||||
|
||||
mentions = &mentions[1..];
|
||||
if mention.range.start < source_range.start
|
||||
|| mention.range.end > source_range.end
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(token) = block.get(mention.range.clone()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(offset) = t_str[last_processed..].find(token) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mention_start_in_text = last_processed + offset;
|
||||
let mention_end_in_text = mention_start_in_text + token.len();
|
||||
|
||||
// Add text before this mention
|
||||
if mention_start_in_text > last_processed {
|
||||
let before_mention = &t_str[last_processed..mention_start_in_text];
|
||||
process_text_segment(
|
||||
before_mention,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
|
||||
// Process the mention replacement
|
||||
let replacement_text = resolve_mention(mention);
|
||||
let replacement_start = text.len();
|
||||
text.push_str(&replacement_text);
|
||||
let replacement_end = text.len();
|
||||
|
||||
highlights.push((replacement_start..replacement_end, Highlight::Mention));
|
||||
|
||||
last_processed = mention_end_in_text;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last mention
|
||||
if last_processed < t_str.len() {
|
||||
let remaining_text = &t_str[last_processed..];
|
||||
process_text_segment(
|
||||
remaining_text,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Code(t) => {
|
||||
text.push_str(t.as_ref());
|
||||
let is_link = link_url.is_some();
|
||||
|
||||
if let Some(link_url) = link_url.clone() {
|
||||
link_ranges.push(prev_len..text.len());
|
||||
link_urls.push(link_url);
|
||||
}
|
||||
|
||||
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
|
||||
}
|
||||
Event::Start(tag) => match tag {
|
||||
Tag::Paragraph => new_paragraph(text, &mut list_stack),
|
||||
Tag::Heading { .. } => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
bold_depth += 1;
|
||||
}
|
||||
Tag::CodeBlock(_kind) => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
code_block = true;
|
||||
}
|
||||
Tag::Emphasis => italic_depth += 1,
|
||||
Tag::Strong => bold_depth += 1,
|
||||
Tag::Strikethrough => strikethrough_depth += 1,
|
||||
Tag::Link { dest_url, .. } => {
|
||||
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
|
||||
}
|
||||
Tag::List(number) => {
|
||||
list_stack.push((number, false));
|
||||
}
|
||||
Tag::Item => {
|
||||
let len = list_stack.len();
|
||||
if let Some((list_number, has_content)) = list_stack.last_mut() {
|
||||
*has_content = false;
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
for _ in 0..len - 1 {
|
||||
text.push_str(" ");
|
||||
}
|
||||
if let Some(number) = list_number {
|
||||
text.push_str(&format!("{}. ", number));
|
||||
*number += 1;
|
||||
*has_content = false;
|
||||
} else {
|
||||
text.push_str("- ");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::End(tag) => match tag {
|
||||
TagEnd::CodeBlock => code_block = false,
|
||||
TagEnd::Heading(_) => bold_depth -= 1,
|
||||
TagEnd::Emphasis => italic_depth -= 1,
|
||||
TagEnd::Strong => bold_depth -= 1,
|
||||
TagEnd::Strikethrough => strikethrough_depth -= 1,
|
||||
TagEnd::Link => link_url = None,
|
||||
TagEnd::List(_) => drop(list_stack.pop()),
|
||||
_ => {}
|
||||
},
|
||||
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
|
||||
Event::Rule => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
text.push_str("────────\n");
|
||||
}
|
||||
Event::HardBreak => text.push('\n'),
|
||||
Event::SoftBreak => text.push('\n'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_text_segment(
|
||||
segment: &str,
|
||||
bold_depth: i32,
|
||||
italic_depth: i32,
|
||||
strikethrough_depth: i32,
|
||||
link_url: Option<String>,
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
) {
|
||||
// Build the style for this segment
|
||||
let mut style = HighlightStyle::default();
|
||||
if bold_depth > 0 {
|
||||
style.font_weight = Some(FontWeight::BOLD);
|
||||
}
|
||||
if italic_depth > 0 {
|
||||
style.font_style = Some(FontStyle::Italic);
|
||||
}
|
||||
if strikethrough_depth > 0 {
|
||||
style.strikethrough = Some(StrikethroughStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Ranges always refer to the rendered text, including replaced mentions.
|
||||
let segment_start = text.len();
|
||||
text.push_str(segment);
|
||||
let text_end = text.len();
|
||||
|
||||
if let Some(link_url) = link_url {
|
||||
// Handle as a markdown link
|
||||
link_ranges.push(segment_start..text_end);
|
||||
link_urls.push(link_url);
|
||||
style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Add highlight for the entire linked segment
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((segment_start..text_end, Highlight::Highlight(style)));
|
||||
}
|
||||
} else {
|
||||
// Handle link detection within the segment
|
||||
let mut finder = linkify::LinkFinder::new();
|
||||
finder.kinds(&[linkify::LinkKind::Url]);
|
||||
let mut last_link_pos = 0;
|
||||
|
||||
for link in finder
|
||||
.links(segment)
|
||||
.filter(|link| WEB_URL.is_match(link.as_str()))
|
||||
{
|
||||
let start = link.start();
|
||||
let end = link.end();
|
||||
|
||||
// Add non-link text before this link
|
||||
if start > last_link_pos {
|
||||
let non_link_start = segment_start + last_link_pos;
|
||||
let non_link_end = segment_start + start;
|
||||
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((non_link_start..non_link_end, Highlight::Highlight(style)));
|
||||
}
|
||||
}
|
||||
|
||||
// Add the link
|
||||
let range = (segment_start + start)..(segment_start + end);
|
||||
link_ranges.push(range.clone());
|
||||
link_urls.push(link.as_str().to_string());
|
||||
|
||||
// Apply link styling (underline + existing style)
|
||||
let mut link_style = style;
|
||||
link_style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
highlights.push((range, Highlight::Highlight(link_style)));
|
||||
|
||||
last_link_pos = end;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last link
|
||||
if last_link_pos < segment.len() {
|
||||
let remaining_start = segment_start + last_link_pos;
|
||||
let remaining_end = segment_start + segment.len();
|
||||
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((remaining_start..remaining_end, Highlight::Highlight(style)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||
let mut is_subsequent_paragraph_of_list = false;
|
||||
|
||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||
if *has_content {
|
||||
is_subsequent_paragraph_of_list = true;
|
||||
} else {
|
||||
*has_content = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
|
||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||
text.push_str(" ");
|
||||
}
|
||||
|
||||
if is_subsequent_paragraph_of_list {
|
||||
text.push_str(" ");
|
||||
}
|
||||
RenderedText::new(content, &replacements, markdown)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use concord::cord06::RekeyScope;
|
||||
use concord::derive::{channel_group_key, grant_locator};
|
||||
use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, HeldRoot};
|
||||
use concord::{ChannelId, CommunityId, Epoch};
|
||||
use gpui::{App, AppContext, Context, EventEmitter, Task};
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
@@ -107,8 +107,6 @@ impl SubscriptionKey {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CommunityEvent {
|
||||
Updated(CommunityId),
|
||||
Open(CommunityId),
|
||||
Close(CommunityId),
|
||||
Channel(CommunityId, ChannelId),
|
||||
/// History exists here that no held key can open.
|
||||
Unreadable(CommunityId),
|
||||
@@ -180,15 +178,16 @@ impl Community {
|
||||
&self.state
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
pub fn name(&self) -> SharedString {
|
||||
if let Some(metadata) = &self.control.community {
|
||||
return metadata.name.clone();
|
||||
return SharedString::from(&metadata.name);
|
||||
}
|
||||
|
||||
self.state
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.state.id.to_hex())
|
||||
.as_ref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from("Unknown"))
|
||||
}
|
||||
|
||||
pub fn control(&self) -> &ControlFold {
|
||||
|
||||
@@ -7,7 +7,7 @@ pub use concord::cord03::{ChatMessage, ReplyRef};
|
||||
use concord::state::CommunityState;
|
||||
pub use concord::{ChannelId, CommunityId, Epoch};
|
||||
use futures::future::{Either, select};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
@@ -225,27 +225,6 @@ impl CommunityRegistry {
|
||||
self.index.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Ask the workspace to open a community's panel.
|
||||
pub fn emit_community(
|
||||
&mut self,
|
||||
community: &Entity<Community>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = community.read(cx).id();
|
||||
|
||||
cx.defer_in(window, move |_this, _window, cx| {
|
||||
cx.emit(CommunityEvent::Open(id));
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask the workspace to close a community's panel.
|
||||
pub fn emit_close(&mut self, id: CommunityId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.defer_in(window, move |_this, _window, cx| {
|
||||
cx.emit(CommunityEvent::Close(id));
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a community owned by the current account and begin tracking it.
|
||||
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -19,6 +19,8 @@ use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, Textarea, TextareaState};
|
||||
use ui::markdown::RenderedText;
|
||||
use ui::message::WelcomeMessage;
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||
@@ -97,6 +99,8 @@ pub struct CommunityPanel {
|
||||
channel: Option<ChannelId>,
|
||||
/// The selected channel's timeline (oldest first)
|
||||
rows: Vec<ChatMessage>,
|
||||
/// Rendered markdown content, keyed by message id and dropped when a row changes
|
||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||
/// Whether the store holds rows older than `rows`
|
||||
has_more: bool,
|
||||
/// A round or a page read is in flight
|
||||
@@ -145,53 +149,49 @@ impl CommunityPanel {
|
||||
window,
|
||||
|_this, _community, event, window, cx| {
|
||||
match event {
|
||||
CommunityEvent::Updated(_)
|
||||
| CommunityEvent::Unreadable(_)
|
||||
| CommunityEvent::Failed(_) => {
|
||||
cx.defer_in(window, |this, window, cx| this.reload(window, cx));
|
||||
}
|
||||
CommunityEvent::Channel(..) => {
|
||||
cx.defer_in(window, |this, window, cx| this.load(window, cx));
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
});
|
||||
}
|
||||
CommunityEvent::Error(error) => {
|
||||
window.push_notification(
|
||||
Notification::error(error.clone()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
_ => {
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.reload(window, cx);
|
||||
});
|
||||
}
|
||||
CommunityEvent::Open(_) | CommunityEvent::Close(_) => {}
|
||||
};
|
||||
},
|
||||
));
|
||||
|
||||
let panel = Self {
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.list_state.set_follow_mode(FollowMode::Tail);
|
||||
this.list_state.set_scroll_handler(cx.listener(
|
||||
|this, event: &ListScrollEvent, window, cx| {
|
||||
if event.visible_range.start <= LOAD_OLDER_THRESHOLD {
|
||||
this.load_older(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
this.load(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
id,
|
||||
focus_handle: cx.focus_handle(),
|
||||
community: community.downgrade(),
|
||||
channel,
|
||||
rows: Vec::new(),
|
||||
rendered_texts_by_id: BTreeMap::new(),
|
||||
has_more: false,
|
||||
loading: false,
|
||||
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
|
||||
input,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
panel.list_state.set_follow_mode(FollowMode::Tail);
|
||||
panel.list_state.set_scroll_handler(cx.listener(
|
||||
|this, event: &ListScrollEvent, window, cx| {
|
||||
if event.visible_range.start <= LOAD_OLDER_THRESHOLD {
|
||||
this.load_older(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
});
|
||||
|
||||
panel
|
||||
}
|
||||
}
|
||||
|
||||
/// The channel to show, following the community's selection.
|
||||
@@ -205,17 +205,18 @@ impl CommunityPanel {
|
||||
if channel != self.channel {
|
||||
self.channel = channel;
|
||||
self.rows.clear();
|
||||
self.rendered_texts_by_id.clear();
|
||||
self.has_more = false;
|
||||
self.loading = false;
|
||||
self.list_state.reset(1);
|
||||
self.list_state.reset(2);
|
||||
}
|
||||
|
||||
channel
|
||||
}
|
||||
|
||||
/// The list's item count: the load-older row, then every message row.
|
||||
/// The list's item count: the welcome row, the load-older row, then every message row.
|
||||
fn item_count(&self) -> usize {
|
||||
self.rows.len() + 1
|
||||
self.rows.len() + 2
|
||||
}
|
||||
|
||||
/// A timeline read, `before_ms` exclusive, or `None` for the newest rows.
|
||||
@@ -494,6 +495,7 @@ impl CommunityPanel {
|
||||
|
||||
if !connected {
|
||||
self.rows = messages;
|
||||
self.rendered_texts_by_id.clear();
|
||||
self.has_more = has_more;
|
||||
self.list_state.reset(self.item_count());
|
||||
cx.notify();
|
||||
@@ -528,7 +530,13 @@ impl CommunityPanel {
|
||||
|
||||
for message in messages {
|
||||
match shown.get(&message.id).copied() {
|
||||
Some(ix) => self.rows[ix] = message,
|
||||
Some(ix) => {
|
||||
// Drop the cached render when the text changes, so edits re-parse.
|
||||
if self.rows[ix].content != message.content {
|
||||
self.rendered_texts_by_id.remove(&message.id);
|
||||
}
|
||||
self.rows[ix] = message;
|
||||
}
|
||||
None => fresh.push(message),
|
||||
}
|
||||
}
|
||||
@@ -544,7 +552,8 @@ impl CommunityPanel {
|
||||
|
||||
shown.insert(message.id, at);
|
||||
self.rows.insert(at, message);
|
||||
self.list_state.splice(at + 1..at + 1, 1);
|
||||
// The welcome and load-older rows sit above the messages.
|
||||
self.list_state.splice(at + 2..at + 2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +635,29 @@ impl CommunityPanel {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The row at index 0: the affordance that pages older history in.
|
||||
/// The row at index 0: the welcome message for the channel.
|
||||
fn render_welcome(&self, cx: &Context<Self>) -> AnyElement {
|
||||
let (name, avatar) = self
|
||||
.community
|
||||
.read_with(cx, |community, _cx| {
|
||||
let seed = community.id().to_hex();
|
||||
let avatar = match community.icon() {
|
||||
Some(path) => Avatar::from_source(path).seed(seed).large(),
|
||||
None => Avatar::new(None).seed(seed).large(),
|
||||
};
|
||||
|
||||
(community.name(), avatar)
|
||||
})
|
||||
.unwrap_or_else(|_| (SharedString::from("this community"), Avatar::new(None)));
|
||||
|
||||
WelcomeMessage::new("welcome")
|
||||
.icon(avatar)
|
||||
.title(format!("Welcome to {}", name))
|
||||
.message(format!("This is the start of the {} channel.", name))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The row at index 1: the affordance that pages older history in.
|
||||
fn render_older(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if !self.has_more {
|
||||
return div().into_any_element();
|
||||
@@ -668,20 +699,34 @@ impl CommunityPanel {
|
||||
fn render_message(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
if ix == 0 {
|
||||
return self.render_welcome(cx);
|
||||
}
|
||||
|
||||
if ix == 1 {
|
||||
return self.render_older(cx);
|
||||
}
|
||||
|
||||
let Some(message) = self.rows.get(ix - 1) else {
|
||||
// The welcome and load-older rows sit above the messages.
|
||||
let Some(message) = self.rows.get(ix - 2) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let show_author = self.opens_run(ix - 1);
|
||||
let show_author = self.opens_run(ix - 2);
|
||||
|
||||
message::render(ix, message, show_author, cx)
|
||||
let content = if message.deleted {
|
||||
message::deleted(cx)
|
||||
} else {
|
||||
self.rendered_texts_by_id
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| RenderedText::new(&message.content, &[], true))
|
||||
.element(ix.into(), window, cx)
|
||||
};
|
||||
|
||||
message::render(ix, message, content, show_author, cx)
|
||||
}
|
||||
|
||||
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
@@ -723,12 +768,13 @@ impl Panel for CommunityPanel {
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(avatar)
|
||||
.child(SharedString::from(community.name()))
|
||||
.child(community.name())
|
||||
.into_any_element()
|
||||
})
|
||||
.unwrap_or_else(|_| div().child("Unknown").into_any_element())
|
||||
.unwrap_or_else(|_| div().text_xs().child("Unknown").into_any_element())
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
|
||||
@@ -3,81 +3,51 @@ use std::collections::BTreeMap;
|
||||
use common::TimestampExt;
|
||||
use community::ChatMessage;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, px,
|
||||
};
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, div};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::{StyledExt, h_flex, v_flex};
|
||||
use ui::h_flex;
|
||||
use ui::message::MessageRow;
|
||||
|
||||
pub(crate) fn render(ix: usize, message: &ChatMessage, show_author: bool, cx: &App) -> AnyElement {
|
||||
pub(crate) fn render(
|
||||
ix: usize,
|
||||
message: &ChatMessage,
|
||||
content: AnyElement,
|
||||
show_author: bool,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let author = persons.read(cx).get(&message.author, cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
div()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_3()
|
||||
.when(!hide_avatar, |this| {
|
||||
if show_author {
|
||||
this.child(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
} else {
|
||||
this.child(div().flex_shrink_0().w(px(32.)))
|
||||
}
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.when(show_author, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(div().font_semibold().child(author.name()))
|
||||
.child(
|
||||
Timestamp::from_secs(message.at_ms / 1000).to_human_time(),
|
||||
)
|
||||
.when(message.edited_at.is_some(), |this| {
|
||||
this.child(div().child("(edited)"))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.child(content(message, cx))
|
||||
.when(!message.reactions.is_empty(), |this| {
|
||||
this.child(reactions(message, cx))
|
||||
}),
|
||||
),
|
||||
MessageRow::new(ix)
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(author.name())
|
||||
.timestamp(Timestamp::from_secs(message.at_ms / 1000).to_human_time())
|
||||
.when(message.edited_at.is_some(), |this| {
|
||||
this.header_extra(div().child("(edited)"))
|
||||
})
|
||||
.child(content)
|
||||
.when(!message.reactions.is_empty(), |this| {
|
||||
this.child(reactions(message, cx))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn content(message: &ChatMessage, cx: &App) -> AnyElement {
|
||||
if message.deleted {
|
||||
return div()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.child("Message deleted")
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
/// The placeholder shown in place of the body of a deleted message.
|
||||
pub(crate) fn deleted(cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(SharedString::from(&message.content))
|
||||
.text_color(cx.theme().text_danger)
|
||||
.child("Message deleted")
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,5 +19,9 @@ log.workspace = true
|
||||
|
||||
uuid = "1.10"
|
||||
|
||||
linkify = "0.10.0"
|
||||
pulldown-cmark = "0.13.1"
|
||||
regex = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
smol.workspace = true
|
||||
|
||||
@@ -27,7 +27,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
Size::Large => px(64.).into(),
|
||||
Size::Medium => px(32.).into(),
|
||||
Size::Small => px(24.).into(),
|
||||
Size::XSmall => px(20.).into(),
|
||||
Size::XSmall => px(18.).into(),
|
||||
Size::Size(size) => size.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,22 +5,21 @@ use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Anchor, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity,
|
||||
AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity,
|
||||
GlobalElementId, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId,
|
||||
MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, ScrollHandle,
|
||||
SharedString, Stateful, StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _,
|
||||
WeakEntity, Window, actions, div, px, rems,
|
||||
WeakEntity, Window, actions, div, px,
|
||||
};
|
||||
pub use gpui_base::dock::{DockArea, DockLayout, DockPlacement};
|
||||
use gpui_base::dock::{
|
||||
DockAreaRenderer, DockContext, DragPanel, DropIndicator, InsertTarget, NodeId, PaneNode,
|
||||
PaneRef, PanelId, TabGroupContext, TabGroupRenderer, TileContext, TilesRenderer,
|
||||
PaneRef, PanelId, TabGroupContext, TabGroupRenderer,
|
||||
};
|
||||
use gpui_base::{Placement, ResizeHandleContext, Side};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
use crate::menu::DropdownMenu as _;
|
||||
use crate::resizable::{resize_handle, resize_handle_appearance};
|
||||
use crate::tab::Tab;
|
||||
use crate::tab::tab_bar::TabBar;
|
||||
@@ -95,6 +94,19 @@ pub fn add_panel(
|
||||
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
|
||||
}
|
||||
|
||||
/// Add a panel to a dock reached through a weak handle.
|
||||
pub fn add_panel_to(
|
||||
dock: &WeakEntity<DockArea>,
|
||||
panel: PanelHandle,
|
||||
placement: DockPlacement,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let _ = dock.update(cx, |area, cx| {
|
||||
add_panel(area, panel, placement, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// The panel in any region of `area` whose logical id is `key`.
|
||||
fn find_panel(area: &DockArea, key: &SharedString, cx: &App) -> Option<(PanelId, NodeId, usize)> {
|
||||
let placements = [
|
||||
@@ -167,7 +179,6 @@ fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
||||
PaneRef::Tiles { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +193,6 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
};
|
||||
child.and_then(right_top_group)
|
||||
}
|
||||
PaneRef::Tiles { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,18 +273,6 @@ impl DockAreaRenderer for DockSkin {
|
||||
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
||||
Rc::new(TabGroupSkin::new(self.shared.clone()))
|
||||
}
|
||||
|
||||
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
|
||||
Rc::new(NoTiles)
|
||||
}
|
||||
}
|
||||
|
||||
struct NoTiles;
|
||||
|
||||
impl TilesRenderer for NoTiles {
|
||||
fn render_drag_bar(&self, _: &TileContext, _: &mut Window, _: &mut App) -> AnyElement {
|
||||
Empty.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// The payload a dock's resize handle drags; the handle is the affordance.
|
||||
@@ -430,9 +428,6 @@ impl TabGroupSkin {
|
||||
}
|
||||
|
||||
/// Whether this group is the left dock's root with a single panel.
|
||||
///
|
||||
/// Such a group draws no tab bar, so its panel owns the window's top-left
|
||||
/// corner — including the space the macOS traffic lights overlay.
|
||||
fn is_plain_left_group(&self, group: &TabGroupContext, cx: &App) -> bool {
|
||||
let Some(area) = self.shared.area() else {
|
||||
return false;
|
||||
@@ -445,9 +440,6 @@ impl TabGroupSkin {
|
||||
&& group.panels().len() == 1
|
||||
}
|
||||
|
||||
/// Whether this group is the topmost-left group on screen, which sits under
|
||||
/// the native macOS traffic lights. The left dock's group is leftmost while
|
||||
/// it is open and holds a panel; the center's is leftmost otherwise.
|
||||
fn is_leftmost_top_group(&self, group: &TabGroupContext, cx: &App) -> bool {
|
||||
let Some(area) = self.shared.area() else {
|
||||
return false;
|
||||
@@ -465,172 +457,6 @@ impl TabGroupSkin {
|
||||
tree.and_then(|tree| left_top_group(tree.root())) == Some(group.node())
|
||||
}
|
||||
|
||||
fn render_toolbar(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
let zoomed = group.is_zoomed();
|
||||
let closable = group.is_closable();
|
||||
let zoomable = group.active_panel().is_some_and(|panel| panel.zoomable(cx));
|
||||
|
||||
let zoom_label = if zoomed { "Zoom Out" } else { "Zoom In" };
|
||||
|
||||
let buttons = group
|
||||
.active_panel()
|
||||
.and_then(PanelHandle::of)
|
||||
.map(|handle| handle.panel().toolbar_buttons(window, cx))
|
||||
.unwrap_or_default();
|
||||
|
||||
let menu_panel = group
|
||||
.active_panel()
|
||||
.and_then(PanelHandle::of)
|
||||
.map(|handle| handle.panel().clone());
|
||||
|
||||
h_flex()
|
||||
.p_0p5()
|
||||
.gap_1p5()
|
||||
.occlude()
|
||||
.rounded_full()
|
||||
.children(buttons.into_iter().map(|button| button.small().ghost()))
|
||||
.when(zoomed, |this| {
|
||||
this.child(
|
||||
Button::new("zoom")
|
||||
.icon(IconName::Zoom)
|
||||
.small()
|
||||
.ghost()
|
||||
.tooltip("Zoom Out")
|
||||
.on_click({
|
||||
let group = TabGroupContext::clone(group);
|
||||
move |_, window, cx| group.toggle_zoom(window, cx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("menu")
|
||||
.icon(IconName::Ellipsis)
|
||||
.small()
|
||||
.ghost()
|
||||
.dropdown_menu({
|
||||
move |menu, _, cx| {
|
||||
let menu = match menu_panel.clone() {
|
||||
Some(panel) => panel.popup_menu(menu, cx),
|
||||
None => menu,
|
||||
};
|
||||
|
||||
menu.when(zoomable, |this| {
|
||||
this.separator().menu(zoom_label, Box::new(ToggleZoom))
|
||||
})
|
||||
.when(closable, |this| {
|
||||
this.separator().menu("Close", Box::new(ClosePanel))
|
||||
})
|
||||
}
|
||||
})
|
||||
.anchor(Anchor::TopRight),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_title(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
ix: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let panel = group.panels()[ix].clone();
|
||||
let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
|
||||
let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
|
||||
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||
let has_leading = left_button.is_some() || bottom_button.is_some();
|
||||
let drag = tab_drag(group, ix, cx);
|
||||
let is_title_bar = self.is_title_bar_group(group, cx);
|
||||
let needs_traffic_light_padding =
|
||||
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
|
||||
let trailing_chrome = is_title_bar
|
||||
.then(|| self.shared.chrome.trailing(window, cx))
|
||||
.flatten();
|
||||
|
||||
let bar = h_flex()
|
||||
.id("tab-title-bar")
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.line_height(rems(1.0))
|
||||
.h(TABBAR_HEIGHT)
|
||||
.bg(cx.theme().panel_background)
|
||||
.when(left_button.is_some(), |this| this.pl_2())
|
||||
.when(right_button.is_some(), |this| this.pr_2())
|
||||
.when(has_leading, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.mr_1()
|
||||
.gap_1()
|
||||
.children(left_button)
|
||||
.children(bottom_button),
|
||||
)
|
||||
})
|
||||
.when(needs_traffic_light_padding, |this| {
|
||||
this.pl(px(TRAFFIC_LIGHT_PADDING))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.id("tab")
|
||||
.flex_initial()
|
||||
.min_w_0()
|
||||
.px_2()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.text_ellipsis()
|
||||
.text_sm()
|
||||
.child(panel_title(&panel, cx)),
|
||||
)
|
||||
.when_some(drag, |this, drag| {
|
||||
this.on_drag(drag, {
|
||||
let panel = panel.clone();
|
||||
move |drag, offset, _, cx| {
|
||||
cx.stop_propagation();
|
||||
drag.set_drag_offset(offset);
|
||||
cx.new(|_| DragPreview {
|
||||
panel: panel.clone(),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child({
|
||||
let space = div().id("tab-title-space").flex_1().h_full();
|
||||
if is_title_bar {
|
||||
title_bar_drag_handlers(space, window, cx).into_any_element()
|
||||
} else {
|
||||
space.into_any_element()
|
||||
}
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.ml_1()
|
||||
.gap_1()
|
||||
.child(self.render_toolbar(group, window, cx))
|
||||
.children(right_button),
|
||||
)
|
||||
.when_some(trailing_chrome, |this, chrome| this.child(chrome));
|
||||
|
||||
if is_title_bar {
|
||||
h_flex()
|
||||
.h(TABBAR_HEIGHT)
|
||||
.bg(cx.theme().panel_background)
|
||||
.child(bar.flex_1())
|
||||
.child(window_controls())
|
||||
.into_any_element()
|
||||
} else {
|
||||
bar.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tabs(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
@@ -642,9 +468,12 @@ impl TabGroupSkin {
|
||||
let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
|
||||
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||
let has_leading = left_button.is_some() || bottom_button.is_some();
|
||||
|
||||
let collapsed = group.is_collapsed();
|
||||
let droppable = group.is_droppable();
|
||||
let tabs_count = group.panels().len();
|
||||
let is_title_bar = self.is_title_bar_group(group, cx);
|
||||
|
||||
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
|
||||
let displayed_ix = displayed.and_then(|displayed| {
|
||||
group
|
||||
@@ -652,12 +481,14 @@ impl TabGroupSkin {
|
||||
.iter()
|
||||
.position(|panel| panel.panel_id(cx) == displayed)
|
||||
});
|
||||
let is_title_bar = self.is_title_bar_group(group, cx);
|
||||
|
||||
let needs_traffic_light_padding =
|
||||
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
|
||||
|
||||
let trailing_chrome = is_title_bar
|
||||
.then(|| self.shared.chrome.trailing(window, cx))
|
||||
.flatten();
|
||||
|
||||
let empty_space = div()
|
||||
.id("tab-bar-empty-space")
|
||||
.h_full()
|
||||
@@ -673,6 +504,7 @@ impl TabGroupSkin {
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let empty_space = if is_title_bar {
|
||||
title_bar_drag_handlers(empty_space, window, cx).into_any_element()
|
||||
} else {
|
||||
@@ -689,7 +521,9 @@ impl TabGroupSkin {
|
||||
.when(is_title_bar || has_leading, |this| {
|
||||
this.prefix(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.size(TABBAR_HEIGHT)
|
||||
.flex_shrink_0()
|
||||
.justify_center()
|
||||
.top_0()
|
||||
.right(-px(1.))
|
||||
.pl_0p5()
|
||||
@@ -767,13 +601,13 @@ impl TabGroupSkin {
|
||||
.when(!collapsed, |this| {
|
||||
this.suffix(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.top_0()
|
||||
.right_0()
|
||||
.h_full()
|
||||
.px_0p5()
|
||||
.gap_1()
|
||||
.child(self.render_toolbar(group, window, cx))
|
||||
.children(right_button)
|
||||
.children(trailing_chrome),
|
||||
)
|
||||
@@ -846,7 +680,13 @@ impl TabGroupSkin {
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip(if is_open { "Collapse" } else { "Expand" })
|
||||
.map(|this| {
|
||||
if is_open {
|
||||
this.tooltip("Collapse")
|
||||
} else {
|
||||
this.tooltip("Expand")
|
||||
}
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
|
||||
}),
|
||||
@@ -901,13 +741,11 @@ impl TabGroupRenderer for TabGroupSkin {
|
||||
self.scroll_handle.scroll_to_item(visible_ix);
|
||||
}
|
||||
|
||||
match visible.as_slice() {
|
||||
[] => Empty.into_any_element(),
|
||||
// One panel in a group that is not asking for tabs gets the title
|
||||
// instead of a tab bar.
|
||||
[ix] => self.render_title(group, *ix, window, cx),
|
||||
_ => self.render_tabs(group, visible.as_slice(), window, cx),
|
||||
if visible.is_empty() {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
self.render_tabs(group, visible.as_slice(), window, cx)
|
||||
}
|
||||
|
||||
fn render_active_panel(
|
||||
|
||||
@@ -19,9 +19,6 @@ pub enum PanelEvent {
|
||||
|
||||
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
||||
/// The name of the panel used to serialize, deserialize and identify the panel.
|
||||
///
|
||||
/// This is used to identify the panel when deserializing the panel.
|
||||
/// Once you have defined a panel id, this must not be changed.
|
||||
fn panel_id(&self) -> SharedString;
|
||||
|
||||
/// The title of the panel
|
||||
|
||||
@@ -22,6 +22,7 @@ impl<T: IconNamed> From<T> for Icon {
|
||||
pub enum IconName {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowDownCircle,
|
||||
Boom,
|
||||
Book,
|
||||
ChevronDown,
|
||||
@@ -66,6 +67,7 @@ pub enum IconName {
|
||||
Ship,
|
||||
Shield,
|
||||
Group,
|
||||
Hashtag,
|
||||
History,
|
||||
UserKey,
|
||||
Upload,
|
||||
@@ -99,6 +101,7 @@ impl IconNamed for IconName {
|
||||
match self {
|
||||
Self::ArrowLeft => "icons/arrow-left.svg",
|
||||
Self::ArrowRight => "icons/arrow-right.svg",
|
||||
Self::ArrowDownCircle => "icons/arrow-down-circle.svg",
|
||||
Self::Boom => "icons/boom.svg",
|
||||
Self::Book => "icons/book.svg",
|
||||
Self::ChevronDown => "icons/chevron-down.svg",
|
||||
@@ -146,6 +149,7 @@ impl IconNamed for IconName {
|
||||
Self::Upload => "icons/upload.svg",
|
||||
Self::Usb => "icons/usb.svg",
|
||||
Self::Group => "icons/group.svg",
|
||||
Self::Hashtag => "icons/hashtag.svg",
|
||||
Self::History => "icons/history.svg",
|
||||
Self::PanelLeft => "icons/panel-left.svg",
|
||||
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
||||
|
||||
@@ -16,8 +16,11 @@ pub mod dock;
|
||||
pub mod group_box;
|
||||
pub mod indicator;
|
||||
pub mod input;
|
||||
pub mod markdown;
|
||||
pub mod menu;
|
||||
pub mod message;
|
||||
pub mod modal;
|
||||
pub mod nav;
|
||||
pub mod nav_item;
|
||||
pub mod notification;
|
||||
pub mod popover;
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window,
|
||||
};
|
||||
use regex::Regex;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
/// Matches `http://` and `https://` URLs. Only these are treated as clickable links.
|
||||
static WEB_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^https?://").unwrap());
|
||||
|
||||
/// A span of the source content, by byte range, that renders as `text` instead.
|
||||
///
|
||||
/// Replacements are resolved before rendering and styled as an accent-colored
|
||||
/// link. Mentions are one use: callers resolve the public key to a display name
|
||||
/// and hand the renderer the span to substitute. They must be sorted by
|
||||
/// `range.start` and must not overlap.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InlineReplacement {
|
||||
pub range: Range<usize>,
|
||||
pub text: SharedString,
|
||||
}
|
||||
|
||||
impl InlineReplacement {
|
||||
pub fn new(range: Range<usize>, text: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
range,
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Highlight {
|
||||
Code,
|
||||
InlineCode(bool),
|
||||
Replacement,
|
||||
Style(HighlightStyle),
|
||||
}
|
||||
|
||||
impl From<HighlightStyle> for Highlight {
|
||||
fn from(style: HighlightStyle) -> Self {
|
||||
Self::Style(style)
|
||||
}
|
||||
}
|
||||
|
||||
/// Message content flattened into a single styled string.
|
||||
///
|
||||
/// Markdown structure (headings, lists, code blocks, emphasis, links) is turned
|
||||
/// into plain text plus highlight ranges, ready to hand to an `InteractiveText`.
|
||||
#[derive(Default)]
|
||||
pub struct RenderedText {
|
||||
pub text: SharedString,
|
||||
pub highlights: Vec<(Range<usize>, Highlight)>,
|
||||
pub link_ranges: Vec<Range<usize>>,
|
||||
pub link_urls: Arc<[String]>,
|
||||
}
|
||||
|
||||
impl RenderedText {
|
||||
/// Parse `content`, optionally as markdown, replacing `replacements` inline.
|
||||
pub fn new(content: &str, replacements: &[InlineReplacement], markdown: bool) -> Self {
|
||||
let mut text = String::new();
|
||||
let mut highlights = Vec::new();
|
||||
let mut link_ranges = Vec::new();
|
||||
let mut link_urls = Vec::new();
|
||||
|
||||
render_text_mut(
|
||||
content,
|
||||
replacements,
|
||||
&mut text,
|
||||
&mut highlights,
|
||||
&mut link_ranges,
|
||||
&mut link_urls,
|
||||
markdown,
|
||||
);
|
||||
|
||||
// Trim trailing whitespace and adjust highlight and link ranges.
|
||||
let trimmed_len = text.trim_end().len();
|
||||
|
||||
// Retain highlights and link ranges that are within the trimmed text.
|
||||
if trimmed_len < text.len() {
|
||||
highlights.retain_mut(|(range, _)| {
|
||||
range.end = range.end.min(trimmed_len);
|
||||
range.start < range.end
|
||||
});
|
||||
|
||||
let mut ix = 0;
|
||||
|
||||
while ix < link_ranges.len() {
|
||||
let range = &mut link_ranges[ix];
|
||||
range.end = range.end.min(trimmed_len);
|
||||
if range.start < range.end {
|
||||
ix += 1;
|
||||
} else {
|
||||
link_ranges.remove(ix);
|
||||
link_urls.remove(ix);
|
||||
}
|
||||
}
|
||||
|
||||
text.truncate(trimmed_len);
|
||||
}
|
||||
|
||||
RenderedText {
|
||||
text: SharedString::from(text),
|
||||
link_urls: link_urls.into(),
|
||||
link_ranges,
|
||||
highlights,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn element(&self, id: ElementId, window: &Window, cx: &App) -> AnyElement {
|
||||
let code_background = cx.theme().elevated_surface_background;
|
||||
let color = cx.theme().text_accent;
|
||||
let code_font = if cfg!(target_os = "macos") {
|
||||
"Menlo"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Consolas"
|
||||
} else {
|
||||
"monospace"
|
||||
};
|
||||
|
||||
InteractiveText::new(
|
||||
id,
|
||||
StyledText::new(self.text.clone())
|
||||
.with_default_highlights(
|
||||
&window.text_style(),
|
||||
self.highlights.iter().map(|(range, highlight)| {
|
||||
(
|
||||
range.clone(),
|
||||
match highlight {
|
||||
Highlight::Code => HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::InlineCode(link) => {
|
||||
if *link {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
HighlightStyle {
|
||||
background_color: Some(code_background),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
Highlight::Replacement => HighlightStyle {
|
||||
color: Some(color),
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Highlight::Style(highlight) => *highlight,
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.with_font_family_overrides(self.highlights.iter().filter_map(
|
||||
|(range, highlight)| match highlight {
|
||||
Highlight::Code | Highlight::InlineCode(_) => {
|
||||
Some((range.clone(), code_font.into()))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
)),
|
||||
)
|
||||
.on_click(self.link_ranges.clone(), {
|
||||
let link_urls = self.link_urls.clone();
|
||||
move |ix, _, cx| {
|
||||
let url = &link_urls[ix];
|
||||
if WEB_URL.is_match(url) {
|
||||
cx.open_url(url);
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_text_mut(
|
||||
block: &str,
|
||||
replacements: &[InlineReplacement],
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
markdown: bool,
|
||||
) {
|
||||
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
|
||||
|
||||
let mut bold_depth = 0;
|
||||
let mut italic_depth = 0;
|
||||
let mut strikethrough_depth = 0;
|
||||
let mut link_url = None;
|
||||
let mut list_stack = Vec::new();
|
||||
let mut code_block = false;
|
||||
|
||||
// Only enable the extensions that make sense for chat messages. Notably this leaves
|
||||
// out smart punctuation, tables, math and footnotes: they rewrite or swallow text.
|
||||
let events: Box<dyn Iterator<Item = (Event<'_>, Range<usize>)> + '_> = if markdown {
|
||||
Box::new(Parser::new_ext(block, Options::ENABLE_STRIKETHROUGH).into_offset_iter())
|
||||
} else {
|
||||
Box::new(std::iter::once((Event::Text(block.into()), 0..block.len())))
|
||||
};
|
||||
|
||||
for (event, source_range) in events {
|
||||
let prev_len = text.len();
|
||||
|
||||
match event {
|
||||
Event::Text(t) => {
|
||||
if code_block {
|
||||
text.push_str(t.as_ref());
|
||||
highlights.push((prev_len..text.len(), Highlight::Code));
|
||||
continue;
|
||||
}
|
||||
|
||||
let t_str = t.as_ref();
|
||||
let mut last_processed = 0;
|
||||
|
||||
for replacement in replacements {
|
||||
if replacement.range.start >= source_range.end {
|
||||
break;
|
||||
}
|
||||
|
||||
if replacement.range.start < source_range.start
|
||||
|| replacement.range.end > source_range.end
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(token) = block.get(replacement.range.clone()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(offset) = t_str[last_processed..].find(token) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let replacement_start_in_text = last_processed + offset;
|
||||
let replacement_end_in_text = replacement_start_in_text + token.len();
|
||||
|
||||
// Add text before this replacement
|
||||
if replacement_start_in_text > last_processed {
|
||||
let before_replacement = &t_str[last_processed..replacement_start_in_text];
|
||||
process_text_segment(
|
||||
before_replacement,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
|
||||
// Process the replacement
|
||||
let replacement_start = text.len();
|
||||
text.push_str(&replacement.text);
|
||||
let replacement_end = text.len();
|
||||
|
||||
highlights.push((replacement_start..replacement_end, Highlight::Replacement));
|
||||
|
||||
last_processed = replacement_end_in_text;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last replacement
|
||||
if last_processed < t_str.len() {
|
||||
let remaining_text = &t_str[last_processed..];
|
||||
process_text_segment(
|
||||
remaining_text,
|
||||
bold_depth,
|
||||
italic_depth,
|
||||
strikethrough_depth,
|
||||
link_url.clone(),
|
||||
text,
|
||||
highlights,
|
||||
link_ranges,
|
||||
link_urls,
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Code(t) => {
|
||||
text.push_str(t.as_ref());
|
||||
let is_link = link_url.is_some();
|
||||
|
||||
if let Some(link_url) = link_url.clone() {
|
||||
link_ranges.push(prev_len..text.len());
|
||||
link_urls.push(link_url);
|
||||
}
|
||||
|
||||
highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link)))
|
||||
}
|
||||
Event::Start(tag) => match tag {
|
||||
Tag::Paragraph => new_paragraph(text, &mut list_stack),
|
||||
Tag::Heading { .. } => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
bold_depth += 1;
|
||||
}
|
||||
Tag::CodeBlock(_kind) => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
code_block = true;
|
||||
}
|
||||
Tag::Emphasis => italic_depth += 1,
|
||||
Tag::Strong => bold_depth += 1,
|
||||
Tag::Strikethrough => strikethrough_depth += 1,
|
||||
Tag::Link { dest_url, .. } => {
|
||||
link_url = WEB_URL.is_match(&dest_url).then(|| dest_url.to_string());
|
||||
}
|
||||
Tag::List(number) => {
|
||||
list_stack.push((number, false));
|
||||
}
|
||||
Tag::Item => {
|
||||
let len = list_stack.len();
|
||||
if let Some((list_number, has_content)) = list_stack.last_mut() {
|
||||
*has_content = false;
|
||||
if !text.is_empty() && !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
for _ in 0..len - 1 {
|
||||
text.push_str(" ");
|
||||
}
|
||||
if let Some(number) = list_number {
|
||||
text.push_str(&format!("{}. ", number));
|
||||
*number += 1;
|
||||
*has_content = false;
|
||||
} else {
|
||||
text.push_str("- ");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::End(tag) => match tag {
|
||||
TagEnd::CodeBlock => code_block = false,
|
||||
TagEnd::Heading(_) => bold_depth -= 1,
|
||||
TagEnd::Emphasis => italic_depth -= 1,
|
||||
TagEnd::Strong => bold_depth -= 1,
|
||||
TagEnd::Strikethrough => strikethrough_depth -= 1,
|
||||
TagEnd::Link => link_url = None,
|
||||
TagEnd::List(_) => drop(list_stack.pop()),
|
||||
_ => {}
|
||||
},
|
||||
Event::Html(t) | Event::InlineHtml(t) => text.push_str(t.as_ref()),
|
||||
Event::Rule => {
|
||||
new_paragraph(text, &mut list_stack);
|
||||
text.push_str("────────\n");
|
||||
}
|
||||
Event::HardBreak => text.push('\n'),
|
||||
Event::SoftBreak => text.push('\n'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_text_segment(
|
||||
segment: &str,
|
||||
bold_depth: i32,
|
||||
italic_depth: i32,
|
||||
strikethrough_depth: i32,
|
||||
link_url: Option<String>,
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
link_urls: &mut Vec<String>,
|
||||
) {
|
||||
// Build the style for this segment
|
||||
let mut style = HighlightStyle::default();
|
||||
if bold_depth > 0 {
|
||||
style.font_weight = Some(FontWeight::BOLD);
|
||||
}
|
||||
if italic_depth > 0 {
|
||||
style.font_style = Some(FontStyle::Italic);
|
||||
}
|
||||
if strikethrough_depth > 0 {
|
||||
style.strikethrough = Some(StrikethroughStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Ranges always refer to the rendered text, including replaced spans.
|
||||
let segment_start = text.len();
|
||||
text.push_str(segment);
|
||||
let text_end = text.len();
|
||||
|
||||
if let Some(link_url) = link_url {
|
||||
// Handle as a markdown link
|
||||
link_ranges.push(segment_start..text_end);
|
||||
link_urls.push(link_url);
|
||||
style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Add highlight for the entire linked segment
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((segment_start..text_end, Highlight::Style(style)));
|
||||
}
|
||||
} else {
|
||||
// Handle link detection within the segment
|
||||
let mut finder = linkify::LinkFinder::new();
|
||||
finder.kinds(&[linkify::LinkKind::Url]);
|
||||
let mut last_link_pos = 0;
|
||||
|
||||
for link in finder
|
||||
.links(segment)
|
||||
.filter(|link| WEB_URL.is_match(link.as_str()))
|
||||
{
|
||||
let start = link.start();
|
||||
let end = link.end();
|
||||
|
||||
// Add non-link text before this link
|
||||
if start > last_link_pos {
|
||||
let non_link_start = segment_start + last_link_pos;
|
||||
let non_link_end = segment_start + start;
|
||||
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((non_link_start..non_link_end, Highlight::Style(style)));
|
||||
}
|
||||
}
|
||||
|
||||
// Add the link
|
||||
let range = (segment_start + start)..(segment_start + end);
|
||||
link_ranges.push(range.clone());
|
||||
link_urls.push(link.as_str().to_string());
|
||||
|
||||
// Apply link styling (underline + existing style)
|
||||
let mut link_style = style;
|
||||
link_style.underline = Some(UnderlineStyle {
|
||||
thickness: 1.0.into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
highlights.push((range, Highlight::Style(link_style)));
|
||||
|
||||
last_link_pos = end;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last link
|
||||
if last_link_pos < segment.len() {
|
||||
let remaining_start = segment_start + last_link_pos;
|
||||
let remaining_end = segment_start + segment.len();
|
||||
|
||||
if style != HighlightStyle::default() {
|
||||
highlights.push((remaining_start..remaining_end, Highlight::Style(style)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_paragraph(text: &mut String, list_stack: &mut [(Option<u64>, bool)]) {
|
||||
let mut is_subsequent_paragraph_of_list = false;
|
||||
|
||||
if let Some((_, has_content)) = list_stack.last_mut() {
|
||||
if *has_content {
|
||||
is_subsequent_paragraph_of_list = true;
|
||||
} else {
|
||||
*has_content = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
|
||||
for _ in 0..list_stack.len().saturating_sub(1) {
|
||||
text.push_str(" ");
|
||||
}
|
||||
|
||||
if is_subsequent_paragraph_of_list {
|
||||
text.push_str(" ");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, MouseButton,
|
||||
MouseDownEvent, ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
div, px,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex};
|
||||
|
||||
type MouseDownListener = Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>;
|
||||
type DoubleClickListener = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
||||
|
||||
/// A single message row in a chat-like list.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MessageRow {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
show_author: bool,
|
||||
hide_avatar: bool,
|
||||
avatar: Option<AnyElement>,
|
||||
author: Option<SharedString>,
|
||||
timestamp: Option<SharedString>,
|
||||
header_extras: SmallVec<[AnyElement; 1]>,
|
||||
body: SmallVec<[AnyElement; 4]>,
|
||||
overlays: SmallVec<[AnyElement; 2]>,
|
||||
on_mouse_down: Option<(MouseButton, MouseDownListener)>,
|
||||
on_double_click: Option<DoubleClickListener>,
|
||||
}
|
||||
|
||||
impl MessageRow {
|
||||
/// Create a message row with the given element id.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
show_author: false,
|
||||
hide_avatar: false,
|
||||
avatar: None,
|
||||
author: None,
|
||||
timestamp: None,
|
||||
header_extras: SmallVec::new(),
|
||||
body: SmallVec::new(),
|
||||
overlays: SmallVec::new(),
|
||||
on_mouse_down: None,
|
||||
on_double_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this row opens a run of messages from one author.
|
||||
#[must_use]
|
||||
pub fn show_author(mut self, show_author: bool) -> Self {
|
||||
self.show_author = show_author;
|
||||
self
|
||||
}
|
||||
|
||||
/// Hide the avatar column entirely.
|
||||
#[must_use]
|
||||
pub fn hide_avatar(mut self, hide_avatar: bool) -> Self {
|
||||
self.hide_avatar = hide_avatar;
|
||||
self
|
||||
}
|
||||
|
||||
/// The avatar shown when this row opens a run of messages.
|
||||
#[must_use]
|
||||
pub fn avatar(mut self, avatar: impl IntoElement) -> Self {
|
||||
self.avatar = Some(avatar.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// The author's display name, shown next to the timestamp.
|
||||
#[must_use]
|
||||
pub fn author(mut self, author: impl Into<SharedString>) -> Self {
|
||||
self.author = Some(author.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The time the message was sent.
|
||||
#[must_use]
|
||||
pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
|
||||
self.timestamp = Some(timestamp.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Append an element to the header row.
|
||||
#[must_use]
|
||||
pub fn header_extra(mut self, extra: impl IntoElement) -> Self {
|
||||
self.header_extras.push(extra.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Append an element to the message body, in order.
|
||||
#[must_use]
|
||||
pub fn child(mut self, child: impl IntoElement) -> Self {
|
||||
self.body.push(child.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Append several elements to the message body, in order.
|
||||
#[must_use]
|
||||
pub fn children(mut self, children: impl IntoIterator<Item = impl IntoElement>) -> Self {
|
||||
self.body
|
||||
.extend(children.into_iter().map(|child| child.into_any_element()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Append an element positioned over the row.
|
||||
#[must_use]
|
||||
pub fn overlay(mut self, overlay: impl IntoElement) -> Self {
|
||||
self.overlays.push(overlay.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Handle a mouse button press anywhere on the row.
|
||||
#[must_use]
|
||||
pub fn on_mouse_down(
|
||||
mut self,
|
||||
button: MouseButton,
|
||||
listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_mouse_down = Some((button, Box::new(listener)));
|
||||
self
|
||||
}
|
||||
|
||||
/// Handle a double click anywhere on the row.
|
||||
#[must_use]
|
||||
pub fn on_double_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_double_click = Some(Box::new(listener));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for MessageRow {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MessageRow {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self {
|
||||
id,
|
||||
style,
|
||||
show_author,
|
||||
hide_avatar,
|
||||
avatar,
|
||||
author,
|
||||
timestamp,
|
||||
header_extras,
|
||||
body,
|
||||
overlays,
|
||||
on_mouse_down,
|
||||
on_double_click,
|
||||
} = self;
|
||||
|
||||
div()
|
||||
.id(id)
|
||||
.group("")
|
||||
.relative()
|
||||
.w_full()
|
||||
.py_1()
|
||||
.when(show_author, |this| this.pt_2())
|
||||
.px_3()
|
||||
.refine_style(&style)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_3()
|
||||
.when(!hide_avatar, |this| {
|
||||
if show_author {
|
||||
match avatar {
|
||||
Some(avatar) => this.child(avatar),
|
||||
None => this.child(div().flex_shrink_0().w(px(32.))),
|
||||
}
|
||||
} else {
|
||||
this.child(div().flex_shrink_0().w(px(32.)))
|
||||
}
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.when(show_author, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.when_some(author, |this, author| {
|
||||
this.child(div().font_semibold().child(author))
|
||||
})
|
||||
.when_some(timestamp, |this, timestamp| {
|
||||
this.child(timestamp)
|
||||
})
|
||||
.children(header_extras),
|
||||
)
|
||||
})
|
||||
.children(body),
|
||||
),
|
||||
)
|
||||
.children(overlays)
|
||||
.when_some(on_mouse_down, |this, (button, listener)| {
|
||||
this.on_mouse_down(button, listener)
|
||||
})
|
||||
.when_some(on_double_click, |this, listener| {
|
||||
this.on_double_click(listener)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// A welcome message shown at the top of a message list.
|
||||
#[derive(IntoElement)]
|
||||
pub struct WelcomeMessage {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
icon: Option<AnyElement>,
|
||||
title: Option<SharedString>,
|
||||
message: Option<SharedString>,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl WelcomeMessage {
|
||||
/// Create a welcome message with the given element id.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
icon: None,
|
||||
title: None,
|
||||
message: None,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon shown above the title.
|
||||
#[must_use]
|
||||
pub fn icon(mut self, icon: impl IntoElement) -> Self {
|
||||
self.icon = Some(icon.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// The welcome title.
|
||||
#[must_use]
|
||||
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The welcome body text.
|
||||
#[must_use]
|
||||
pub fn message(mut self, message: impl Into<SharedString>) -> Self {
|
||||
self.message = Some(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Append an element below the body text.
|
||||
#[must_use]
|
||||
pub fn child(mut self, child: impl IntoElement) -> Self {
|
||||
self.children.push(child.into_any_element());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for WelcomeMessage {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for WelcomeMessage {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self {
|
||||
id,
|
||||
style,
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
children,
|
||||
} = self;
|
||||
|
||||
v_flex()
|
||||
.id(id)
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.refine_style(&style)
|
||||
.when_some(icon, |this, icon| this.child(icon))
|
||||
.child(
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.when_some(title, |this, title| {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text)
|
||||
.child(title),
|
||||
)
|
||||
})
|
||||
.when_some(message, |this, message| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(message),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.children(children)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
||||
div,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{Selectable, StyledExt, h_flex, v_flex};
|
||||
|
||||
/// A single navigation row, such as an entry in a sidebar list.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Nav {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
prefix: Option<AnyElement>,
|
||||
label: SharedString,
|
||||
suffix: Option<AnyElement>,
|
||||
selected: 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 {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
prefix: None,
|
||||
label: SharedString::default(),
|
||||
suffix: None,
|
||||
selected: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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();
|
||||
|
||||
v_flex()
|
||||
.id(format!("nav-{}", self.id))
|
||||
.w_full()
|
||||
.h_10()
|
||||
.child(
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_1p5()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.flex_1()
|
||||
.child(div().truncate().min_w_0().child(self.label))
|
||||
.child(div().flex_1())
|
||||
.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().ghost_element_hover))
|
||||
.when(self.selected, |this| {
|
||||
this.bg(cx.theme().ghost_element_active)
|
||||
})
|
||||
})
|
||||
.when_some(self.on_click, |this, handler| {
|
||||
this.on_click(move |event, window, cx| handler(event, window, cx))
|
||||
})
|
||||
.refine_style(&self.style),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ use std::rc::Rc;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px, relative,
|
||||
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use crate::{Icon, IconName, Selectable, h_flex};
|
||||
|
||||
@@ -25,6 +25,7 @@ pub struct Tab {
|
||||
children: Vec<AnyElement>,
|
||||
pub(super) disabled: bool,
|
||||
pub(super) selected: bool,
|
||||
pub(super) segmented: bool,
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
@@ -69,6 +70,7 @@ impl Default for Tab {
|
||||
children: Vec::new(),
|
||||
disabled: false,
|
||||
selected: false,
|
||||
segmented: false,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
on_click: None,
|
||||
@@ -132,6 +134,12 @@ impl Tab {
|
||||
self.tab_bar_prefix = Some(tab_bar_prefix);
|
||||
self
|
||||
}
|
||||
|
||||
/// Render the tab as a segment inside a segmented control.
|
||||
pub(crate) fn segmented(mut self, segmented: bool) -> Self {
|
||||
self.segmented = segmented;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Tab {
|
||||
@@ -167,74 +175,103 @@ impl Styled for Tab {
|
||||
|
||||
impl RenderOnce for Tab {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let fg = if self.disabled {
|
||||
let Self {
|
||||
ix,
|
||||
base,
|
||||
label,
|
||||
icon,
|
||||
prefix,
|
||||
suffix,
|
||||
children,
|
||||
disabled,
|
||||
selected,
|
||||
segmented,
|
||||
on_click,
|
||||
..
|
||||
} = self;
|
||||
|
||||
let foreground = if disabled {
|
||||
cx.theme().text_muted
|
||||
} else if self.selected {
|
||||
} else if selected {
|
||||
cx.theme().tab_active_foreground
|
||||
} else {
|
||||
cx.theme().tab_foreground
|
||||
};
|
||||
|
||||
self.base
|
||||
.id(self.ix)
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
let content = h_flex()
|
||||
.flex_1()
|
||||
.h_6()
|
||||
.whitespace_nowrap()
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.h(TABBAR_HEIGHT)
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
.text_color(fg)
|
||||
.text_sm()
|
||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||
.child(
|
||||
h_flex()
|
||||
.when(segmented, |this| this.justify_center().px_1())
|
||||
.when(!segmented, |this| this.justify_start())
|
||||
.map(|this| match icon {
|
||||
Some(icon) => this.w(px(38.)).child(icon.size_4()),
|
||||
None => this
|
||||
.map(|this| match label {
|
||||
Some(label) => this.child(label),
|
||||
None => this,
|
||||
})
|
||||
.children(children),
|
||||
});
|
||||
|
||||
base.id(ix)
|
||||
.group("tab")
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_color(foreground)
|
||||
.when(segmented, |this| {
|
||||
this.text_xs()
|
||||
.flex_1()
|
||||
.h(px(30.))
|
||||
.line_height(relative(1.))
|
||||
.whitespace_nowrap()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_6()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(selected && !disabled, |this| {
|
||||
this.bg(cx.theme().tab_active_background)
|
||||
.when(cx.theme().shadow, |this| this.shadow_sm())
|
||||
})
|
||||
.when(!selected && !disabled, |this| {
|
||||
this.hover(|this| this.bg(cx.theme().tab_hover_background))
|
||||
})
|
||||
})
|
||||
.when(!segmented, |this| {
|
||||
this.flex_shrink_0()
|
||||
.min_w_32()
|
||||
.h_7()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.overflow_hidden()
|
||||
.flex_shrink_0()
|
||||
.px_3()
|
||||
.map(|this| match self.icon {
|
||||
Some(icon) => this.w(px(38.)).child(icon.size_4()),
|
||||
None => this
|
||||
.map(|this| match self.label {
|
||||
Some(label) => this.child(label),
|
||||
None => this,
|
||||
})
|
||||
.children(self.children),
|
||||
}),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| {
|
||||
this.child(div().pr_2().child(suffix))
|
||||
.when(selected && !disabled, |this| {
|
||||
this.bg(cx.theme().tab_background)
|
||||
})
|
||||
.when(!selected && !disabled, |this| {
|
||||
this.hover(|this| {
|
||||
this.bg(cx.theme().tab_hover_background)
|
||||
.text_color(cx.theme().tab_active_foreground)
|
||||
})
|
||||
})
|
||||
})
|
||||
.when_some(prefix, |this, prefix| this.child(prefix))
|
||||
.child(content)
|
||||
.when_some(suffix, |this, suffix| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.when(!selected, |this| {
|
||||
this.invisible().group_hover("tab", |this| this.visible())
|
||||
})
|
||||
.child(suffix),
|
||||
)
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, |_ev, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.when(!self.disabled, |this| {
|
||||
this.when_some(self.on_click.clone(), |this, on_click| {
|
||||
.when(!disabled, |this| {
|
||||
this.when_some(on_click, |this, on_click| {
|
||||
this.on_click(move |event, window, cx| on_click(event, window, cx))
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.h_0p5()
|
||||
.when(self.selected && !self.disabled, |this| {
|
||||
this.bg(cx.theme().element_active)
|
||||
})
|
||||
.when(!self.selected && !self.disabled, |this| {
|
||||
this.invisible().group_hover("", |this| {
|
||||
this.visible().bg(cx.theme().secondary_background)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use gpui::{
|
||||
Window, div, px,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
use super::Tab;
|
||||
use crate::button::{Button, ButtonVariants as _};
|
||||
@@ -25,6 +26,7 @@ pub struct TabBar {
|
||||
last_empty_space: AnyElement,
|
||||
selected_index: Option<usize>,
|
||||
menu: bool,
|
||||
segmented: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
@@ -43,9 +45,16 @@ impl TabBar {
|
||||
selected_index: None,
|
||||
on_click: None,
|
||||
menu: false,
|
||||
segmented: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the tabs as a segmented control inside a pill-shaped track.
|
||||
pub fn segmented(mut self, segmented: bool) -> Self {
|
||||
self.segmented = segmented;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether to show the menu button when tabs overflow, default is false.
|
||||
pub fn menu(mut self, menu: bool) -> Self {
|
||||
self.menu = menu;
|
||||
@@ -113,33 +122,45 @@ impl Styled for TabBar {
|
||||
}
|
||||
|
||||
impl RenderOnce for TabBar {
|
||||
fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let mut item_labels = Vec::new();
|
||||
let selected_index = self.selected_index;
|
||||
let on_click = self.on_click.clone();
|
||||
let segmented = self.segmented;
|
||||
let has_prefix = self.prefix.is_some();
|
||||
|
||||
self.base
|
||||
.group("tab-bar")
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.refine_style(&self.style)
|
||||
.when(segmented, |this| {
|
||||
this.bg(cx.theme().tab_background)
|
||||
.p_0p5()
|
||||
.rounded(cx.theme().radius)
|
||||
})
|
||||
.when_some(self.prefix, |this, prefix| this.child(prefix))
|
||||
.child(
|
||||
h_flex()
|
||||
.id("tabs")
|
||||
.flex_1()
|
||||
.overflow_x_scroll()
|
||||
.when(!segmented, |this| {
|
||||
this.overflow_x_scroll()
|
||||
.when(!has_prefix, |this| this.pl_2())
|
||||
})
|
||||
.when_some(self.scroll_handle, |this, scroll_handle| {
|
||||
this.track_scroll(&scroll_handle)
|
||||
})
|
||||
.gap(px(0.))
|
||||
.gap_1()
|
||||
.children(self.children.into_iter().enumerate().map(|(ix, child)| {
|
||||
item_labels.push((child.label.clone(), child.disabled));
|
||||
let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
|
||||
child
|
||||
.ix(ix)
|
||||
.tab_bar_prefix(tab_bar_prefix)
|
||||
.segmented(segmented)
|
||||
.when_some(self.selected_index, |this, selected_ix| {
|
||||
this.selected(selected_ix == ix)
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
.show_close(false)
|
||||
.overlay_closable(false)
|
||||
.keyboard(false)
|
||||
.title("Onboarding")
|
||||
.title("Get Started")
|
||||
.child(import.clone())
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,17 +2,18 @@ use chat::{ChatRegistry, Room, RoomKind};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, Window, div, px,
|
||||
Subscription, WeakEntity, Window, div, px,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{DockArea, DockPlacement, PanelHandle};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::{StyledExt, WindowExtension, v_flex};
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let view = cx.new(|cx| NewChat::new(window, cx));
|
||||
pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||
let view = cx.new(|cx| NewChat::new(dock, window, cx));
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.width(px(420.)).title("New chat").child(view.clone())
|
||||
@@ -20,6 +21,9 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
}
|
||||
|
||||
pub struct NewChat {
|
||||
/// The dock a started chat opens in
|
||||
dock: WeakEntity<DockArea>,
|
||||
|
||||
/// Public key input
|
||||
input: Entity<InputState>,
|
||||
|
||||
@@ -31,7 +35,7 @@ pub struct NewChat {
|
||||
}
|
||||
|
||||
impl NewChat {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub"));
|
||||
|
||||
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
@@ -41,6 +45,7 @@ impl NewChat {
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
input,
|
||||
error: None,
|
||||
_subscription: Some(subscription),
|
||||
@@ -56,6 +61,8 @@ impl NewChat {
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
|
||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||
self.set_error("You are not signed in", cx);
|
||||
return;
|
||||
@@ -70,12 +77,20 @@ impl NewChat {
|
||||
.organize(¤t_user)
|
||||
.kind(RoomKind::Ongoing);
|
||||
|
||||
let chat = ChatRegistry::global(cx);
|
||||
chat.update(cx, |chat, cx| {
|
||||
let room = chat.update(cx, |chat, cx| {
|
||||
let room = cx.new(|_| room);
|
||||
chat.emit_room(&room, window, cx);
|
||||
chat.track_room(&room, cx);
|
||||
room
|
||||
});
|
||||
|
||||
ui::dock::add_panel_to(
|
||||
&self.dock,
|
||||
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
window.close_modal(cx);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::settings::AppSettings;
|
||||
@@ -6,25 +5,20 @@ use anyhow::Error;
|
||||
use auto_update::AutoUpdater;
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use common::download_dir;
|
||||
use community::{CommunityEvent, CommunityRegistry};
|
||||
use community_ui::CommunityPanel;
|
||||
use device::{DeviceEvent, DeviceRegistry};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, px,
|
||||
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{PersonRegistry, shorten_pubkey};
|
||||
use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||
use ui::dock::{self, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||
use ui::notification::{Notification, NotificationKind};
|
||||
use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::dialogs::restore::RestoreEncryption;
|
||||
use crate::dialogs::{new_chat, new_community, settings};
|
||||
@@ -71,9 +65,6 @@ enum Command {
|
||||
|
||||
pub struct Workspace {
|
||||
dock: Entity<DockArea>,
|
||||
title_bar_chrome: Rc<dock::TitleBarChrome>,
|
||||
/// The community panel currently docked, if any
|
||||
community_panel: Option<WeakEntity<CommunityPanel>>,
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Event subscriptions
|
||||
@@ -83,12 +74,11 @@ pub struct Workspace {
|
||||
impl Workspace {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let communities = CommunityRegistry::global(cx);
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let sidebar = cx.new(|cx| Sidebar::new(window, cx));
|
||||
let (dock, title_bar_chrome) = dock::dock_area("coop", window, cx);
|
||||
let (dock, _) = dock::dock_area("coop", window, cx);
|
||||
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -164,7 +154,7 @@ impl Workspace {
|
||||
|
||||
subscriptions.push(
|
||||
// Observe all events emitted by the chat registry
|
||||
cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| {
|
||||
cx.subscribe_in(&chat, window, move |_this, _chat, ev, window, cx| {
|
||||
match ev {
|
||||
ChatEvent::InboxRelayNotFound => {
|
||||
const MSG: &str = "Messaging Relays not found. Cannot receive messages.";
|
||||
@@ -187,28 +177,6 @@ impl Workspace {
|
||||
cx,
|
||||
);
|
||||
}
|
||||
ChatEvent::OpenRoom(id) => {
|
||||
if let Some(room) = chat.read(cx).room(id, cx) {
|
||||
this.add_panel_to_dock(
|
||||
chat_ui::init(room, window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
ChatEvent::CloseRoom(..) => {
|
||||
this.dock.update(cx, |area, cx| {
|
||||
// Force focus to the tab panel
|
||||
ui::dock::focus_tab_panel(area, window, cx);
|
||||
|
||||
// Dispatch the close panel action
|
||||
cx.defer_in(window, |_, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
window.close_all_modals(cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
ChatEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error).autohide(false), cx);
|
||||
}
|
||||
@@ -217,71 +185,23 @@ impl Workspace {
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
// Observe all events emitted by the community registry
|
||||
cx.subscribe_in(
|
||||
&communities,
|
||||
window,
|
||||
move |this, communities, event, window, cx| match event {
|
||||
CommunityEvent::Open(id) => {
|
||||
if let Some(community) = communities.read(cx).community(id) {
|
||||
let panel = community_ui::init(community, window, cx);
|
||||
|
||||
this.community_panel = Some(panel.downgrade());
|
||||
this.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
||||
}
|
||||
}
|
||||
CommunityEvent::Close(_) => {
|
||||
let Some(panel) = this
|
||||
.community_panel
|
||||
.take()
|
||||
.and_then(|panel| panel.upgrade())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.dock.update(cx, |area, cx| {
|
||||
ui::dock::add_panel(
|
||||
area,
|
||||
PanelHandle::new(panel),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
ui::dock::focus_tab_panel(area, window, cx);
|
||||
|
||||
cx.defer_in(window, |_, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
let sidebar = PanelHandle::new(sidebar);
|
||||
|
||||
this.dock.update(cx, |area, cx| {
|
||||
let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx);
|
||||
area.set_dock(DockPlacement::Left, left, window, cx);
|
||||
area.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
|
||||
});
|
||||
|
||||
let greeter = PanelHandle::new(greeter::init(window, cx));
|
||||
let center = DockLayout::v_split()
|
||||
.child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None);
|
||||
|
||||
this.dock.update(cx, |area, cx| {
|
||||
area.set_center(center, window, cx);
|
||||
this.dock.update(cx, |this, cx| {
|
||||
let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx);
|
||||
let center = DockLayout::v_split()
|
||||
.child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None);
|
||||
|
||||
this.set_dock(DockPlacement::Left, left, window, cx);
|
||||
this.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
|
||||
this.set_center(center, window, cx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
title_bar_chrome,
|
||||
community_panel: None,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -365,10 +285,11 @@ impl Workspace {
|
||||
self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx);
|
||||
}
|
||||
Command::ShowSearch => {
|
||||
self.add_panel_to_dock(search::init(window, cx), DockPlacement::Center, window, cx);
|
||||
let panel = search::init(self.dock.downgrade(), window, cx);
|
||||
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
||||
}
|
||||
Command::NewChat => {
|
||||
new_chat::open(window, cx);
|
||||
new_chat::open(self.dock.downgrade(), window, cx);
|
||||
}
|
||||
Command::NewCommunity => {
|
||||
new_community::open(window, cx);
|
||||
@@ -583,174 +504,6 @@ impl Workspace {
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
fn titlebar_right(_window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let auto_updater = AutoUpdater::try_global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(&public_key, cx);
|
||||
let announcement = profile.announcement();
|
||||
|
||||
let updater_status = auto_updater.as_ref().and_then(|updater| {
|
||||
let updater = updater.read(cx);
|
||||
(!updater.idle()).then(|| updater.status())
|
||||
});
|
||||
|
||||
let staged_update = auto_updater
|
||||
.as_ref()
|
||||
.is_some_and(|updater| updater.read(cx).staged());
|
||||
|
||||
h_flex()
|
||||
.when(!cx.theme().platform.is_mac(), |this| this.pr_2())
|
||||
.gap_2()
|
||||
.when_some(updater_status, |this, status| {
|
||||
this.child(div().text_xs().italic().child(status))
|
||||
})
|
||||
.when(staged_update, |this| {
|
||||
this.child(
|
||||
Button::new("restart-to-update")
|
||||
.label("Restart to Update")
|
||||
.tooltip("Quit and relaunch into the installed update")
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(|_event, _window, cx| {
|
||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||
auto_updater.update(cx, |this, cx| this.restart(cx));
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(nip4e_enabled, |this| {
|
||||
this.child(
|
||||
Button::new("key")
|
||||
.icon(IconName::UserKey)
|
||||
.tooltip("Decoupled encryption key")
|
||||
.small()
|
||||
.ghost()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.min_w(px(260.))
|
||||
.label("Encryption Key")
|
||||
.when_some(announcement.as_ref(), |this, announcement| {
|
||||
let name = announcement.client_name();
|
||||
let pkey = shorten_pubkey(announcement.public_key(), 8);
|
||||
|
||||
this.item(PopupMenuItem::element(move |_window, cx| {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(
|
||||
Icon::new(IconName::Device)
|
||||
.small()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
)
|
||||
.child(name.clone())
|
||||
}))
|
||||
.item(
|
||||
PopupMenuItem::element(move |_window, cx| {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child(
|
||||
Icon::new(IconName::UserKey)
|
||||
.small()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
)
|
||||
.child(SharedString::from(pkey.clone()))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Backup",
|
||||
IconName::Shield,
|
||||
Box::new(Command::BackupEncryption),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Restore from secret key",
|
||||
IconName::Usb,
|
||||
Box::new(Command::ImportEncryption),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Reload",
|
||||
IconName::Refresh,
|
||||
Box::new(Command::RefreshEncryption),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Reset",
|
||||
IconName::Warning,
|
||||
Box::new(Command::ResetEncryption),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("inbox")
|
||||
.icon(IconName::Inbox)
|
||||
.small()
|
||||
.ghost()
|
||||
.dropdown_menu(move |this, _window, cx| {
|
||||
let urls: Vec<(SharedString, SharedString)> = profile
|
||||
.messaging_relays()
|
||||
.iter()
|
||||
.map(|url| {
|
||||
(
|
||||
SharedString::from(url.to_string()),
|
||||
chat.read(cx).count_messages(url).to_string().into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Header
|
||||
let menu = this.min_w(px(260.)).label("Messaging Relays");
|
||||
|
||||
// Content
|
||||
let menu = urls.into_iter().fold(menu, |this, (url, count)| {
|
||||
this.item(PopupMenuItem::element(move |_window, cx| {
|
||||
h_flex()
|
||||
.px_1()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.justify_between()
|
||||
.child(url.clone())
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(count.clone()),
|
||||
)
|
||||
}))
|
||||
});
|
||||
|
||||
// Footer
|
||||
menu.separator()
|
||||
.menu_with_icon(
|
||||
"Manage gossip relays",
|
||||
IconName::Relay,
|
||||
Box::new(Command::ShowRelayList),
|
||||
)
|
||||
.menu_with_icon(
|
||||
"Manage messaging relays",
|
||||
IconName::Relay,
|
||||
Box::new(Command::ShowMessaging),
|
||||
)
|
||||
.separator()
|
||||
.menu_with_icon(
|
||||
"Reload",
|
||||
IconName::Refresh,
|
||||
Box::new(Command::RefreshMessagingRelays),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Workspace {
|
||||
@@ -758,9 +511,6 @@ impl Render for Workspace {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
// Render the title bar chrome
|
||||
self.title_bar_chrome.set_trailing(Self::titlebar_right);
|
||||
|
||||
div()
|
||||
.id("workspace")
|
||||
.on_action(cx.listener(Self::on_command))
|
||||
|
||||
@@ -58,13 +58,16 @@ impl Panel for GreeterPanel {
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
div()
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_4()
|
||||
.text_color(cx.theme().text_muted),
|
||||
)
|
||||
.child("Welcome")
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ use common::DebouncedDelay;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div,
|
||||
uniform_list,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity,
|
||||
Window, div, uniform_list,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -17,22 +17,25 @@ use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::nav::Nav;
|
||||
use ui::notification::Notification;
|
||||
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::sidebar::{TreeRow, TreeRowKind};
|
||||
use crate::sidebar::nav_avatar;
|
||||
|
||||
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
|
||||
cx.new(|cx| SearchPanel::new(window, cx))
|
||||
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
|
||||
cx.new(|cx| SearchPanel::new(dock, window, cx))
|
||||
}
|
||||
|
||||
pub struct SearchPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
/// The dock a started chat opens in
|
||||
dock: WeakEntity<DockArea>,
|
||||
|
||||
/// Find input state
|
||||
find_input: Entity<InputState>,
|
||||
@@ -63,7 +66,7 @@ pub struct SearchPanel {
|
||||
}
|
||||
|
||||
impl SearchPanel {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let contact_list = cx.new(|_| None);
|
||||
let selected_pkeys = cx.new(|_| HashSet::new());
|
||||
let find_results = cx.new(|_| None);
|
||||
@@ -107,6 +110,7 @@ impl SearchPanel {
|
||||
Self {
|
||||
name: "Search".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock,
|
||||
find_input,
|
||||
find_debouncer: DebouncedDelay::new(),
|
||||
find_results,
|
||||
@@ -285,6 +289,7 @@ impl SearchPanel {
|
||||
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let async_chat = chat.downgrade();
|
||||
let dock = self.dock.clone();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
@@ -295,14 +300,26 @@ impl SearchPanel {
|
||||
let receivers = self.get_selected(cx);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
// Create a new room and emit it
|
||||
async_chat.update_in(cx, |this, _window, cx| {
|
||||
// Create a new room and register it
|
||||
let room = async_chat.update_in(cx, |chat, _window, cx| {
|
||||
let room = cx.new(|_| {
|
||||
Room::new(public_key, receivers)
|
||||
.organize(&public_key)
|
||||
.kind(RoomKind::Ongoing)
|
||||
});
|
||||
this.emit_room(&room, _window, cx);
|
||||
chat.track_room(&room, cx);
|
||||
room
|
||||
})?;
|
||||
|
||||
// Open it in the dock
|
||||
cx.update(|window, cx| {
|
||||
ui::dock::add_panel_to(
|
||||
&dock,
|
||||
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
|
||||
// Reset the find panel
|
||||
@@ -341,13 +358,17 @@ impl SearchPanel {
|
||||
this.select(&pkey_clone, cx);
|
||||
});
|
||||
|
||||
TreeRow::new(
|
||||
ElementId::NamedInteger("search-result".into(), (range.start + ix) as u64),
|
||||
TreeRowKind::Room,
|
||||
profile.name(),
|
||||
Nav::new(ElementId::NamedInteger(
|
||||
"search-result".into(),
|
||||
(range.start + ix) as u64,
|
||||
))
|
||||
.label(profile.name())
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when_some(
|
||||
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx),
|
||||
|this, avatar| this.prefix(avatar),
|
||||
)
|
||||
.avatar(profile.avatar_seed())
|
||||
.picture(profile.avatar())
|
||||
.on_click(handler)
|
||||
.selected(selected)
|
||||
.into_any_element()
|
||||
@@ -382,13 +403,17 @@ impl SearchPanel {
|
||||
this.select(&pkey_clone, cx);
|
||||
});
|
||||
|
||||
TreeRow::new(
|
||||
ElementId::NamedInteger("contact".into(), (range.start + ix) as u64),
|
||||
TreeRowKind::Room,
|
||||
profile.name().trim(),
|
||||
Nav::new(ElementId::NamedInteger(
|
||||
"contact".into(),
|
||||
(range.start + ix) as u64,
|
||||
))
|
||||
.label(profile.name().trim())
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when_some(
|
||||
nav_avatar(Some(profile.avatar_seed()), profile.avatar(), cx),
|
||||
|this, avatar| this.prefix(avatar),
|
||||
)
|
||||
.avatar(profile.avatar_seed())
|
||||
.picture(profile.avatar())
|
||||
.on_click(handler)
|
||||
.selected(selected)
|
||||
.into_any_element()
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
use gpui::{App, InteractiveElement, IntoElement, ParentElement, Styled, Window, div, svg};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::{StyledExt, h_flex, title_bar_drag_handlers, v_flex};
|
||||
|
||||
use crate::dialogs::import;
|
||||
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
||||
|
||||
pub(super) fn render(window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.bg(cx.theme().surface_background)
|
||||
.child(title_bar_drag_handlers(
|
||||
div()
|
||||
.id("onboarding-drag")
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.h(TABBAR_HEIGHT)
|
||||
.w_full(),
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_end()
|
||||
.gap_4()
|
||||
.p_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_8()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
)
|
||||
.child(
|
||||
v_flex().child(div().font_semibold().child(TITLE)).child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(DESCRIPTION),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("join-now")
|
||||
.label("Join now")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
.h_8()
|
||||
.w_full(),
|
||||
)
|
||||
.child(
|
||||
Button::new("import-identity")
|
||||
.label("Import identity")
|
||||
.secondary()
|
||||
.font_semibold()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.on_click(|_event, window, cx| import::open(window, cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,121 +1,38 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{App, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::{IconName, Selectable, h_flex};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SidebarTab {
|
||||
Recents,
|
||||
Chats,
|
||||
Inbox,
|
||||
Communities,
|
||||
}
|
||||
|
||||
impl SidebarTab {
|
||||
pub const ALL: [SidebarTab; 3] = [Self::Recents, Self::Chats, Self::Communities];
|
||||
pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Communities];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Recents => "Recents",
|
||||
Self::Chats => "Chats",
|
||||
Self::Inbox => "Inbox",
|
||||
Self::Communities => "Communities",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(self) -> IconName {
|
||||
/// Heading for the tab's list of items.
|
||||
pub fn list_title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Recents => IconName::History,
|
||||
Self::Chats => IconName::Message,
|
||||
Self::Communities => IconName::Group,
|
||||
Self::Inbox => "Direct Messages",
|
||||
Self::Communities => "Communities",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Recents => "sidebar-recents",
|
||||
Self::Chats => "sidebar-chats",
|
||||
Self::Inbox => "sidebar-inbox",
|
||||
Self::Communities => "sidebar-communities",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Recents => 0,
|
||||
Self::Chats => 1,
|
||||
Self::Communities => 2,
|
||||
Self::Inbox => 0,
|
||||
Self::Communities => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chat(self) -> bool {
|
||||
matches!(self, Self::Chats)
|
||||
}
|
||||
|
||||
pub fn community(self) -> bool {
|
||||
matches!(self, Self::Communities)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct TabBar {
|
||||
active: SidebarTab,
|
||||
on_select: Option<Rc<dyn Fn(SidebarTab, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl TabBar {
|
||||
pub fn new(active: SidebarTab) -> Self {
|
||||
Self {
|
||||
active,
|
||||
on_select: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_select(
|
||||
mut self,
|
||||
handler: impl Fn(SidebarTab, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_select = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TabBar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self { active, on_select } = self;
|
||||
|
||||
div()
|
||||
.id("sidebar-tabs")
|
||||
.absolute()
|
||||
.bottom_3()
|
||||
.left_0()
|
||||
.w_full()
|
||||
.px_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.p_1()
|
||||
.gap_1()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().background)
|
||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
||||
.children(SidebarTab::ALL.into_iter().map(|tab| {
|
||||
let on_select = on_select.clone();
|
||||
|
||||
Button::new(format!("tab-{}", tab.list_id()))
|
||||
.icon(tab.icon())
|
||||
.ghost()
|
||||
.flex_1()
|
||||
.rounded()
|
||||
.selected(tab == active)
|
||||
.tooltip(tab.label())
|
||||
.on_click(move |_event, window, cx| {
|
||||
if let Some(on_select) = on_select.as_ref() {
|
||||
on_select(tab, window, cx);
|
||||
}
|
||||
})
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use chat::Room;
|
||||
use community::Community;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::{Avatar, PixelAvatar};
|
||||
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex};
|
||||
|
||||
use super::tab::SidebarTab;
|
||||
|
||||
pub enum SidebarRow {
|
||||
Section {
|
||||
label: SharedString,
|
||||
count: usize,
|
||||
},
|
||||
Room {
|
||||
room: Entity<Room>,
|
||||
},
|
||||
Community {
|
||||
community: Entity<Community>,
|
||||
},
|
||||
Action {
|
||||
label: SharedString,
|
||||
tab: SidebarTab,
|
||||
},
|
||||
Hint {
|
||||
text: SharedString,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TreeRowKind {
|
||||
Section,
|
||||
Room,
|
||||
Community,
|
||||
Action,
|
||||
Hint,
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct TreeRow {
|
||||
id: ElementId,
|
||||
kind: TreeRowKind,
|
||||
label: SharedString,
|
||||
avatar: Option<SharedString>,
|
||||
picture: Option<ImageSource>,
|
||||
icon: Option<IconName>,
|
||||
count: Option<usize>,
|
||||
created_at: Option<SharedString>,
|
||||
selected: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl TreeRow {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
kind: TreeRowKind,
|
||||
label: impl Into<SharedString>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
kind,
|
||||
label: label.into(),
|
||||
avatar: None,
|
||||
picture: None,
|
||||
icon: None,
|
||||
count: None,
|
||||
created_at: None,
|
||||
selected: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the seed for the row's generated avatar.
|
||||
pub fn avatar(mut self, seed: impl Into<SharedString>) -> Self {
|
||||
self.avatar = Some(seed.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows `picture` instead of the generated avatar.
|
||||
pub fn picture(mut self, picture: Option<impl Into<ImageSource>>) -> Self {
|
||||
self.picture = picture.map(Into::into);
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows `icon` in the avatar slot when the row has no avatar or picture.
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn count(mut self, count: usize) -> Self {
|
||||
self.count = Some(count);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
|
||||
self.created_at = Some(created_at.into());
|
||||
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 TreeRow {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TreeRow {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
let is_section = self.kind == TreeRowKind::Section;
|
||||
let is_room = self.kind == TreeRowKind::Room;
|
||||
let is_community = self.kind == TreeRowKind::Community;
|
||||
let is_action = self.kind == TreeRowKind::Action;
|
||||
let is_hint = self.kind == TreeRowKind::Hint;
|
||||
let is_selected = self.selected;
|
||||
|
||||
let avatar = if hide_avatar {
|
||||
None
|
||||
} else {
|
||||
match (self.avatar, self.picture) {
|
||||
(None, None) => None,
|
||||
(seed, Some(picture)) => Some(
|
||||
Avatar::from_source(picture)
|
||||
.when_some(seed, |avatar, seed| avatar.seed(seed))
|
||||
.xsmall()
|
||||
.flex_shrink_0()
|
||||
.into_any_element(),
|
||||
),
|
||||
(Some(seed), None) => Some(
|
||||
PixelAvatar::new(seed)
|
||||
.xsmall()
|
||||
.flex_shrink_0()
|
||||
.into_any_element(),
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
let avatar = avatar.or_else(|| {
|
||||
self.icon.map(|icon| {
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.w(px(20.))
|
||||
.justify_center()
|
||||
.text_color(cx.theme().icon_muted)
|
||||
.child(Icon::new(icon).small())
|
||||
.into_any_element()
|
||||
})
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(is_section, |this| {
|
||||
this.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
})
|
||||
.when(is_room || is_community, |this| this.text_sm())
|
||||
.when(is_action, |this| {
|
||||
this.text_sm().text_color(cx.theme().text_muted)
|
||||
})
|
||||
.when(is_hint, |this| {
|
||||
this.text_xs()
|
||||
.font_normal()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
})
|
||||
.when_some(avatar, |this, avatar| this.child(avatar))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.flex_1()
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.min_w_0()
|
||||
.when(is_room, |this| this.font_medium())
|
||||
.child(self.label),
|
||||
)
|
||||
.when(is_selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CheckCircle)
|
||||
.small()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().icon_accent),
|
||||
)
|
||||
})
|
||||
.when_some(self.count, |this, count| {
|
||||
this.child(div().flex_shrink_0().font_normal().child(count.to_string()))
|
||||
})
|
||||
.when_some(self.created_at, |this, created_at| {
|
||||
this.child(div().flex_1()).child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_xs()
|
||||
.child(created_at),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(self.on_click, |this, handler| {
|
||||
this.cursor_pointer()
|
||||
.when(!is_section, |this| {
|
||||
this.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
})
|
||||
.on_click(move |event, window, cx| handler(event, window, cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{AnyElement, App, ImageSource, IntoElement, ParentElement, SharedString, Styled, px};
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::{Avatar, PixelAvatar};
|
||||
use ui::{Icon, IconName, Sizable, h_flex};
|
||||
|
||||
pub(crate) fn nav_avatar(
|
||||
seed: Option<impl Into<SharedString>>,
|
||||
picture: Option<impl Into<ImageSource>>,
|
||||
cx: &App,
|
||||
) -> Option<AnyElement> {
|
||||
if AppSettings::get_hide_avatar(cx) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match (seed.map(Into::into), picture.map(Into::into)) {
|
||||
(None, None) => None,
|
||||
(seed, Some(picture)) => Some(
|
||||
Avatar::from_source(picture)
|
||||
.when_some(seed, |avatar, seed| avatar.seed(seed))
|
||||
.small()
|
||||
.flex_shrink_0()
|
||||
.into_any_element(),
|
||||
),
|
||||
(Some(seed), None) => Some(
|
||||
PixelAvatar::new(seed)
|
||||
.small()
|
||||
.flex_shrink_0()
|
||||
.into_any_element(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.w(px(20.))
|
||||
.justify_center()
|
||||
.text_color(cx.theme().icon_muted)
|
||||
.child(Icon::new(icon).small())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Brand backgrounds shown behind the signed-out screen; one is picked at random.
|
||||
const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"];
|
||||
|
||||
/// Pick one of the signed-out backgrounds at random.
|
||||
pub(crate) fn pick_banner() -> SharedString {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|elapsed| elapsed.subsec_nanos())
|
||||
.unwrap_or_default();
|
||||
let index = nanos as usize % SIGNED_OUT_BANNERS.len();
|
||||
SIGNED_OUT_BANNERS[index].into()
|
||||
}
|
||||