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>
  );
}