diff --git a/crates/community_ui/src/lib.rs b/crates/community_ui/src/lib.rs index 68c1a58a..524d532b 100644 --- a/crates/community_ui/src/lib.rs +++ b/crates/community_ui/src/lib.rs @@ -27,10 +27,9 @@ mod message; /// How near the top row a scroll has to come before the panel splices older history in. const LOAD_OLDER_THRESHOLD: usize = 20; +/// A repeat message within this window keeps its run, so it carries no avatar or name. +const RUN_WINDOW_MS: u64 = 300_000; -/// Why a room is not showing the messages it knows about. -/// -/// "No messages yet" is a claim, and a room we cannot read is not an empty one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Notice { Stranded, @@ -59,23 +58,22 @@ impl fmt::Display for Notice { ), Notice::MissingKey(epoch) => write!( formatter, - "Messages here can't be read yet — this channel's key for epoch {} is missing", + "Messages here can't be read yet, this channel's key for epoch {} is missing", epoch.0 ), Notice::Unreachable => formatter.write_str("Couldn't reach the community's relays"), Notice::Unreadable(1) => { - formatter.write_str("1 message here can't be read yet — no key we hold opens it") + formatter.write_str("1 message here can't be read yet, no key we hold opens it") } Notice::Unreadable(count) => write!( formatter, - "{count} messages here can't be read yet — no key we hold opens them" + "{count} messages here can't be read yet, no key we hold opens them" ), } } } impl Notice { - /// Whether the room still accepts writes, rather than only being readable. fn writable(self) -> bool { matches!(self, Notice::Unreachable | Notice::Unreadable(_)) } @@ -653,6 +651,20 @@ impl CommunityPanel { .into_any_element() } + /// Whether the row at `index` opens a run from one author. + fn opens_run(&self, index: usize) -> bool { + let Some(current) = self.rows.get(index) else { + return true; + }; + + let Some(previous) = index.checked_sub(1).and_then(|index| self.rows.get(index)) else { + return true; + }; + + current.author != previous.author + || current.at_ms.saturating_sub(previous.at_ms) > RUN_WINDOW_MS + } + fn render_message( &mut self, ix: usize, @@ -667,7 +679,9 @@ impl CommunityPanel { return div().into_any_element(); }; - message::render(ix, message, cx) + let show_author = self.opens_run(ix - 1); + + message::render(ix, message, show_author, cx) } fn render_composer(&self, cx: &mut Context) -> impl IntoElement { diff --git a/crates/community_ui/src/message.rs b/crates/community_ui/src/message.rs index 19adbdc8..8fd27b27 100644 --- a/crates/community_ui/src/message.rs +++ b/crates/community_ui/src/message.rs @@ -4,7 +4,7 @@ use common::TimestampExt; use community::ChatMessage; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, + AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, px, }; use nostr_sdk::prelude::*; use person::PersonRegistry; @@ -13,7 +13,7 @@ use theme::ActiveTheme; use ui::avatar::Avatar; use ui::{StyledExt, h_flex, v_flex}; -pub(crate) fn render(ix: usize, message: &ChatMessage, cx: &App) -> AnyElement { +pub(crate) fn render(ix: usize, message: &ChatMessage, 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); @@ -29,35 +29,36 @@ pub(crate) fn render(ix: usize, message: &ChatMessage, cx: &App) -> AnyElement { .items_start() .gap_3() .when(!hide_avatar, |this| { - this.child( - Avatar::new(author.avatar()) - .seed(author.avatar_seed()) - .flex_shrink_0(), - ) + 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() - .child( - h_flex() - .gap_2() - .text_sm() - .child(div().font_semibold().child(author.name())) - .child( - div() - .text_color(cx.theme().text_placeholder) - .child(Timestamp::from_secs(message.at_ms / 1000).to_ago()), - ) - .when(message.edited_at.is_some(), |this| { - this.child( - div() - .text_color(cx.theme().text_placeholder) - .child("(edited)"), + .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)) diff --git a/crates/concord/src/cords/cord03.rs b/crates/concord/src/cords/cord03.rs index fc0344c8..aa84e7ac 100644 --- a/crates/concord/src/cords/cord03.rs +++ b/crates/concord/src/cords/cord03.rs @@ -129,7 +129,6 @@ pub fn build_message( build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms) } -/// `parent` is the immediate parent; a `None` root means the parent is the thread's root. #[allow(clippy::too_many_arguments)] pub fn build_comment( author: PublicKey, @@ -146,12 +145,14 @@ pub fn build_comment( tags.push(Tag::custom(TAG_ROOT_KIND, [root.kind.to_string()])); tags.push(reply_tag(TAG_ROOT, &root.reply)); + if let Some(root_author) = root.reply.author { tags.push(Tag::custom(TAG_ROOT_AUTHOR, [root_author.to_hex()])); } tags.push(Tag::custom(TAG_TARGET_KIND, [parent.kind.to_string()])); tags.push(reply_tag(TAG_TARGET, &parent.reply)); + if let Some(parent_author) = parent.reply.author { tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()])); }