ChannelMessageList.tsx14.0 KBView on GitHub
import { Fragment, useState, type ReactNode } from 'react';
import { Check, SmilePlus } from 'lucide-react';

import { BimiAvatar } from '@/components/ui/bimi-avatar';
import { DayDivider, dayKey } from '@/components/ui/day-divider';
import { EmojiPicker } from '@/components/ui/emoji-picker';
import { ReactionChip } from '@/modules/conversations/components/timeline/ReactionChip';
import { SlackAttachments } from '@/modules/conversations/components/timeline/SlackAttachments';
import type { EventReaction, SlackAttachmentRef } from '@/modules/crm/types';
import { cn, formatTime } from '@/lib/utils';

/** Slack-style quick reactions, matching the conversation timeline's hover toolbar. */
const QUICK_REACTIONS = ['✅', '👀', '🙌'];

/** Rollup for a message that is the root of a Slack thread. */
export interface ThreadSummary {
  /** The root message's Slack `ts` — the key the thread panel reads by. */
  threadTs: string;
  replyCount: number;
  lastReplyAt: string | null;
  /** Distinct Slack user ids that replied, for the facepile. */
  replierUserIds: string[];
}

/** Normalized row rendered in a channel body, source-agnostic. */
export interface ThreadMessage {
  id: string;
  senderName: string;
  avatarUrl?: string;
  avatarEmail?: string;
  when?: string;
  outbound: boolean;
  body: ReactNode;
  /** Present only on a Slack root that has replies — renders the "N replies" button. */
  thread?: ThreadSummary;
  /**
   * Aggregated emoji reactions (design: slack-parity.md Phase 6). Ingest and the write path
   * both predate this; only the unibox's read and this row never showed them.
   */
  reactions?: EventReaction[];
  /**
   * Uploaded files on the message — a screenshot, a PDF — each already presigned by the read
   * (design: slack-images.md). Same story as `reactions` above: storage, presigning and the
   * `SlackAttachments` renderer all existed and were wired into the conversation timeline, so
   * a customer's screenshot appeared on the deal and vanished in the unibox.
   *
   * Slack-only today. LinkedIn and WhatsApp media travel their own way and are not mapped here.
   */
  attachments?: SlackAttachmentRef[];
}

/** Slack-style grouping window: consecutive same-sender messages within 3 min
 *  collapse their avatar/name/time header. */
const CONTINUATION_WINDOW_MS = 3 * 60 * 1000;
function withinContinuationWindow(prev?: string, cur?: string): boolean {
  if (!prev || !cur) return false;
  const dt = new Date(cur).getTime() - new Date(prev).getTime();
  return dt >= 0 && dt <= CONTINUATION_WINDOW_MS;
}

interface ChannelMessageListProps {
  messages: ThreadMessage[];
  /** Opens the thread panel. Absent inside the panel itself — threads don't nest in Slack. */
  onOpenThread?: (threadTs: string) => void;
  /** Resolves a Slack user id to a face for the replies facepile. */
  resolveReplier?: (userId: string) => { displayName: string; avatarUrl?: string } | null;
  /** Marks the newest own message as sent. Off inside the thread panel. */
  showReadMarker?: boolean;
  /**
   * Toggle an EXISTING reaction (a chip click), handed the whole aggregate: Slack writes through
   * on its `key` + `emojiUnicode`, while LinkedIn and WhatsApp need `reactedByMe` to tell a
   * withdrawal from a replacement. Absent for channels that cannot be reacted to; the chips then
   * render read-only.
   */
  onToggleReaction?: (messageId: string, reaction: EventReaction) => void;
  /** React with a unicode glyph from the quick row or the picker. */
  onReact?: (messageId: string, emoji: string) => void;
}

/**
 * The message column shared by the full channel view and the thread panel: avatar +
 * sender + time, grouped Slack-style (a run from the same sender within a few minutes
 * collapses its header), with an optional "N replies" button under a thread root.
 *
 * Both surfaces render the same rows because they show the same thing — the only
 * difference is which messages they are handed, which is a read concern, not a
 * presentation one.
 */
export function ChannelMessageList({
  messages,
  onOpenThread,
  resolveReplier,
  showReadMarker = true,
  onToggleReaction,
  onReact,
}: ChannelMessageListProps) {
  return (
    <div className="flex flex-col">
      {messages.map((msg, i) => {
        const prev = messages[i - 1];

        // The day pill, matching the CRM conversation timeline exactly (same component). The
        // unibox had no day boundaries at all, so a channel read as one unbroken run of messages
        // however many days apart they were.
        const when = msg.when ? new Date(msg.when) : null;
        const prevWhen = prev?.when ? new Date(prev.when) : null;
        const showDivider =
          !!when && (i === 0 || !prevWhen || dayKey(when) !== dayKey(prevWhen));

        const sameSender =
          !!prev && prev.outbound === msg.outbound && prev.senderName === msg.senderName;
        const within = !!prev && withinContinuationWindow(prev.when, msg.when);
        // A divider always breaks the grouping chain — two messages three minutes apart across
        // midnight would otherwise render as a headerless continuation directly beneath a pill
        // announcing a new day, with no sender or time to anchor it.
        const isContinuation = sameSender && within && !showDivider;
        // Read/sent marker: ONLY the latest message, and only if it's ours. Force the
        // time header to show even on a continuation so the marker has an anchor right
        // of the timestamp.
        const markRead = showReadMarker && i === messages.length - 1 && msg.outbound;
        const showHeader = !isContinuation || markRead;
        return (
          <Fragment key=[redacted]
            {showDivider && when && <DayDivider date={when} />}
          <div
            className={cn(
              // `relative` anchors the hover toolbar, which floats over the row's top-right
              // corner instead of sitting in the layout — see MessageHoverToolbar.
              'group/msg hover:bg-muted/40 relative flex gap-3 rounded-md px-1 transition-colors',
              isContinuation ? 'mt-0.5' : 'mt-4',
            )}
          >
            {onReact && <MessageHoverToolbar onReact={(emoji) => onReact(msg.id, emoji)} />}
            <div className="w-8 shrink-0">
              {!isContinuation && (
                <BimiAvatar
                  email={msg.avatarEmail || ''}
                  name={msg.senderName}
                  avatarUrl={msg.avatarUrl}
                  className="h-8 w-8 rounded-full"
                />
              )}
            </div>
            <div className="min-w-0 flex-1">
              {showHeader && (
                <div className="flex items-baseline gap-1.5">
                  {!isContinuation && (
                    <span className="text-foreground text-sm font-semibold">{msg.senderName}</span>
                  )}
                  {msg.when && (
                    <span className="text-muted-foreground text-xs">{formatTime(msg.when)}</span>
                  )}
                  {markRead && (
                    <Check
                      className="text-muted-foreground/70 h-3 w-3 shrink-0"
                      aria-label="Sent"
                    />
                  )}
                </div>
              )}
              <div className="text-foreground mt-0.5 whitespace-pre-wrap text-sm leading-relaxed">
                {msg.body}
              </div>
              {/* Below the words, above the reactions — where Slack puts it, and where the
                  conversation timeline already put it. Renders nothing when there are none. */}
              <SlackAttachments attachments={msg.attachments} />
              <MessageReactionRow
                reactions={msg.reactions ?? []}
                onToggle={
                  onToggleReaction ? (reaction) => onToggleReaction(msg.id, reaction) : undefined
                }
                onReact={onReact ? (emoji) => onReact(msg.id, emoji) : undefined}
              />
              {msg.thread && onOpenThread && (
                <RepliesButton
                  thread={msg.thread}
                  resolveReplier={resolveReplier}
                  onClick={() => onOpenThread(msg.thread!.threadTs)}
                />
              )}
            </div>
          </div>
          </Fragment>
        );
      })}
    </div>
  );
}

/**
 * The affordance itself: a facepile, the reply count, and when the thread last moved.
 * Exported so the conversation timeline, whose rows are `ConversationEvent`s rather
 * than `ThreadMessage`s, renders the identical control rather than a lookalike.
 */
export function RepliesButton({
  thread,
  resolveReplier,
  onClick,
}: {
  thread: ThreadSummary;
  resolveReplier?: (userId: string) => { displayName: string; avatarUrl?: string } | null;
  onClick: () => void;
}) {
  const faces = thread.replierUserIds.slice(0, 3);
  return (
    <button
      type="button"
      onClick={onClick}
      className="hover:bg-sunken -ml-1 mt-1 flex cursor-pointer items-center gap-2 rounded-lg px-1.5 py-1 transition-colors"
    >
      {faces.length > 0 && (
        <div className="flex -space-x-1.5">
          {faces.map((userId) => {
            const principal = resolveReplier?.(userId);
            return (
              <BimiAvatar
                key=[redacted]
                email=""
                name={principal?.displayName ?? '?'}
                avatarUrl={principal?.avatarUrl}
                className="ring-background h-5 w-5 rounded-full ring-2"
              />
            );
          })}
        </div>
      )}
      {/* Slack sizes the reply count below the message text, not with it. */}
      <span className="text-xs font-medium text-blue-600 dark:text-blue-400">
        {/* `replyCount === 0` is not "a thread with no replies" — that thread would have
            no button at all. It is a message shown in the channel because it POINTS at a
            thread we cannot count: an orphan reply whose root was never ingested, or a
            broadcast. "View thread" is the honest label; the panel fills in the rest. */}
        {thread.replyCount === 0
          ? 'View thread'
          : `${thread.replyCount} ${thread.replyCount === 1 ? 'reply' : 'replies'}`}
      </span>
      {thread.lastReplyAt && (
        <span className="text-muted-foreground text-xs">
          Last reply {formatTime(thread.lastReplyAt)}
        </span>
      )}
    </button>
  );
}

const toolbarButton =
  'text-muted-foreground hover:text-foreground hover:bg-muted inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md transition-colors';

/**
 * Slack's floating message toolbar: a small raised container pinned to the row's top-right,
 * OUT of the layout (absolutely positioned), revealed on hover of the row.
 *
 * Out of the layout is the whole point. The predecessor kept the quick-reaction buttons inline
 * under every message and merely faded them out, so each row reserved a strip of empty space for
 * an affordance nobody had used — a channel of unreacted messages read as double-spaced. Now a
 * message only grows when it actually HAS reactions.
 */
function MessageHoverToolbar({ onReact }: { onReact: (emoji: string) => void }) {
  // The picker portals outside the row, so hovering into it leaves `group/msg` and would fade the
  // toolbar out from under an open popover. Pin it while the picker is open, as Slack does.
  const [pickerOpen, setPickerOpen] = useState(false);

  return (
    <div
      className={cn(
        'bg-popover absolute -top-3 right-2 z-10 flex items-center gap-0.5 rounded-lg border p-0.5 shadow-sm transition-opacity',
        pickerOpen
          ? 'opacity-100'
          : 'pointer-events-none opacity-0 focus-within:pointer-events-auto focus-within:opacity-100 group-hover/msg:pointer-events-auto group-hover/msg:opacity-100',
      )}
      onClick={(e) => e.stopPropagation()}
    >
      {QUICK_REACTIONS.map((emoji) => (
        <button
          key=[redacted]
          type="button"
          onClick={(e) => {
            e.stopPropagation();
            onReact(emoji);
          }}
          className={cn(toolbarButton, 'text-sm leading-none')}
        >
          {emoji}
        </button>
      ))}
      <EmojiPicker align="end" onSelect={onReact} onOpenChange={setPickerOpen}>
        <button
          type="button"
          onClick={(e) => e.stopPropagation()}
          aria-label="Add reaction"
          className={toolbarButton}
        >
          <SmilePlus className="h-3.5 w-3.5" />
        </button>
      </EmojiPicker>
    </div>
  );
}

/**
 * Reaction chips under a message.
 *
 * Renders nothing unless the message actually has reactions — the way to ADD one is the floating
 * toolbar above, so an unreacted message costs no vertical space at all. Once chips exist the row
 * is already committed to the space, so Slack's trailing "add another" pill joins them on hover.
 */
function MessageReactionRow({
  reactions,
  onToggle,
  onReact,
}: {
  reactions: EventReaction[];
  onToggle?: (reaction: EventReaction) => void;
  onReact?: (emoji: string) => void;
}) {
  if (reactions.length === 0) return null;

  return (
    <div className="mt-1 flex flex-wrap items-center gap-1">
      {reactions.map((reaction) => (
        <ReactionChip key=[redacted] reaction={reaction} onToggle={onToggle} />
      ))}

      {onReact && (
        <EmojiPicker align="start" onSelect={onReact}>
          <button
            type="button"
            onClick={(e) => e.stopPropagation()}
            aria-label="Add another reaction"
            title="Add reaction"
            // `data-[state=open]` is Radix's on the trigger: keep the pill up while its own
            // popover is open, which hovering into the portaled picker would otherwise undo.
            className="bg-sunken text-muted-foreground hover:text-foreground hover:bg-raised inline-flex h-6 w-8 cursor-pointer items-center justify-center rounded-full opacity-0 transition-opacity focus-visible:opacity-100 group-hover/msg:opacity-100 data-[state=open]:opacity-100"
          >
            <SmilePlus className="h-3.5 w-3.5" />
          </button>
        </EmojiPicker>
      )}
    </div>
  );
}