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