mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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<()>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user