SlackThreadPanel.tsx7.4 KBView on GitHub import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Loader2, X } from 'lucide-react';
import { toast } from 'sonner';
import { SlackMentionTextarea } from '@/modules/conversations/components/timeline/composer/SlackMentionTextarea';
import { isSlackMentionMenuOpen } from '@/modules/conversations/components/timeline/composer/slack-mention';
import { useSlackPrincipalsForMessages } from '@/modules/conversations/components/timeline/use-slack-principals';
import { renderSlackBody } from '@/modules/conversations/components/timeline/SlackStructuredBody';
import { useChannelReactions } from '@/modules/inbox/hooks/use-channel-reactions';
import { ChannelMessageList, type ThreadMessage } from './ChannelMessageList';
import { SendButton } from '@/modules/drafting/components/send-button';
import { useTRPC } from '@/providers/query-provider';
interface SlackThreadPanelProps {
workspaceId: string;
channelId: string;
/** The root message's Slack `ts`. */
threadTs: string;
/** `#channel`, for the panel header. */
channelLabel: string;
onClose: () => void;
/** Called after a reply lands, so the channel behind can refresh its reply count. */
onReplySent: () => void;
}
/**
* A Slack thread, opened from the "N replies" button on a root message.
*
* The read is by (workspace, channel, threadTs) rather than off the channel list the
* caller already holds, because the channel list is exactly what does NOT have the whole
* thread: it carries roots, and a root whose replies predate the sync window has none of
* them stored. `inbox.slackThreadMessages` live-fills that case from Slack.
*
* Replies post with `threadTs`, so they land in the thread rather than the channel.
*/
export function SlackThreadPanel({
workspaceId,
channelId,
threadTs,
channelLabel,
onClose,
onReplySent,
}: SlackThreadPanelProps) {
const trpc = useTRPC();
const [draft, setDraft] = useState('');
const messagesQ = useQuery(
trpc.inbox.slackThreadMessages.queryOptions({ workspaceId, channelId, threadTs }),
);
const raw = useMemo(
() => (messagesQ.data ?? []).map((m) => ({ ...m, slackWorkspaceId: workspaceId })),
[messagesQ.data, workspaceId],
);
const resolveSlackPrincipal = useSlackPrincipalsForMessages(raw);
const messages: ThreadMessage[] = useMemo(() => {
const resolveUserId = (userId: string) => {
const p = resolveSlackPrincipal(workspaceId, userId);
return p ? { displayName: p.displayName } : null;
};
return raw.map((m) => {
const principal = m.slackUserId
? resolveSlackPrincipal(workspaceId, m.slackUserId)
: undefined;
return {
id: m.eventId,
senderName: principal?.displayName ?? m.userName ?? 'Slack',
avatarUrl: principal?.avatarUrl,
avatarEmail: m.userEmail ?? undefined,
when: m.occurredAt,
outbound: m.outbound,
// Same renderer as the channel stream — a message must not render one way in the
// channel and another in its thread (slack-parity.md Phase 2).
body: renderSlackBody(m, resolveUserId),
reactions: m.reactions,
attachments: m.attachmentRefs,
};
});
}, [raw, resolveSlackPrincipal, workspaceId]);
// Same write path and the same two caches as the channel stream behind it: reacting to a root
// from inside its thread must move the chip on the row too, not just here.
const { toggle: toggleReaction, react } = useChannelReactions({ workspaceId, channelId });
// Keep the newest reply in view, and re-anchor when switching between threads.
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messagesQ.isLoading, messages.length, threadTs]);
const composerRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
composerRef.current?.focus();
}, [threadTs]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
// The composer's `@` menu owns Escape while it's up — see slack-mention.ts.
if (isSlackMentionMenuOpen()) return;
e.preventDefault();
e.stopImmediatePropagation();
onClose();
};
document.addEventListener('keydown', onKeyDown, true);
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [onClose]);
const sendSlack = useMutation(trpc.integrations.slack.sendMessage.mutationOptions());
const handleSend = async () => {
const text = draft.trim();
if (!text) return;
try {
await sendSlack.mutateAsync({ workspaceId, channelId, message: text, threadTs });
setDraft('');
void messagesQ.refetch();
onReplySent();
} catch {
toast.error('Failed to send');
}
};
return (
<div className="border-border bg-background flex h-full w-full flex-col border-l">
<div className="bg-background sticky top-0 z-20 flex items-center justify-between px-4 py-3">
<div className="min-w-0">
<h2 className="text-base font-semibold">Thread</h2>
<p className="text-muted-foreground truncate text-xs">{channelLabel}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close thread"
className="text-muted-foreground hover:bg-muted hover:text-foreground flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">
{messagesQ.isLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : messages.length === 0 ? (
<div className="text-muted-foreground py-10 text-center text-sm">
This thread could not be loaded.
</div>
) : (
// No `onOpenThread`: Slack threads do not nest, so a reply never carries one.
<ChannelMessageList
messages={messages}
showReadMarker={false}
onToggleReaction={(messageId, reaction) =>
toggleReaction(messageId, reaction.key, reaction.emojiUnicode)
}
onReact={react}
/>
)}
</div>
<div className="shrink-0 px-3 pb-4">
<div className="border-border bg-raised flex flex-col gap-2 rounded-2xl border px-3 py-2 shadow-lg">
<SlackMentionTextarea
ref={composerRef}
value={draft}
onValueChange={setDraft}
workspaceId={workspaceId}
channelId={channelId}
onSubmit={() => void handleSend()}
rows={1}
autoGrow
placeholder="Reply in thread…"
/* One row at rest, growing to half the viewport before it scrolls — same cap as
the channel composer, so a long reply reads the same in either place. */
className="max-h-[50vh] min-h-[2.25rem] w-full resize-none bg-transparent px-1 pt-1 text-sm outline-none"
/>
<div className="flex items-center justify-end">
<SendButton
onSend={() => void handleSend()}
disabled={!draft.trim() || sendSlack.isPending}
/>
</div>
</div>
</div>
</div>
);
}