feat(desktop): add deterministic nested thread panels

Add Slack-style thread summaries and drilldown navigation for channel replies, and scope typing indicators to the active thread so deeper conversations stay isolated from the main timeline.

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-13 14:29:17 -04:00
parent 8d24bd4130
commit 5c28fb783d
16 changed files with 1013 additions and 209 deletions
+39 -14
View File
@@ -21,7 +21,7 @@ use nostr::ToBech32;
use pool::{
AgentPool, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource, SessionState,
};
use queue::{EventQueue, QueuedEvent};
use queue::{EventQueue, QueuedEvent, ThreadTags};
use relay::HarnessRelay;
use sprout_core::kind::{
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE,
@@ -805,7 +805,7 @@ async fn tokio_main() -> Result<()> {
} else {
None
};
let mut typing_channels: HashSet<Uuid> = HashSet::new();
let mut typing_channels: HashMap<Uuid, ThreadTags> = HashMap::new();
let mut presence_task: Option<tokio::task::JoinHandle<()>> = None;
// ── Step 6d: Maintenance (slot refill + queue compaction) ────────────────
@@ -931,7 +931,9 @@ async fn tokio_main() -> Result<()> {
// called on relay events or pool results, neither of which
// arrive when the channel is silent.
if queue.has_flushable_work() {
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) {
typing_channels.insert(channel_id, thread_tags);
}
}
}
@@ -962,7 +964,9 @@ async fn tokio_main() -> Result<()> {
// this, batches requeued during crash recovery sit idle until the
// next relay event arrives — which can be minutes on quiet channels.
if respawn_collected {
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) {
typing_channels.insert(channel_id, thread_tags);
}
}
// Borrow result_rx and join_set simultaneously via split-borrow helper.
@@ -1272,7 +1276,11 @@ async fn tokio_main() -> Result<()> {
}
}
// ── End mode gate ────────────────────────────────
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
for (channel_id, thread_tags) in
dispatch_pending(&mut pool, &mut queue, &ctx)
{
typing_channels.insert(channel_id, thread_tags);
}
}
None => {
tracing::warn!("relay event stream ended — requesting reconnect");
@@ -1294,7 +1302,11 @@ async fn tokio_main() -> Result<()> {
let _ = result_rx;
if queue.has_flushable_work() {
tracing::debug!("heartbeat_skipped_events");
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
for (channel_id, thread_tags) in
dispatch_pending(&mut pool, &mut queue, &ctx)
{
typing_channels.insert(channel_id, thread_tags);
}
} else if pool.any_idle() {
dispatch_heartbeat(&mut pool, &ctx, &mut heartbeat_in_flight);
} else {
@@ -1331,8 +1343,12 @@ async fn tokio_main() -> Result<()> {
// Use try_publish (non-blocking) for typing indicators —
// they're ephemeral and must not block the main loop during
// relay reconnection (#35).
for &ch in &typing_channels {
if let Ok(event) = relay.build_typing_event(ch) {
for (&ch, thread_tags) in &typing_channels {
if let Ok(event) = relay.build_typing_event(
ch,
thread_tags.root_event_id.as_deref(),
thread_tags.parent_event_id.as_deref(),
) {
if let Err(e) = relay.try_publish_event(event) {
tracing::debug!("typing indicator dropped for {ch}: {e}");
}
@@ -1381,7 +1397,9 @@ async fn tokio_main() -> Result<()> {
{
break;
}
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) {
typing_channels.insert(channel_id, thread_tags);
}
}
Some(PoolEvent::Panic(join_error)) => {
tracing::error!("agent task panicked: {join_error}");
@@ -1401,7 +1419,9 @@ async fn tokio_main() -> Result<()> {
tracing::error!("all agents dead — exiting");
break;
}
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) {
typing_channels.insert(channel_id, thread_tags);
}
}
None => {} // relay/heartbeat/shutdown branches handled inline above
}
@@ -1540,7 +1560,7 @@ fn dispatch_pending(
pool: &mut AgentPool,
queue: &mut EventQueue,
ctx: &Arc<PromptContext>,
) -> Vec<Uuid> {
) -> Vec<(Uuid, ThreadTags)> {
let mut dispatched_channels = Vec::new();
loop {
let batch = match queue.flush_next() {
@@ -1548,6 +1568,11 @@ fn dispatch_pending(
None => break,
};
let channel_id = batch.channel_id;
let typing_scope = batch
.events
.last()
.map(|event| queue::parse_thread_tags(&event.event))
.unwrap_or_default();
let affinity_hit = pool.has_session_for(channel_id);
let agent = match pool.try_claim(Some(channel_id)) {
Some(a) => a,
@@ -1595,7 +1620,7 @@ fn dispatch_pending(
cancel_tx: Some(cancel_tx),
},
);
dispatched_channels.push(channel_id);
dispatched_channels.push((channel_id, typing_scope));
}
tracing::debug!(
dispatched = dispatched_channels.len(),
@@ -1779,7 +1804,7 @@ fn recover_panicked_agent(
join_error: tokio::task::JoinError,
heartbeat_in_flight: &mut bool,
removed_channels: &HashSet<Uuid>,
typing_channels: &mut HashSet<Uuid>,
typing_channels: &mut HashMap<Uuid, ThreadTags>,
crash_history: &mut [SlotCircuit],
respawn_tx: &mpsc::Sender<RespawnResult>,
respawn_tasks: &mut tokio::task::JoinSet<()>,
@@ -1863,7 +1888,7 @@ fn drain_ready_join_results(
config: &Config,
heartbeat_in_flight: &mut bool,
removed_channels: &HashSet<Uuid>,
typing_channels: &mut HashSet<Uuid>,
typing_channels: &mut HashMap<Uuid, ThreadTags>,
crash_history: &mut [SlotCircuit],
respawn_tx: &mpsc::Sender<RespawnResult>,
respawn_tasks: &mut tokio::task::JoinSet<()>,
+22 -2
View File
@@ -564,10 +564,30 @@ impl HarnessRelay {
}
/// Build a typing indicator event (kind:20002) for a channel.
pub fn build_typing_event(&self, channel_id: Uuid) -> Result<Event, RelayError> {
pub fn build_typing_event(
&self,
channel_id: Uuid,
root_event_id: Option<&str>,
parent_event_id: Option<&str>,
) -> Result<Event, RelayError> {
let h_tag = Tag::parse(&["h", &channel_id.to_string()])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?;
let event = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "", [h_tag])
let mut tags = vec![h_tag];
if let Some(parent) = parent_event_id {
if let Some(root) = root_event_id {
if root != parent {
tags.push(
Tag::parse(&["e", root, "", "root"])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?,
);
}
}
tags.push(
Tag::parse(&["e", parent, "", "reply"])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?,
);
}
let event = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "", tags)
.sign_with_keys(&self.keys)?;
Ok(event)
}
+130 -85
View File
@@ -1,8 +1,10 @@
import * as React from "react";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel";
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
@@ -22,12 +24,20 @@ type ChannelPaneProps = {
isTimelineLoading: boolean;
messages: TimelineMessage[];
onCancelEdit?: () => void;
onCancelReply: () => void;
onBackThread: () => void;
onCancelThreadReply: () => void;
onCloseThread: () => void;
onDelete?: (message: TimelineMessage) => void;
onEdit?: (message: TimelineMessage) => void;
onEditSave?: (content: string) => Promise<void>;
onReply: (message: TimelineMessage) => void;
onSend: (
onOpenNestedThread: (message: TimelineMessage) => void;
onOpenThread: (message: TimelineMessage) => void;
onSendMessage: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<void>;
onSendThreadReply: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
@@ -41,8 +51,14 @@ type ChannelPaneProps = {
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
replyTargetId: string | null;
replyTargetMessage: TimelineMessage | null;
canGoBackThread: boolean;
openThreadHeadId: string | null;
threadHeadMessage: TimelineMessage | null;
threadMessages: MainTimelineEntry[];
threadTypingPubkeys: string[];
threadTotalReplyCount: number;
threadReplyTargetId: string | null;
threadReplyTargetMessage: TimelineMessage | null;
targetMessageId: string | null;
typingPubkeys: string[];
};
@@ -57,97 +73,126 @@ export const ChannelPane = React.memo(function ChannelPane({
isSending,
isTimelineLoading,
messages,
onBackThread,
onCancelEdit,
onCancelReply,
onCancelThreadReply,
onCloseThread,
onDelete,
onEdit,
onEditSave,
onReply,
onSend,
onOpenNestedThread,
onOpenThread,
onSendMessage,
onSendThreadReply,
onTargetReached,
onToggleReaction,
canGoBackThread,
personaLookup,
profiles,
replyTargetId,
replyTargetMessage,
openThreadHeadId,
targetMessageId,
threadHeadMessage,
threadMessages,
threadTypingPubkeys,
threadTotalReplyCount,
threadReplyTargetId,
threadReplyTargetMessage,
typingPubkeys,
}: ChannelPaneProps) {
const isComposerDisabled =
!activeChannel ||
!activeChannel.isMember ||
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
isSending;
return (
<>
<MessageTimeline
channelId={activeChannel?.id}
activeReplyTargetId={replyTargetId}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
personaLookup={personaLookup}
profiles={profiles}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
: "Messages and sub-replies will appear here once the relay has history for this channel."
}
emptyTitle={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={isTimelineLoading}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onReply={onReply}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
targetMessageId={targetMessageId}
/>
<TypingIndicatorRow
channel={activeChannel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={
!activeChannel ||
!activeChannel.isMember ||
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
isSending
}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelReply}
onEditSave={onEditSave}
onSend={onSend}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel && !activeChannel.isMember
? "Join this channel to message."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
}
replyTarget={
replyTargetMessage
? {
author: replyTargetMessage.author,
body: replyTargetMessage.body,
id: replyTargetMessage.id,
}
: null
}
/>
</>
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<MessageTimeline
channelId={activeChannel?.id}
activeReplyTargetId={openThreadHeadId}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
personaLookup={personaLookup}
profiles={profiles}
emptyDescription={
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
: "Messages and sub-replies will appear here once the relay has history for this channel."
}
emptyTitle={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={isTimelineLoading}
messages={messages}
onDelete={onDelete}
onEdit={onEdit}
onReply={onOpenThread}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
targetMessageId={targetMessageId}
/>
<TypingIndicatorRow
channel={activeChannel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={isComposerDisabled}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onEditSave={onEditSave}
onSend={onSendMessage}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel && !activeChannel.isMember
? "Join this channel to message."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
}
/>
</div>
{threadHeadMessage ? (
<MessageThreadPanel
canGoBack={canGoBackThread}
channel={activeChannel}
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
currentPubkey={currentPubkey}
disabled={isComposerDisabled}
isSending={isSending}
onBack={onBackThread}
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}
onDelete={onDelete}
onOpenNestedThread={onOpenNestedThread}
onSend={onSendThreadReply}
onToggleReaction={onToggleReaction}
profiles={profiles}
replyTargetId={threadReplyTargetId}
replyTargetMessage={threadReplyTargetMessage}
threadHead={threadHeadMessage}
threadReplies={threadMessages}
threadTypingPubkeys={threadTypingPubkeys}
totalReplyCount={threadTotalReplyCount}
/>
) : null}
</div>
);
});
@@ -31,6 +31,7 @@ import {
getChannelIdFromTags,
getThreadReference,
} from "@/features/messages/lib/threading";
import { buildThreadPanelData } from "@/features/messages/lib/threadPanel";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useChannelTyping } from "@/features/messages/useChannelTyping";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
@@ -81,10 +82,14 @@ export function ChannelScreen({
const queryClient = useQueryClient();
const { markChannelRead, openChannelManagement } = useAppShell();
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
const [threadHeadPath, setThreadHeadPath] = React.useState<string[]>([]);
const [threadReplyTargetId, setThreadReplyTargetId] = React.useState<
string | null
>(null);
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
const currentPubkey = currentIdentity?.pubkey;
const activeChannelId = activeChannel?.id ?? null;
const openThreadHeadId = threadHeadPath[threadHeadPath.length - 1] ?? null;
const messagesQuery = useChannelMessagesQuery(activeChannel);
useChannelSubscription(activeChannel);
@@ -134,14 +139,34 @@ export function ChannelScreen({
() => resolvedMessages[resolvedMessages.length - 1] ?? null,
[resolvedMessages],
);
const typingPubkeys = useChannelTyping(
const typingEntries = useChannelTyping(
activeChannel,
currentPubkey,
latestMessageEvent,
);
const mainTypingPubkeys = React.useMemo(
() =>
typingEntries
.filter((entry) => entry.threadHeadId === null)
.map((entry) => entry.pubkey),
[typingEntries],
);
const threadTypingPubkeys = React.useMemo(
() =>
typingEntries
.filter((entry) => entry.threadHeadId === openThreadHeadId)
.map((entry) => entry.pubkey),
[openThreadHeadId, typingEntries],
);
const messageProfilePubkeys = React.useMemo(
() => [...new Set([...messageAuthorPubkeys, ...typingPubkeys])],
[messageAuthorPubkeys, typingPubkeys],
() =>
[
...new Set([
...messageAuthorPubkeys,
...typingEntries.map((entry) => entry.pubkey),
]),
],
[messageAuthorPubkeys, typingEntries],
);
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
enabled: messageProfilePubkeys.length > 0,
@@ -213,11 +238,19 @@ export function ChannelScreen({
resolvedMessages,
],
);
const replyTargetMessage = React.useMemo(
const threadPanelData = React.useMemo(
() =>
timelineMessages.find((message) => message.id === replyTargetId) ?? null,
[replyTargetId, timelineMessages],
buildThreadPanelData(
timelineMessages,
openThreadHeadId,
threadReplyTargetId,
),
[openThreadHeadId, threadReplyTargetId, timelineMessages],
);
const openThreadHeadMessage = threadPanelData.threadHead;
const threadMessages = threadPanelData.visibleReplies;
const threadReplyTargetMessage = threadPanelData.replyTargetMessage;
const threadTotalReplyCount = threadPanelData.totalReplyCount;
const editTargetMessage = React.useMemo(
() =>
timelineMessages.find((message) => message.id === editTargetId) ?? null,
@@ -226,21 +259,27 @@ export function ChannelScreen({
const {
handleCancelEdit,
handleCancelReply,
handleCancelThreadReply,
handleBackThread,
handleCloseThread,
handleDelete,
handleEdit,
handleEditSave,
handleReply,
handleSend,
handleOpenNestedThread,
handleOpenThread,
handleSendMessage,
handleSendThreadReply,
handleToggleReaction,
} = useChannelPaneHandlers({
deleteMessageMutation,
editMessageMutation,
editTargetId,
replyTargetId,
openThreadHeadId,
sendMessageMutation,
setEditTargetId,
setReplyTargetId,
setThreadHeadPath,
setThreadReplyTargetId,
threadReplyTargetId,
toggleReactionMutation,
});
@@ -273,7 +312,8 @@ export function ChannelScreen({
const requestedAncestorIdsRef = React.useRef<Set<string>>(new Set());
const resetComposerTargets = React.useCallback(
(_channelId: string | null) => {
setReplyTargetId(null);
setThreadHeadPath([]);
setThreadReplyTargetId(null);
setEditTargetId(null);
},
[],
@@ -290,13 +330,31 @@ export function ChannelScreen({
}, [activeChannelId, resetComposerTargets]);
React.useEffect(() => {
if (replyTargetId && !replyTargetMessage) {
setReplyTargetId(null);
if (openThreadHeadId && !openThreadHeadMessage) {
setThreadHeadPath((current) => current.slice(0, -1));
return;
}
if (openThreadHeadMessage && !threadReplyTargetId) {
setThreadReplyTargetId(openThreadHeadMessage.id);
return;
}
if (threadReplyTargetId && !threadReplyTargetMessage) {
setThreadReplyTargetId(openThreadHeadMessage?.id ?? null);
}
if (editTargetId && !editTargetMessage) {
setEditTargetId(null);
}
}, [editTargetId, editTargetMessage, replyTargetId, replyTargetMessage]);
}, [
editTargetId,
editTargetMessage,
openThreadHeadId,
openThreadHeadMessage,
threadHeadPath,
threadReplyTargetId,
threadReplyTargetMessage,
]);
React.useEffect(() => {
resetRequestedAncestors(activeChannelId);
@@ -454,19 +512,29 @@ export function ChannelScreen({
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
onCancelReply={handleCancelReply}
onCancelThreadReply={handleCancelThreadReply}
onBackThread={handleBackThread}
onCloseThread={handleCloseThread}
onDelete={handleDelete}
onEdit={handleEdit}
onEditSave={handleEditSave}
onReply={handleReply}
onSend={handleSend}
onOpenNestedThread={handleOpenNestedThread}
onOpenThread={handleOpenThread}
onSendMessage={handleSendMessage}
onSendThreadReply={handleSendThreadReply}
onToggleReaction={effectiveToggleReaction}
canGoBackThread={threadHeadPath.length > 1}
openThreadHeadId={openThreadHeadId}
personaLookup={personaLookup}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
targetMessageId={targetMessageId}
typingPubkeys={typingPubkeys}
threadHeadMessage={openThreadHeadMessage}
threadMessages={threadMessages}
threadTypingPubkeys={threadTypingPubkeys}
threadTotalReplyCount={threadTotalReplyCount}
threadReplyTargetId={threadReplyTargetId}
threadReplyTargetMessage={threadReplyTargetMessage}
typingPubkeys={mainTypingPubkeys}
/>
</React.Suspense>
)
@@ -19,24 +19,31 @@ export function useChannelPaneHandlers({
deleteMessageMutation,
editMessageMutation,
editTargetId,
replyTargetId,
openThreadHeadId,
sendMessageMutation,
setEditTargetId,
setReplyTargetId,
setThreadHeadPath,
setThreadReplyTargetId,
threadReplyTargetId,
toggleReactionMutation,
}: {
deleteMessageMutation: ReturnType<typeof useDeleteMessageMutation>;
editMessageMutation: ReturnType<typeof useEditMessageMutation>;
editTargetId: string | null;
replyTargetId: string | null;
openThreadHeadId: string | null;
sendMessageMutation: ReturnType<typeof useSendMessageMutation>;
setEditTargetId: React.Dispatch<React.SetStateAction<string | null>>;
setReplyTargetId: React.Dispatch<React.SetStateAction<string | null>>;
setThreadHeadPath: React.Dispatch<React.SetStateAction<string[]>>;
setThreadReplyTargetId: React.Dispatch<React.SetStateAction<string | null>>;
threadReplyTargetId: string | null;
toggleReactionMutation: ReturnType<typeof useToggleReactionMutation>;
}) {
// Keep mutable values in refs so callbacks never need to list them as deps.
const replyTargetIdRef = React.useRef(replyTargetId);
replyTargetIdRef.current = replyTargetId;
const openThreadHeadIdRef = React.useRef(openThreadHeadId);
openThreadHeadIdRef.current = openThreadHeadId;
const threadReplyTargetIdRef = React.useRef(threadReplyTargetId);
threadReplyTargetIdRef.current = threadReplyTargetId;
const editTargetIdRef = React.useRef(editTargetId);
editTargetIdRef.current = editTargetId;
@@ -53,9 +60,25 @@ export function useChannelPaneHandlers({
const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync);
toggleMutateRef.current = toggleReactionMutation.mutateAsync;
const handleCancelReply = React.useCallback(() => {
setReplyTargetId(null);
}, [setReplyTargetId]);
const handleCancelThreadReply = React.useCallback(() => {
setThreadReplyTargetId(openThreadHeadIdRef.current);
}, [setThreadReplyTargetId]);
const handleCloseThread = React.useCallback(() => {
setThreadHeadPath([]);
setThreadReplyTargetId(null);
}, [setThreadHeadPath, setThreadReplyTargetId]);
const handleBackThread = React.useCallback(() => {
setThreadHeadPath((current) => {
if (current.length <= 1) {
return current;
}
const nextPath = current.slice(0, -1);
setThreadReplyTargetId(nextPath[nextPath.length - 1] ?? null);
return nextPath;
});
}, [setThreadHeadPath, setThreadReplyTargetId]);
const handleCancelEdit = React.useCallback(() => {
setEditTargetId(null);
@@ -70,10 +93,9 @@ export function useChannelPaneHandlers({
setEditTargetId((current) =>
current === message.id ? null : message.id,
);
// Clear reply when entering edit mode.
setReplyTargetId(null);
setThreadReplyTargetId(openThreadHeadIdRef.current);
},
[setEditTargetId, setReplyTargetId],
[setEditTargetId, setThreadReplyTargetId],
);
const handleEditSave = React.useCallback(
@@ -89,18 +111,37 @@ export function useChannelPaneHandlers({
[setEditTargetId],
);
const handleReply = React.useCallback(
const handleOpenThread = React.useCallback(
(message: { id: string }) => {
setReplyTargetId((current) =>
current === message.id ? null : message.id,
);
// Clear edit when entering reply mode.
if (openThreadHeadIdRef.current === message.id) {
setThreadHeadPath([]);
setThreadReplyTargetId(null);
setEditTargetId(null);
return;
}
setThreadHeadPath([message.id]);
setThreadReplyTargetId(message.id);
setEditTargetId(null);
},
[setReplyTargetId, setEditTargetId],
[setEditTargetId, setThreadHeadPath, setThreadReplyTargetId],
);
const handleSend = React.useCallback(
const handleOpenNestedThread = React.useCallback(
(message: { id: string }) => {
setThreadHeadPath((current) => {
if (current[current.length - 1] === message.id) {
return current;
}
return [...current, message.id];
});
setThreadReplyTargetId(message.id);
setEditTargetId(null);
},
[setEditTargetId, setThreadHeadPath, setThreadReplyTargetId],
);
const handleSendMessage = React.useCallback(
async (
content: string,
mentionPubkeys: string[],
@@ -109,12 +150,33 @@ export function useChannelPaneHandlers({
await sendMutateRef.current({
content,
mentionPubkeys,
parentEventId: replyTargetIdRef.current,
mediaTags,
});
setReplyTargetId(null);
},
[setReplyTargetId],
[],
);
const handleSendThreadReply = React.useCallback(
async (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => {
const parentEventId =
threadReplyTargetIdRef.current ?? openThreadHeadIdRef.current;
if (!parentEventId) {
return;
}
await sendMutateRef.current({
content,
mentionPubkeys,
parentEventId,
mediaTags,
});
setThreadReplyTargetId(openThreadHeadIdRef.current);
},
[setThreadReplyTargetId],
);
const handleToggleReaction = React.useCallback(
@@ -130,12 +192,16 @@ export function useChannelPaneHandlers({
return {
handleCancelEdit,
handleCancelReply,
handleCancelThreadReply,
handleBackThread,
handleCloseThread,
handleDelete,
handleEdit,
handleEditSave,
handleReply,
handleSend,
handleOpenNestedThread,
handleOpenThread,
handleSendMessage,
handleSendThreadReply,
handleToggleReaction,
};
}
@@ -0,0 +1,162 @@
import type { TimelineMessage } from "@/features/messages/types";
type ThreadPanelData = {
threadHead: TimelineMessage | null;
totalReplyCount: number;
visibleReplies: MainTimelineEntry[];
replyTargetMessage: TimelineMessage | null;
};
export type TimelineThreadSummaryParticipant = {
id: string;
author: string;
avatarUrl: string | null;
};
export type TimelineThreadSummary = {
threadHeadId: string;
replyCount: number;
participants: TimelineThreadSummaryParticipant[];
};
export type MainTimelineEntry = {
message: TimelineMessage;
summary: TimelineThreadSummary | null;
};
function normalizeHeadMessage(message: TimelineMessage): TimelineMessage {
return {
...message,
depth: 0,
};
}
function normalizeBranchReply(message: TimelineMessage): TimelineMessage {
return {
...message,
// Thread-panel replies render flat like Slack's side thread view.
depth: 0,
};
}
function buildSummaryParticipants(
replies: TimelineMessage[],
): TimelineThreadSummaryParticipant[] {
const recentUniqueParticipants = new Map<string, TimelineThreadSummaryParticipant>();
for (let index = replies.length - 1; index >= 0; index -= 1) {
const reply = replies[index];
const participantKey = reply.pubkey ?? reply.id;
if (recentUniqueParticipants.has(participantKey)) {
continue;
}
recentUniqueParticipants.set(participantKey, {
id: participantKey,
author: reply.author,
avatarUrl: reply.avatarUrl ?? null,
});
if (recentUniqueParticipants.size >= 3) {
break;
}
}
return [...recentUniqueParticipants.values()].reverse();
}
function buildDirectChildrenByParentId(messages: TimelineMessage[]) {
const childrenByParentId = new Map<string, TimelineMessage[]>();
for (const message of messages) {
if (!message.parentId) {
continue;
}
const children = childrenByParentId.get(message.parentId) ?? [];
children.push(message);
childrenByParentId.set(message.parentId, children);
}
return childrenByParentId;
}
function buildSummaryForDirectReplies(
messageId: string,
directChildrenByParentId: Map<string, TimelineMessage[]>,
): TimelineThreadSummary | null {
const directReplies = directChildrenByParentId.get(messageId) ?? [];
if (directReplies.length === 0) {
return null;
}
return {
threadHeadId: messageId,
replyCount: directReplies.length,
participants: buildSummaryParticipants(directReplies),
};
}
export function buildMainTimelineEntries(
messages: TimelineMessage[],
): MainTimelineEntry[] {
const directChildrenByParentId = buildDirectChildrenByParentId(messages);
return messages
.filter((message) => message.parentId == null)
.map((message) => {
return {
message,
summary: buildSummaryForDirectReplies(message.id, directChildrenByParentId),
};
});
}
export function buildThreadPanelData(
messages: TimelineMessage[],
openThreadHeadId: string | null,
threadReplyTargetId: string | null,
): ThreadPanelData {
if (!openThreadHeadId) {
return {
threadHead: null,
totalReplyCount: 0,
visibleReplies: [],
replyTargetMessage: null,
};
}
const messageById = new Map(messages.map((message) => [message.id, message]));
const threadHead = messageById.get(openThreadHeadId) ?? null;
if (!threadHead) {
return {
threadHead: null,
totalReplyCount: 0,
visibleReplies: [],
replyTargetMessage: null,
};
}
const directChildrenByParentId = buildDirectChildrenByParentId(messages);
const normalizedThreadHead = normalizeHeadMessage(threadHead);
const directReplies = (directChildrenByParentId.get(openThreadHeadId) ?? []).map(
(message) => normalizeBranchReply(message),
);
const visibleReplies = directReplies.map((message) => ({
message,
summary: buildSummaryForDirectReplies(message.id, directChildrenByParentId),
}));
const replyTargetInBranch =
threadReplyTargetId === threadHead.id
? normalizedThreadHead
: messageById.get(threadReplyTargetId ?? "") ?? null;
return {
threadHead: normalizedThreadHead,
totalReplyCount: directReplies.length,
visibleReplies,
replyTargetMessage: replyTargetInBranch ?? normalizedThreadHead,
};
}
@@ -94,6 +94,27 @@ export function buildReplyTags(
return tags;
}
export function buildThreadReferenceTags(
channelId: string,
parentEventId: string | null,
rootEventId: string | null,
) {
const tags: string[][] = [["h", channelId]];
if (!parentEventId) {
return tags;
}
if (!rootEventId || parentEventId === rootEventId) {
tags.push(["e", parentEventId, "", "reply"]);
return tags;
}
tags.push(["e", rootEventId, "", "root"]);
tags.push(["e", parentEventId, "", "reply"]);
return tags;
}
export function resolveReplyRootId(
parentEventId: string,
events: RelayEvent[],
@@ -40,6 +40,8 @@ type MessageComposerProps = {
body: string;
id: string;
} | null;
typingParentEventId?: string | null;
typingRootEventId?: string | null;
};
const MAX_TEXTAREA_ROWS = 4;
@@ -56,6 +58,8 @@ export function MessageComposer({
onSend,
placeholder,
replyTarget = null,
typingParentEventId = null,
typingRootEventId = null,
}: MessageComposerProps) {
const [content, setContent] = React.useState("");
const contentRef = React.useRef(content);
@@ -74,7 +78,11 @@ export function MessageComposer({
const mentions = useMentions(channelId);
const channelLinks = useChannelLinks();
const notifyTyping = useTypingBroadcast(channelId);
const notifyTyping = useTypingBroadcast(
channelId,
typingParentEventId,
typingRootEventId,
);
const media = useMediaUpload(setContent);
@@ -11,15 +11,9 @@ import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import { BotIdenticon } from "./BotIdenticon";
import { MessageActionBar } from "./MessageActionBar";
import { MessageTimestamp } from "./MessageTimestamp";
/** Returns true if this message is from a bot instance. */
function isBotInstance(role?: string): boolean {
return role === "bot";
}
const DiffMessage = React.lazy(() => import("./DiffMessage"));
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
@@ -177,13 +171,6 @@ export const MessageRow = React.memo(
data-testid="message-row"
>
<div className="flex shrink-0 items-center gap-1">
{isBotInstance(message.role) ? (
<BotIdenticon
value={message.author}
size={20}
className="rounded"
/>
) : null}
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
@@ -0,0 +1,209 @@
import { ArrowLeft, MessageSquareText, X } from "lucide-react";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { MessageComposer } from "./MessageComposer";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
type MessageThreadPanelProps = {
canGoBack: boolean;
channel: Channel | null;
channelId: string | null;
channelName: string;
currentPubkey?: string;
disabled?: boolean;
isSending: boolean;
onBack: () => void;
onCancelReply: () => void;
onClose: () => void;
onDelete?: (message: TimelineMessage) => void;
onOpenNestedThread: (message: TimelineMessage) => void;
onSend: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<void>;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,
remove: boolean,
) => Promise<void>;
profiles?: UserProfileLookup;
replyTargetId: string | null;
replyTargetMessage: TimelineMessage | null;
threadHead: TimelineMessage | null;
threadReplies: MainTimelineEntry[];
threadTypingPubkeys: string[];
totalReplyCount: number;
};
function canManageMessage(
message: TimelineMessage,
currentPubkey: string | undefined,
): boolean {
return Boolean(
currentPubkey &&
message.pubkey &&
currentPubkey.toLowerCase() === message.pubkey.toLowerCase(),
);
}
export function MessageThreadPanel({
canGoBack,
channel,
channelId,
channelName,
currentPubkey,
disabled = false,
isSending,
onBack,
onCancelReply,
onClose,
onDelete,
onOpenNestedThread,
onSend,
onToggleReaction,
profiles,
replyTargetId,
replyTargetMessage,
threadHead,
threadReplies,
threadTypingPubkeys,
totalReplyCount,
}: MessageThreadPanelProps) {
if (!threadHead) {
return null;
}
const composerReplyTarget =
replyTargetMessage && replyTargetMessage.id !== threadHead.id
? {
author: replyTargetMessage.author,
body: replyTargetMessage.body,
id: replyTargetMessage.id,
}
: null;
return (
<aside
className="hidden h-full w-[380px] shrink-0 flex-col border-l border-border/80 bg-background lg:flex"
data-testid="message-thread-panel"
>
<div className="flex items-center gap-3 border-b border-border/80 px-4 py-3">
{canGoBack ? (
<Button
aria-label="Back"
data-testid="message-thread-back"
onClick={onBack}
size="icon"
type="button"
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
</Button>
) : null}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<MessageSquareText className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold tracking-tight">Thread</h2>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{totalReplyCount} {totalReplyCount === 1 ? "reply" : "replies"}
</p>
</div>
<Button
aria-label="Close thread"
data-testid="message-thread-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
<TypingIndicatorRow
channel={channel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
/>
<div className="min-h-0 flex-1 overflow-y-auto" data-testid="message-thread-body">
<div className="border-b border-border/60 px-3 py-3" data-testid="message-thread-head">
<MessageRow
activeReplyTargetId={replyTargetId}
message={threadHead}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
: undefined
}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
</div>
<div className="px-3 py-3" data-testid="message-thread-replies">
{threadReplies.length > 0 ? (
<div className="space-y-2">
{threadReplies.map((entry) => (
<div key={entry.message.id}>
<MessageRow
activeReplyTargetId={replyTargetId}
message={entry.message}
onDelete={
onDelete && canManageMessage(entry.message, currentPubkey)
? onDelete
: undefined
}
onReply={onOpenNestedThread}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{entry.summary ? (
<MessageThreadSummaryRow
message={entry.message}
onOpenThread={onOpenNestedThread}
summary={entry.summary}
/>
) : null}
</div>
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-border/70 bg-card/40 px-4 py-6 text-center">
<p className="text-sm font-medium text-foreground/80">
No replies in this branch yet
</p>
<p className="mt-1 text-xs text-muted-foreground">
Reply in the thread to continue this branch.
</p>
</div>
)}
</div>
</div>
<div className="border-t border-border/80 p-4">
<MessageComposer
channelId={channelId}
channelName={channelName}
disabled={disabled || isSending || !channelId}
isSending={isSending}
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
onSend={onSend}
placeholder={`Reply in thread to ${threadHead.author}`}
replyTarget={composerReplyTarget}
typingParentEventId={threadHead.id}
typingRootEventId={threadHead.rootId}
/>
</div>
</aside>
);
}
@@ -0,0 +1,69 @@
import { MessageSquareText } from "lucide-react";
import type {
TimelineThreadSummary,
TimelineThreadSummaryParticipant,
} from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import { UserAvatar } from "@/shared/ui/UserAvatar";
function ParticipantAvatar({
participant,
index,
}: {
participant: TimelineThreadSummaryParticipant;
index: number;
}) {
return (
<div
className={index > 0 ? "-ml-2" : ""}
style={{ zIndex: 10 - index }}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="rounded-full border-2 border-background"
displayName={participant.author}
size="xs"
/>
</div>
);
}
export function MessageThreadSummaryRow({
message,
onOpenThread,
summary,
}: {
message: TimelineMessage;
onOpenThread: (message: TimelineMessage) => void;
summary: TimelineThreadSummary;
}) {
return (
<button
className="ml-8 flex w-fit max-w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
data-thread-head-id={message.id}
data-testid="message-thread-summary"
onClick={() => onOpenThread(message)}
type="button"
>
<div className="flex shrink-0 items-center">
{summary.participants.map((participant, index) => (
<ParticipantAvatar
index={index}
key={participant.id}
participant={participant}
/>
))}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5 font-medium">
<MessageSquareText className="h-3.5 w-3.5" />
<span>
{summary.replyCount}{" "}
{summary.replyCount === 1 ? "reply" : "replies"}
</span>
</div>
</div>
</button>
);
}
@@ -4,11 +4,13 @@ import {
formatDayHeading,
isSameDay,
} from "@/features/messages/lib/dateFormatters";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import { DayDivider } from "./DayDivider";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { SystemMessageRow } from "./SystemMessageRow";
type TimelineMessageListProps = {
@@ -42,10 +44,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
profiles,
}: TimelineMessageListProps) {
const elements: React.ReactNode[] = [];
const entries = React.useMemo(
() => buildMainTimelineEntries(messages),
[messages],
);
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
const prev = i > 0 ? messages[i - 1] : null;
for (let i = 0; i < entries.length; i++) {
const { message, summary } = entries[i];
const prev = i > 0 ? entries[i - 1]?.message : null;
if (!prev || !isSameDay(prev.createdAt, message.createdAt)) {
elements.push(
@@ -90,6 +96,17 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
profiles={profiles}
/>,
);
if (summary && onReply) {
elements.push(
<MessageThreadSummaryRow
key={`thread-summary-${message.id}`}
message={message}
onOpenThread={onReply}
summary={summary}
/>,
);
}
}
}
@@ -1,6 +1,9 @@
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
import {
getChannelIdFromTags,
getThreadReference,
} from "@/features/messages/lib/threading";
import { relayClient } from "@/shared/api/relayClient";
import type { Channel, RelayEvent } from "@/shared/api/types";
import {
@@ -9,7 +12,17 @@ import {
KIND_TYPING_INDICATOR,
} from "@/shared/constants/kinds";
type TypingEntry = { expiresAt: number; firstSeenAt: number };
export type TypingIndicatorEntry = {
pubkey: string;
threadHeadId: string | null;
};
type TypingEntry = {
expiresAt: number;
firstSeenAt: number;
pubkey: string;
threadHeadId: string | null;
};
type TypingState = Record<string, TypingEntry>;
const TYPING_INDICATOR_TTL_MS = 8_000;
@@ -43,6 +56,14 @@ function isTypingCompletionEvent(event: RelayEvent | null | undefined) {
);
}
function getTypingScopeId(event: RelayEvent) {
return getThreadReference(event.tags).parentId ?? null;
}
function getTypingStateKey(pubkey: string, threadHeadId: string | null) {
return `${pubkey}:${threadHeadId ?? "channel"}`;
}
export function useChannelTyping(
channel: Channel | null,
currentPubkey?: string,
@@ -65,21 +86,23 @@ export function useChannelTyping(
}
const typingPubkey = event.pubkey.toLowerCase();
const threadHeadId = getTypingScopeId(event);
const typingKey = getTypingStateKey(typingPubkey, threadHeadId);
if (normalizedCurrentPubkey && typingPubkey === normalizedCurrentPubkey) {
return;
}
const suppressUntil =
typingSuppressUntilByPubkeyRef.current[typingPubkey] ?? 0;
typingSuppressUntilByPubkeyRef.current[typingKey] ?? 0;
if (suppressUntil > Date.now()) {
return;
}
if (suppressUntil > 0) {
delete typingSuppressUntilByPubkeyRef.current[typingPubkey];
delete typingSuppressUntilByPubkeyRef.current[typingKey];
}
const latestMessageCreatedAt =
latestMessageCreatedAtByPubkeyRef.current[typingPubkey] ?? 0;
latestMessageCreatedAtByPubkeyRef.current[typingKey] ?? 0;
if (event.created_at <= latestMessageCreatedAt) {
return;
}
@@ -87,12 +110,14 @@ export function useChannelTyping(
const now = Date.now();
setTypingByPubkey((current) => {
const pruned = pruneTypingState(current, now);
const existing = pruned[typingPubkey];
const existing = pruned[typingKey];
return {
...pruned,
[typingPubkey]: {
[typingKey]: {
expiresAt: now + TYPING_INDICATOR_TTL_MS,
firstSeenAt: existing?.firstSeenAt ?? now,
pubkey: typingPubkey,
threadHeadId,
},
};
});
@@ -119,20 +144,22 @@ export function useChannelTyping(
}
const authorPubkey = latestMessageEvent.pubkey.toLowerCase();
latestMessageCreatedAtByPubkeyRef.current[authorPubkey] = Math.max(
latestMessageCreatedAtByPubkeyRef.current[authorPubkey] ?? 0,
const threadHeadId = getTypingScopeId(latestMessageEvent);
const typingKey = getTypingStateKey(authorPubkey, threadHeadId);
latestMessageCreatedAtByPubkeyRef.current[typingKey] = Math.max(
latestMessageCreatedAtByPubkeyRef.current[typingKey] ?? 0,
latestMessageEvent.created_at,
);
typingSuppressUntilByPubkeyRef.current[authorPubkey] =
typingSuppressUntilByPubkeyRef.current[typingKey] =
Date.now() + TYPING_POST_MESSAGE_SUPPRESS_MS;
setTypingByPubkey((current) => {
const next = pruneTypingState(current);
if (!(authorPubkey in next)) {
if (!(typingKey in next)) {
return next;
}
const updated = { ...next };
delete updated[authorPubkey];
delete updated[typingKey];
return updated;
});
}, [channelId, latestMessageEvent]);
@@ -193,9 +220,9 @@ export function useChannelTyping(
return useMemo(
() =>
Object.entries(typingByPubkey)
.sort((left, right) => left[1].firstSeenAt - right[1].firstSeenAt)
.map(([pubkey]) => pubkey),
Object.values(typingByPubkey)
.sort((left, right) => left.firstSeenAt - right.firstSeenAt)
.map(({ pubkey, threadHeadId }) => ({ pubkey, threadHeadId })),
[typingByPubkey],
);
}
@@ -8,11 +8,19 @@ const TYPING_SEND_INTERVAL_MS = 3_000;
* Publishes kind:20002 typing indicators for the current user,
* throttled to at most once every 3 seconds per channel.
*/
export function useTypingBroadcast(channelId: string | null | undefined) {
export function useTypingBroadcast(
channelId: string | null | undefined,
parentEventId?: string | null,
rootEventId?: string | null,
) {
const lastSentRef = useRef(0);
const lastChannelRef = useRef(channelId);
const channelIdRef = useRef(channelId);
const parentEventIdRef = useRef(parentEventId);
const rootEventIdRef = useRef(rootEventId);
channelIdRef.current = channelId;
parentEventIdRef.current = parentEventId;
rootEventIdRef.current = rootEventId;
const notifyTyping = useCallback(() => {
const id = channelIdRef.current;
@@ -32,7 +40,13 @@ export function useTypingBroadcast(channelId: string | null | undefined) {
}
lastSentRef.current = now;
relayClient.sendTypingIndicator(id).catch(() => {});
relayClient
.sendTypingIndicator(
id,
parentEventIdRef.current ?? null,
rootEventIdRef.current ?? null,
)
.catch(() => {});
}, []);
return notifyTyping;
+11 -2
View File
@@ -19,6 +19,7 @@ import {
type RelaySubscription,
type RelaySubscriptionFilter,
} from "@/shared/api/relayClientShared";
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
const RECONNECT_BASE_DELAY_MS = 1_000;
const RECONNECT_MAX_DELAY_MS = 30_000;
@@ -135,7 +136,11 @@ export class RelayClient {
);
}
async sendTypingIndicator(channelId: string) {
async sendTypingIndicator(
channelId: string,
parentEventId?: string | null,
rootEventId?: string | null,
) {
// Bail when disconnected — not worth triggering a reconnect for ephemeral typing events.
if (this.wsId === null) {
return;
@@ -143,7 +148,11 @@ export class RelayClient {
const event = await signRelayEvent({
kind: KIND_TYPING_INDICATOR,
content: "",
tags: [["h", channelId]],
tags: buildThreadReferenceTags(
channelId,
parentEventId ?? null,
rootEventId ?? null,
),
});
// Fire-and-forget: no need to wait for relay acknowledgement.
+82 -25
View File
@@ -264,8 +264,11 @@ test("shows your avatar on your own message when profile avatar is set", async (
);
});
test("supports nested replies with visible indentation", async ({ page }) => {
test("opens a branch-only thread panel from the reply action", async ({
page,
}) => {
const firstReply = `First threaded reply ${Date.now()}`;
const siblingReply = `Sibling threaded reply ${Date.now()}`;
const nestedReply = `Nested threaded reply ${Date.now()}`;
await page.goto("/");
@@ -275,33 +278,87 @@ test("supports nested replies with visible indentation", async ({ page }) => {
"Welcome to #general",
);
const rows = page.getByTestId("message-row");
const replyButtons = page.locator('[data-testid^="reply-message-"]');
const timeline = page.getByTestId("message-timeline");
const timelineRows = timeline.getByTestId("message-row");
const threadPanel = page.getByTestId("message-thread-panel");
const threadComposer = threadPanel.locator('[data-testid="message-input"]');
const threadSendButton = threadPanel.getByTestId("send-message");
const threadReplies = threadPanel.getByTestId("message-thread-replies");
const rootMessage = timelineRows.first();
await rows.first().hover();
await replyButtons.first().click();
await expect(page.getByTestId("reply-target")).toContainText("Replying to");
await page.getByTestId("message-input").fill(firstReply);
await page.getByTestId("send-message").click();
await expect(rows.last()).toContainText(firstReply);
await expect(rows.last()).not.toContainText("Welcome to #general");
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
);
await rows.last().hover();
await replyButtons.last().click();
await expect(page.getByTestId("reply-target")).toContainText(firstReply);
await page.getByTestId("message-input").fill(nestedReply);
await page.getByTestId("send-message").click();
await expect(rows.last()).toContainText(nestedReply);
await expect(rows.last()).not.toContainText(firstReply);
await threadComposer.fill(firstReply);
await threadSendButton.click();
await expect(threadReplies).toContainText(firstReply);
const rootBox = await rows.nth(0).boundingBox();
const firstReplyBox = await rows.nth(1).boundingBox();
const nestedReplyBox = await rows.nth(2).boundingBox();
await threadComposer.fill(siblingReply);
await threadSendButton.click();
await expect(threadReplies).toContainText(siblingReply);
if (!rootBox || !firstReplyBox || !nestedReplyBox) {
throw new Error("Expected reply rows to be rendered.");
}
await expect(
timeline.getByTestId("message-row").filter({ hasText: firstReply }),
).toHaveCount(0);
await expect(
timeline.getByTestId("message-row").filter({ hasText: siblingReply }),
).toHaveCount(0);
expect(firstReplyBox.x).toBeGreaterThan(rootBox.x + 8);
expect(nestedReplyBox.x).toBeGreaterThan(firstReplyBox.x + 8);
const rootSummaryRow = timeline.getByTestId("message-thread-summary").first();
await expect(rootSummaryRow).toContainText("2 replies");
await threadPanel.getByTestId("message-thread-close").click();
await expect(threadPanel).toBeHidden();
await rootSummaryRow.click();
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
);
const firstReplyRow = threadReplies
.getByTestId("message-row")
.filter({ hasText: firstReply })
.first();
await firstReplyRow.hover();
await firstReplyRow.getByRole("button", { name: "Reply" }).click();
await expect(threadPanel.getByTestId("message-thread-back")).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
firstReply,
);
await expect(threadPanel.getByTestId("message-thread-head")).not.toContainText(
"Welcome to #general",
);
await expect(threadReplies).not.toContainText(siblingReply);
await threadComposer.fill(nestedReply);
await threadSendButton.click();
await expect(threadReplies).toContainText(nestedReply);
await expect(threadReplies).not.toContainText(siblingReply);
await expect(
timeline.getByTestId("message-row").filter({ hasText: nestedReply }),
).toHaveCount(0);
await threadPanel.getByTestId("message-thread-back").click();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
);
await expect(
threadReplies.getByTestId("message-row").filter({ hasText: nestedReply }),
).toHaveCount(0);
const nestedSummaryRow = threadReplies.getByTestId("message-thread-summary");
await expect(nestedSummaryRow).toContainText("1 reply");
await nestedSummaryRow.click();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
firstReply,
);
await expect(threadReplies).toContainText(nestedReply);
});