'use client' /** * `` — THE single source of truth for a Slack-channel-style * message row. Rendered by BOTH: * - the OpenMSP Slack-community feed (hub `components/slack/chat-interface.tsx`) * - the customer ticket conversation feed (lib `ticket-detail-drawer.tsx`) * * Both surfaces render THIS component, so they are pixel-identical by * construction — avatar size, font sizes, weights, spacing, and line-heights * can never drift apart. The markup is the verbatim Slack `MessageItem` layout * (avatar `w-8 h-8 md:w-10 md:h-10 rounded-lg object-cover` + bold name + * relative time + `whitespace-pre-wrap` body). Colors are ODS theme tokens * ONLY (`text-ods-text-primary` / `text-ods-text-secondary`). The `text-[Npx]` * / `font-body` / `leading-*` utilities are FONT/SIZE, not color — they are * required to match the Slack typography exactly. * * `footer` is the only per-surface variation slot: the Slack feed passes its * "N replies" badge; the ticket feed passes ``. */ import Image from '../../embed-shims/next-image' import { getFirstLastInitials } from '../../utils/format' import { useProxiedImageUrl } from './hooks/use-proxied-image-url' import { useState, type ReactNode } from 'react' export interface ChatMessageRowProps { /** Display name (bold, top-left). */ displayName: string /** Avatar image URL. Proxied via `useProxiedImageUrl`; falls back to * initials in a same-sized `rounded-lg` box when absent. */ avatarUrl?: string | null /** Pre-formatted relative-time label (e.g. "2h ago"). Caller formats it — * Slack passes its server `displayTime`, tickets pass * `formatRelativeTime(createdAt)`. Empty/undefined hides the time. */ timeLabel?: string | null /** Message body. Empty + no footer renders nothing under the header. */ body: string /** Per-surface slot under the body: Slack reply badge / ticket attachments. */ footer?: ReactNode } export function ChatMessageRow({ displayName, avatarUrl, timeLabel, body, footer, }: ChatMessageRowProps) { // Avatars load directly from their (https) host — same as the rest of the app, // so the browser disk-caches them (Google/etc. send `cache-control: max-age`). // `useProxiedImageUrl` only rewrites http/relative URLs; https pass through. const proxiedAvatar = useProxiedImageUrl(avatarUrl ?? '') const resolvedAvatar = proxiedAvatar || avatarUrl || undefined // Fall back to the initials box if the avatar load FAILS (transient CDN 429, // ad-blocker, dead URL) instead of showing a broken image. Keyed on the URL // (not a bool) so a later render with a DIFFERENT avatar re-attempts the load // rather than inheriting a stale failure. const [failedSrc, setFailedSrc] = useState(null) const src = resolvedAvatar && resolvedAvatar !== failedSrc ? resolvedAvatar : undefined const hasBody = body.trim().length > 0 return (
{/* Avatar — verbatim Slack sizing: 32px → 40px, rounded-lg, object-cover. Initials fallback uses the SAME box so layout is identical with or without an image. */} {src ? ( {displayName} setFailedSrc(resolvedAvatar ?? null)} /> ) : (
{getFirstLastInitials(displayName) || '?'}
)} {/* Message content */}
{/* Header — name + relative time. Verbatim Slack typography. */}
{displayName} {timeLabel && ( {timeLabel} )}
{/* Body — verbatim Slack: 12/14px, pre-wrap, break-words. */} {hasBody && (
{body}
)} {footer}
) } /** * Skeleton with 1:1 structural parity to `` — SAME wrapper * (`flex gap-2 md:gap-3`), SAME avatar box (`w-8 h-8 md:w-10 md:h-10 * rounded-lg`), SAME header `mb-1`, and bar heights matching the real * name/time/body line-heights so the loading→loaded swap does not reflow. */ export function ChatMessageRowSkeleton() { // Bars use `bg-ods-border` (NOT `bg-ods-skeleton` — that token resolves to // transparent in this build, leaving the box visually empty). return (
{/* Header row — name + time bars, same mb-1 + gap-2 as the real header. */}
{/* Two body lines — match the 12/14px body line-height. */}
) }