import React, { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'; import V2Lightbox, { type V2LightboxImage } from './V2Lightbox'; import { Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import ReactMarkdown, { type ExtraProps } from 'react-markdown'; import V2Avatar from './V2Avatar'; import V2GithubPrCard, { parseGithubPrUrls } from './V2GithubPrCard'; import V2ApprovalCard from './V2ApprovalCard'; import V2BYOHandoffCard from './V2BYOHandoffCard'; import V2GigCard from './V2GigCard'; import V2RunTraceLink from './V2RunTraceLink'; import V2DecisionCard, { type V2DecisionCardData, type V2DecisionRuling } from './V2DecisionCard'; import V2MessageActions, { type V2RenderedReaction } from './V2MessageActions'; import { V2Message } from '../hooks/useV2PodDetail'; import { formatRelativeTime } from '../utils/grouping'; import { useAuth } from '../../context/AuthContext'; import { useV2Api } from '../hooks/useV2Api'; import { getSignedAttachmentUrl } from '../../utils/signedAttachmentUrl'; import { messageImageUrl } from '../utils/messageImageUrl'; import CardRenderBoundary from '../cards/CardRenderBoundary'; const V2UICard = lazy(() => import('../cards/V2UICard')); // Minimal v2-scoped markdown renderer. Plain HTML elements (no MUI), so // styling stays in v2.css under `.v2-msg__content`. The body comes pre-stripped // of [[file:...]] / [[reactions:...]] tokens above, so this is purely for // agent-authored prose: bold/italic, lists, inline code, fenced code, links. // Match @username — letters/digits start, then letters/digits/underscore/hyphen. // Used to wrap inline mentions in a styled pill. Capturing group keeps the // match in `split()` output so we can render text + pill segments in order. const MENTION_RE = /(@[a-zA-Z0-9][a-zA-Z0-9_-]*)/g; // Walk a React children tree and replace bare `@name` text segments with a // styled `` pill. Recurses into arrays + cloned elements so mentions // inside ``, ``, list items, etc. still render correctly. Code // blocks (``, `
`) are skipped — code is verbatim, mentions in code
// are intentional and shouldn't be transformed.
const renderWithMentions = (node: React.ReactNode): React.ReactNode => {
  if (typeof node === 'string') {
    if (!node.includes('@')) return node;
    const parts = node.split(MENTION_RE);
    return parts.map((part, i) => {
      if (i % 2 === 1) {
        return (
          {part}
        );
      }
      return part;
    });
  }
  if (Array.isArray(node)) {
    return node.map((child, i) => (
      {renderWithMentions(child)}
    ));
  }
  if (React.isValidElement(node)) {
    const type = node.type;
    if (type === 'code' || type === 'pre') return node;
    const props = node.props as { children?: React.ReactNode };
    const transformed = renderWithMentions(props.children);
    return React.cloneElement(node, undefined, transformed);
  }
  return node;
};

export const MessageMarkdownImage = ({ src, alt }: React.ImgHTMLAttributes) => {
  const safeSrc = messageImageUrl(src);
  if (safeSrc) return ;
  // Keep the destination usable without initiating a browser image request.
  if (src && /^(?:https?:\/\/|\/)/i.test(src)) {
    return {alt || src};
  }
  return {alt || src};
};

export const messageMarkdownComponents = {
  img: MessageMarkdownImage,
  // react-markdown passes its mdast `node` to every component; spreading it
  // onto the DOM element renders node="[object Object]".
  a: ({ children, node: _node, ...props }: React.AnchorHTMLAttributes & ExtraProps) => (
    {renderWithMentions(children)}
  ),
  p: ({ children }: React.HTMLAttributes) => (
    

{renderWithMentions(children)}

), li: ({ children }: React.HTMLAttributes) => (
  • {renderWithMentions(children)}
  • ), // Inline code vs fenced code share ``; only fenced code is wrapped in // `
    `. Both fall through to v2.css selectors `.v2-msg__content code`
      // and `.v2-msg__content pre`. Mentions inside code are NOT transformed.
      // Fenced code past six lines collapses (direction C).
      pre: ({ node: _node, ...props }: React.HTMLAttributes & ExtraProps) => ,
    };
    
    interface V2MessageRowProps {
      message: V2Message;
      // A settled decision replaces its source request/reply with the durable
      // ruling row. Keep the source id as a secondary landing anchor so Activity
      // links to the request still land on that one visible answer.
      sourceMessageId?: string | number;
      // DecisionRequest data is deliberately joined by the thread container,
      // not parsed from the agent's prose. The message is the durable timeline
      // anchor; the queue owns the choices and resolution state.
      decision?: V2DecisionCardData;
      onDecisionRuled?: (decisionId: string, ruling: V2DecisionRuling) => void;
      // A decision's durable ruling is an ordinary human reply. This marker only
      // supplies the small transcript label; it never turns arbitrary prose into
      // a decision result.
      isDecisionRuling?: boolean;
      isLead?: boolean;
      // Map of agent-user username → per-installation displayName, so messages
      // authored by an installed agent render as "Engineer (Nova)" instead of the
      // raw User row username "openclaw-nova". Frontend-only display layer; the
      // underlying User row is unchanged.
      agentDisplayNames?: Map;
      // Lowercased author key → runtime short name (`codex`, `claude`, …), the
      // mono tag after the time on agent rows (direction C). Absent = no tag.
      agentTags?: Map;
      // Lowercased set of strings we treat as agent author bylines (both raw
      // usernames and displayNames). The backend may serve either shape on
      // `message.user.username`, so we gate click behavior on a known set.
      agentAuthorKeys?: Set;
      // Clicking the author avatar / name opens the inspector to that member's
      // detail sub-page. Passed in by V2Thread; only fires for agent authors.
      onAuthorClick?: (author: string) => void;
      /**
       * The root this bubble is being rendered UNDER, when it sits inside an
       * expanded thread rail. Undefined everywhere else, including for the same
       * message rendered flat in the channel.
       *
       * Only used to suppress a quote that would restate the context the rail
       * already provides — see the predicate at the quote block. Deliberately not
       * a boolean like `inThread`: the suppression has to compare the quote's
       * target to THIS root, and a boolean cannot express "the right root".
       */
      insideThreadRoot?: string;
      // Clicking a file pill routes to the inspector artifact preview by
      // ObjectStore filename (or originalName for static demo tokens).
      onOpenFile?: (fileName: string) => void;
      // Sets this message as the composer's reply target (reply threading —
      // backend replyToMessageId; agents already use it, this is the human side).
      onReply?: (message: V2Message) => void;
      // Starts or joins the message's thread without creating a reply edge. The
      // parent resolves an already-threaded message to its existing root before
      // aiming the composer, so this control never asks the server for nesting.
      onThread?: (message: V2Message) => void;
      // Notifies the transcript when a quote points at a row hidden behind a
      // thread fold. Repeating that quote after collapsing must be a fresh
      // navigation gesture even though the hash string is unchanged.
      onQuoteNavigate?: (messageId: string | number) => void;
      // Consecutive-author grouping (craft audit finding 7): when the previous
      // message is the same author within the grouping window, the header row
      // (avatar / name / time) is suppressed and the row tightens. The avatar
      // column is kept as an empty grid cell so text stays aligned. Reply moves
      // to a hover affordance since the head row is gone.
      grouped?: boolean;
    }
    
    interface ParsedFile {
      name: string;
      ext: string;
      size?: string;
      // Upload kind from the directive (image / document / data / office / archive);
      // images render as thumbnails, everything else as a chip.
      kind?: string;
      // Set when the pill came from an [[upload:...]] directive backed by a real
      // ObjectStore record. Click → mint signed URL → open. Plain [[file:...]]
      // pills (used by demo fixtures) leave this undefined and render as static.
      fileName?: string;
    }
    
    interface ParsedReaction {
      emoji: string;
      count: number;
    }
    
    
    // Match a markdown-ish file token: [[file:Name.ext]] or [[file:Name.ext|2.4 KB]].
    // This is a v2-only convention so we can preview file pills until the backend
    // Message model gains a real `attachments[]` field.
    const FILE_TOKEN_RE = /\[\[file:([^\]|]+)(?:\|([^\]]+))?\]\]/g;
    // Match a real upload directive emitted by the composer / agent SDK after a
    // successful POST /api/uploads:
    //   [[upload:|||]]
    // fileName is the ObjectStore key (e.g. `1714678910-712345678.pdf`); the pill
    // click handler exchanges it for a short-TTL signed URL via getSignedAttachmentUrl.
    const UPLOAD_TOKEN_RE = /\[\[upload:([^\]|]+)\|([^\]|]+)\|([^\]|]+)(?:\|([^\]]+))?\]\]/g;
    const formatBytes = (raw: string | number): string => {
      const n = typeof raw === 'number' ? raw : parseInt(raw, 10);
      if (!Number.isFinite(n) || n <= 0) return '';
      const units = ['B', 'KB', 'MB', 'GB'];
      let v = n;
      let i = 0;
      while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
      return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
    };
    // Match a v2 reactions token: [[reactions:👍 3, 💬 2, 🔥 1]]. Pure render —
    // no write path tonight; the YC demo seed populates this from fixtures.
    // Real reactions backend ships post-YC.
    const REACTION_TOKEN_RE = /\[\[reactions:([^\]]+)\]\]/g;
    const MARKDOWN_IMAGE_RE = /^!\[[^\]]*\]\(([^)]+)\)$/;
    // Absolute image URLs and the instance-relative upload reference the legacy
    // image send posted as bare content (`/api/uploads/.png`).
    // Bare http(s) URLs become markdown links. The match runs to the end of the URL,
    // so `github.com/x` links whole. Trailing punctuation stays outside the link, and
    // so does a closing paren the URL does not open. Brackets are escaped in the link
    // text and percent-encoded in the destination, so a `]` or `(` in a URL cannot
    // close the markdown link early and leave the URL as plain text.
    // A PR URL that gets a card is replaced in the sentence by a short owner/repo#N
    // link, so the sentence keeps its words and punctuation ("Review: o/r#12.") and
    // the card below carries the detail. Removing the URL instead left stray
    // separators behind ("Review: ." and "see  for it").
    export const prUrlsToRefLinks = (text: string): string => text.replace(
      // A URL already inside link syntax (`[label](url)`, ``, `[url]`) is left alone.
      /(? (
        `[${owner}/${repo}#${num}](https://github.com/${owner}/${repo}/pull/${num})`
      ),
    );
    
    const LINK_DEST_ENCODE: Record = { '(': '%28', ')': '%29', '[': '%5B', ']': '%5D', '\\': '%5C' };
    export const linkifyBareUrls = (text: string): string => text.replace(
      /(?"]+/g,
      (raw) => {
        let url = raw;
        for (;;) {
          const before = url;
          url = url.replace(/[.,!?;:]+$/, '');
          if (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
            url = url.slice(0, -1);
          }
          if (url === before) break;
        }
        const label = url.replace(/[\\[\]]/g, '\\$&');
        const dest = url.replace(/[()[\]\\]/g, (c) => LINK_DEST_ENCODE[c]);
        return `[${label}](${dest})${raw.slice(url.length)}`;
      },
    );
    
    const IMAGE_URL_RE = /^(?:https?:\/\/.+|\/api\/uploads\/[^\s]+)\.(png|jpe?g|gif|webp|svg)(\?.*)?$/i;
    
    // Code spans and fences are quoted grammar, not attachments: mask them while
    // the directives are pulled out, then put them back verbatim.
    const CODE_RE = /```[\s\S]*?```|`[^`\n]*`/g;
    const maskCode = (content: string): { masked: string; restore: (s: string) => string } => {
      const spans: string[] = [];
      const masked = content.replace(CODE_RE, (span) => { spans.push(span); return `\u0000${spans.length - 1}\u0000`; });
      return { masked, restore: (s) => s.replace(/\u0000(\d+)\u0000/g, (_m, i) => spans[Number(i)]) };
    };
    
    const parseFiles = (content: string): { stripped: string; files: ParsedFile[] } => {
      const files: ParsedFile[] = [];
      const { masked, restore } = maskCode(content);
      // Real uploads first — they carry a fileName and resolve to a signed URL on
      // click. Then static file tokens (demo fixtures, no backend reference).
      let working = masked.replace(UPLOAD_TOKEN_RE, (_match, rawFileName, rawOriginal, rawSize, rawKind) => {
        const fileName = String(rawFileName).trim();
        const name = String(rawOriginal).trim();
        const dot = name.lastIndexOf('.');
        const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : 'file';
        const kind = rawKind ? String(rawKind).trim() : undefined;
        files.push({ fileName, name, ext, kind, size: formatBytes(String(rawSize).trim()) || undefined });
        return '';
      });
      working = working.replace(FILE_TOKEN_RE, (_match, rawName, rawSize) => {
        const name = String(rawName).trim();
        const dot = name.lastIndexOf('.');
        const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : 'file';
        files.push({ name, ext, size: rawSize ? String(rawSize).trim() : undefined });
        return '';
      });
      return { stripped: restore(working).trim(), files };
    };
    
    const parseReactions = (content: string): { stripped: string; reactions: ParsedReaction[] } => {
      const reactions: ParsedReaction[] = [];
      const stripped = content.replace(REACTION_TOKEN_RE, (_match, body) => {
        String(body).split(',').forEach((entry: string) => {
          const trimmed = entry.trim();
          if (!trimmed) return;
          // Accept "👍 3" or "👍3" — count is the trailing digits, emoji is the rest.
          const m = trimmed.match(/^(.*?)\s*(\d+)$/u);
          if (!m) return;
          const emoji = m[1].trim();
          const count = parseInt(m[2], 10);
          if (emoji && Number.isFinite(count) && count > 0) {
            reactions.push({ emoji, count });
          }
        });
        return '';
      }).trim();
      return { stripped, reactions };
    };
    
    const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']);
    
    // A browser cannot send the bearer header on ``, so each thumbnail
    // asks for the short-TTL signed URL (ADR-002 1b, cached per file) and falls
    // back to the plain reference while the public-read GET still exists.
    const SignedImage: React.FC<{ src: string; alt: string }> = ({ src, alt }) => {
      const [resolved, setResolved] = useState(src);
      useEffect(() => {
        let active = true;
        if (!/\/api\/uploads\//.test(src)) return undefined;
        void getSignedAttachmentUrl(src).then((signed) => {
          const safeSigned = messageImageUrl(signed);
          if (active && safeSigned) setResolved(safeSigned);
        });
        return () => { active = false; };
      }, [src]);
      return {alt};
    };
    const isImageFile = (file: ParsedFile): boolean => file.kind === 'image' || IMAGE_EXTS.has(file.ext);
    
    // Direction C file chip: type badge as text on tint (never a colour), name, size.
    // Click opens through the inspector when a handler is in scope, else mints the
    // signed URL and opens in a tab.
    const FileChip: React.FC<{
      file: ParsedFile;
      onOpenFile?: (fileName: string) => void;
    }> = ({ file, onOpenFile }) => {
      const { t } = useTranslation();
      const inner = (
        <>
          {file.ext.slice(0, 4)}
          {file.name}
          {file.size && {file.size}}
        
      );
      const handleClick = async (event: React.MouseEvent) => {
        event.preventDefault();
        if (onOpenFile) {
          onOpenFile(file.fileName || file.name);
          return;
        }
        if (!file.fileName) return;
        const signed = await getSignedAttachmentUrl(`/api/uploads/${file.fileName}`);
        if (signed) window.open(signed, '_blank', 'noopener,noreferrer');
      };
      if (!file.fileName && !onOpenFile) return {inner};
      return (
        
      );
    };
    
    // Code past six lines collapses in place; Show more expands it, Show less folds it.
    const COLLAPSE_AFTER_LINES = 6;
    export const CollapsiblePre: React.FC> = ({ children, ...props }) => {
      const { t } = useTranslation();
      const [open, setOpen] = useState(false);
      const text = React.Children.toArray(children).map((child) => {
        if (typeof child === 'string') return child;
        if (React.isValidElement(child)) {
          const inner = (child.props as { children?: React.ReactNode }).children;
          return React.Children.toArray(inner).filter((c) => typeof c === 'string').join('');
        }
        return '';
      }).join('');
      const lines = text.replace(/\n$/, '').split('\n').length;
      if (lines <= COLLAPSE_AFTER_LINES) return 
    {children}
    ; return (
    {children}
    ); }; // Agent prose past six lines (6 × 22px) folds in place; Show more expands it, Show less folds it. // Only the fold is measured, so a short reply renders exactly as before. const PROSE_COLLAPSE_PX = 132; export const CollapsibleProse: React.FC<{ collapsible: boolean; children: React.ReactNode }> = ({ collapsible, children }) => { const { t } = useTranslation(); const ref = useRef(null); const [open, setOpen] = useState(false); const [overflows, setOverflows] = useState(false); useLayoutEffect(() => { const el = ref.current; if (!collapsible || !el) return undefined; const measure = () => setOverflows(el.scrollHeight > PROSE_COLLAPSE_PX + 1); measure(); if (typeof ResizeObserver === 'undefined') return undefined; const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); }, [collapsible]); const folded = overflows && !open; if (!collapsible) return <>{children}; return ( <> {/* Focus reaching a link below the fold opens the message, so the hidden link is never focused blind. */}
    { if (!folded) return; setOpen(true); // The link is still clipped until the message re-lays out, so scroll it in once the open frame has painted. const target = event.target as HTMLElement; requestAnimationFrame(() => target.scrollIntoView?.({ block: 'nearest' })); }} > {children}
    {overflows && ( )} ); }; // Match the §3.8 agent-dm-created announcement posted by commonly-bot: // "🤝 Pixel and codex started a DM — [view](/v2/pods/)" // The full message body is the line above; capture the headline text and the // target pod id so we can render a card with a router-aware navigation // button instead of the raw markdown link (which would `target="_blank"` and // pop a new tab — wrong for in-app navigation). const AGENT_DM_EVENT_RE = /^🤝\s+(.+?)\s+—\s+\[view\]\(\/v2\/pods\/([a-f0-9]{24})\)\s*$/i; const parseAgentDmEvent = (content: string | undefined): { headline: string; targetPodId: string } | null => { if (!content) return null; const match = content.trim().match(AGENT_DM_EVENT_RE); if (!match) return null; return { headline: match[1], targetPodId: match[2] }; }; const MAX_REACTION_CHIPS = 6; const LONG_PRESS_MS = 500; // Jump to a message already in the transcript and mark it landed for a beat. export const landOnMessage = (id: string | number | null | undefined): boolean => { if (id === null || id === undefined) return false; let el = typeof document !== 'undefined' ? document.getElementById(`message-${id}`) : null; if (!el && typeof document !== 'undefined') { const target = String(id); el = Array.from(document.querySelectorAll('[data-source-message-id]')) .find((candidate) => candidate.dataset.sourceMessageId === target) || null; } if (!el) return false; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); // The landed row is the keyboard target as well as the visual target. Keep // focus from changing the scroll position a second time after smooth scroll. if (typeof (el as HTMLElement).focus === 'function') { (el as HTMLElement).focus({ preventScroll: true }); } el.classList.add('v2-msg--landed'); window.setTimeout(() => el.classList.remove('v2-msg--landed'), 2000); return true; }; const V2MessageRow: React.FC = ({ message, sourceMessageId, decision, onDecisionRuled, isDecisionRuling = false, isLead, agentDisplayNames, agentTags, agentAuthorKeys, onAuthorClick, onOpenFile, onReply, onThread, onQuoteNavigate, grouped, insideThreadRoot }) => { const { currentUser } = useAuth(); const { t } = useTranslation(); const navigate = useNavigate(); const api = useV2Api(); // Picker state per message. Open via "+ react"; close on first click // (no need for outside-click handling — the picker hides after action). const [pickerOpen, setPickerOpen] = useState(false); // Touch reveal (2026-08-23): on hoverless devices the action cluster // must NOT sit on every message by default — that is chrome over content. // A tap on the message reveals it (CSS shows .v2-msg--reveal only inside // the hover:none media block); a second tap or the picker closing hides // it again. Desktop hover behavior is untouched — the class is inert // wherever hover exists. const [actionsRevealed, setActionsRevealed] = useState(false); const [reactionsExpanded, setReactionsExpanded] = useState(false); const pressTimer = useRef(null); const isHoverless = () => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(hover: none)').matches; // Direction C at 390: a long-press (~500ms) reveals the strip inline under // the body; a tap elsewhere dismisses it. Desktop hover is untouched. // The pointer-up that ends a long-press still delivers a click on most // touch stacks; without this latch that click would hide the strip the // same instant the press revealed it (walk-2b, 390). const pressRevealedRef = useRef(false); const onPressStart = () => { if (!isHoverless()) return; pressRevealedRef.current = false; pressTimer.current = window.setTimeout(() => { setActionsRevealed(true); pressRevealedRef.current = true; pressTimer.current = null; }, LONG_PRESS_MS); }; const onPressEnd = () => { if (pressTimer.current !== null) { window.clearTimeout(pressTimer.current); pressTimer.current = null; } }; const onBubbleTap = () => { if (!isHoverless()) return; if (pressRevealedRef.current) { pressRevealedRef.current = false; return; } if (actionsRevealed) { setActionsRevealed(false); setPickerOpen(false); } }; // Surface why a reaction failed instead of swallowing it. Before this, a // rejected reaction (bad emoji 400, non-member 403, rate-limit 429) did // nothing visible — which made the ❤️-validation bug read as "reactions // don't work / can't add more than one" (2026-07-24). const [reactionError, setReactionError] = useState(null); // Thumbnail → lightbox, per row; the index is into this row's own images. const [lightboxIndex, setLightboxIndex] = useState(null); const rawUsername = message.user?.username || 'Unknown'; const overriddenDisplay = agentDisplayNames?.get(rawUsername); const author = overriddenDisplay || rawUsername; // Click is gated by agentAuthorKeys — backend may serve either raw username // or displayName on `message.user.username`, and the v2 set covers both. const isClickable = !!onAuthorClick && !!agentAuthorKeys?.has(rawUsername.toLowerCase()); const runtimeTag = agentTags?.get(rawUsername.toLowerCase()) || agentTags?.get(author.toLowerCase()); const handleAuthorClick = isClickable ? () => onAuthorClick?.(rawUsername) : undefined; const time = formatRelativeTime(message.created_at); // Kernel system actions have a structured payload and no message actions, // avatar, reply affordance or mention semantics. Legacy system messages // continue through the existing renderer below. if ((message.message_type === 'system' || message.messageType === 'system') && message.payload?.kind === 'system-action' && message.payload.action === 'task-claimed') { const actorLabel = overriddenDisplay || message.payload.actorName || author; return (
    {actorLabel} {t('podChat.systemActions.taskClaimedVerb')} {message.payload.taskId || ''} {time && }
    ); } if (decision) { return (
    ); } // ADR-020 D3: payload-driven components render from structure, not // content regex — the first message class where `payload` is the truth // and `content` is only the plain-text fallback for legacy surfaces. if (message.payload?.kind === 'approval-card') { return <>; } if (message.payload?.kind === 'byo-handoff') { return <>; } if (message.payload?.kind === 'ui') { return {message.content}}>{message.content}}>; } if (message.payload?.kind === 'gig-card') { return <>; } // §3.8 system event card. Detected by content shape (commonly-bot only // posts this exact form), so we don't depend on a metadata column the PG // messages table doesn't have. Render a chrome-light card with a // router-aware "Open conversation" button — never a new tab. const dmEvent = parseAgentDmEvent(message.content); if (dmEvent) { return (
    {dmEvent.headline}
    {time &&
    {time}
    }
    ); } // Two-pass parse: reactions first (they live anywhere in the body), then // files. Order matters — files leave a trimmed body that we then read for // image rendering. const { stripped: noReactions, reactions } = parseReactions(message.content || ''); const { stripped: afterFiles, files } = parseFiles(noReactions); const markdownImage = afterFiles.match(MARKDOWN_IMAGE_RE)?.[1]; const rawImageUrl = message.message_type === 'image' || message.messageType === 'image' || IMAGE_URL_RE.test(afterFiles) ? afterFiles : markdownImage; // Upload responses use instance-portable `/api/uploads/...` references. // Resolve them against the configured API origin at render time so the // browser never requests image bytes from the frontend host. const imageUrl = messageImageUrl(rawImageUrl); const externalBareImage = rawImageUrl && !imageUrl && !markdownImage && /^https?:\/\//i.test(afterFiles) ? afterFiles : null; // Direction C: every image attached to the message is a 156×104 thumbnail in // one gallery row; other uploads are chips. Uploaded images resolve to the // same `/api/uploads/` reference the legacy image path already uses. const imageFiles = files.filter(isImageFile); const otherFiles = files.filter((file) => !isImageFile(file)); const galleryImages: V2LightboxImage[] = [ ...(imageUrl ? [{ src: imageUrl, name: t('podChat.attachment.image') }] : []), ...imageFiles.filter((file) => file.fileName).flatMap((file) => { const src = messageImageUrl(`/api/uploads/${file.fileName}`); return src ? [{ src, name: file.name }] : []; }), ]; // GitHub PR URL detection — if the message body contains a `pull/` URL, // we render an inline preview card below the text. Card fetch is lazy + // memoized at module scope; one fetch per (owner, repo, number) per session. // The bare URL is stripped from the rendered text so we don't double-show // "URL as text + URL as card". const prRefs = imageUrl ? [] : parseGithubPrUrls(afterFiles); let stripped = afterFiles; if (prRefs.length > 0) { stripped = prUrlsToRefLinks(afterFiles).trim(); } // Auto-linkify bare http(s) URLs that aren't already inside markdown link // syntax. Without this, agents posting raw URLs render as plain text and // the user can't click them. if (stripped && !externalBareImage) { stripped = linkifyBareUrls(stripped); } // Highlight messages that @-mention the current user. Word-boundary so // `@foo` doesn't match `@foobar`. Skip for self-authored messages — no // value highlighting your own outgoing message. const meUsername = currentUser?.username?.toLowerCase(); const isSelfAuthored = meUsername === rawUsername.toLowerCase(); const mentionsMe = !isSelfAuthored && !!meUsername && new RegExp(`@${meUsername.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(stripped); // --- reactions state, hoisted from the render tail so the hover cluster // owns the trigger while the chips row keeps rendering below the text --- const liveReactions = message.reactions; const hasLive = Array.isArray(liveReactions); const reactMessageId = String(message.id || ''); const canInteract = !!(hasLive && reactMessageId && /^\d+$/.test(reactMessageId)); const renderList: V2RenderedReaction[] = hasLive ? liveReactions! : reactions.map((r) => ({ emoji: r.emoji, count: r.count, mine: false })); const toggleReaction = async (emoji: string, mine: boolean) => { if (!canInteract) return; try { if (mine) { await api.del(`/api/messages/${reactMessageId}/reactions/${encodeURIComponent(emoji)}`); } else { await api.post(`/api/messages/${reactMessageId}/reactions`, { emoji }); } // Optimistic update is unnecessary — the socket `messageReaction` // event from the backend updates the message list in place. } catch (err) { const e = err as { response?: { status?: number; data?: { msg?: string; error?: string } } }; const status = e.response?.status; const serverMsg = e.response?.data?.msg || e.response?.data?.error; setReactionError( serverMsg || (status === 403 ? "You're not a member of this pod." : '') || (status === 429 ? 'Too many reactions — give it a moment.' : '') || 'Could not add that reaction.', ); window.setTimeout(() => setReactionError(null), 4000); // eslint-disable-next-line no-console console.warn('[reactions] toggle failed:', (err as Error).message); } finally { setPickerOpen(false); } }; return (
    {grouped ? ( ); }; export default V2MessageRow;