"use client";

import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type UIEvent as ReactUIEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { io, Socket } from "socket.io-client";
import { ChannelSync, MessageStore, Outbox, IndexedDbOutboxStorage, type OutboxStatus } from "@slackwsh/sync";
import type { BlocksV1, Call, Message, Task } from "@slackwsh/contracts";
import { buildMessagePermalink, detectTaskIntent, parseTaskDueAt } from "@slackwsh/core";
import { api, ApiError, getCurrentUserId, getValidAccessToken, forceLogin, type ChannelAbout } from "../../lib/api";
import { CommandPalette } from "../../components/CommandPalette";
import { ClipRecorder } from "../../components/ClipRecorder";
import { CallOverlay } from "../../components/CallOverlay";
import { NewCallModal, type NewCallSubmit } from "../../components/NewCallModal";
import { GlobalNav } from "../../components/GlobalNav";
import { RoomAbout } from "../../components/RoomAbout";
import { TaskModal, type TaskModalValues } from "../../components/TaskModal";
import { ComposerRewriteBar } from "../../components/ComposerRewriteBar";
import { WorkspaceSidebar } from "../../components/WorkspaceSidebar";
import { UserAvatar } from "../../components/UserAvatar";
import { UserProfilePanel } from "../../components/UserProfilePanel";
import { FilesBrowser } from "../../components/FilesBrowser";
import { ForwardMessageModal } from "../../components/ForwardMessageModal";
import { ForwardedMessageCard, forwardedMessageFromBlocks } from "../../components/ForwardedMessageCard";
import { RichTextEditor, type RichTextEditorHandle } from "../../components/RichTextEditor";
import { RichTextMessage } from "../../components/RichTextMessage";
import { avatarColor, initials } from "../../lib/avatar";
import {
  channelPath,
  dmChannelByUserId,
  dmTitle,
  isDmChannel,
  normalizeChannelRows,
  textChannels as onlyTextChannels,
  type ConversationRow,
} from "../../lib/conversations";
import { clearUnread, hydrateUnreadCounts, setChannelUnread, useClearUnreadOnView, useUnreadTotal } from "../../lib/unread-store";
import { useMemberStatusRefresh } from "../../lib/member-status-events";
import { getSavedMessages, unsaveMessage as removeLegacySavedMessage } from "../../lib/saved-items";
import { formatDurationLabel } from "../../lib/datetime";
import {
  IconAt,
  IconCheck,
  IconChevron,
  IconClip,
  IconEdit,
  IconForward,
  IconDownload,
  IconHelp,
  IconLock,
  IconLink,
  IconMessages,
  IconMic,
  IconMore,
  IconMoreVertical,
  IconPhone,
  IconPin,
  IconPlus,
  IconSearch,
  IconShare,
  IconSend,
  IconSmile,
  IconStar,
  IconTrash,
  IconUsers,
  IconVideo,
  IconX,
} from "../../components/icons";
import { gatewaySocketOptions, gatewayUrl } from "../../lib/gateway-socket";

const ROOM_TABS = ["Messages", "Files & links", "Pins"] as const;
type RoomTab = (typeof ROOM_TABS)[number];

interface MemberRow {
  member: {
    role: string;
    displayName?: string | null;
    title?: string | null;
    statusText?: string | null;
    statusEmoji?: string | null;
    deactivatedAt?: string | null;
  };
  user: {
    id: string | number;
    name: string;
    email: string;
    username?: string | null;
    avatarUrl?: string | null;
    tz?: string | null;
  };
}

type MentionOption =
  | { kind: "special"; handle: "here" | "channel" | "everyone"; label: string; description: string }
  | { kind: "user"; handle: string; label: string; description: string; userId: string | number };

type EmojiOption = { emoji: string; name: string; shortcodes: string[]; category: "Smileys" | "Gestures" | "Work" | "Objects" };
type EmojiPickerState = { mode: "composer" } | { mode: "thread" } | { mode: "reaction"; messageId: string | number };
type MessageSurface = "main" | "thread";

interface EditingMessageState {
  message: Message;
  surface: MessageSurface;
  draft: string;
  blocks: BlocksV1;
}

interface DeleteMessageState {
  message: Message;
  surface: MessageSurface;
}

interface FileAttachment {
  id: string | number;
  name: string;
  type: string;
  size: number;
  url: string;
  category?: "image" | "video" | "audio" | "pdf" | "document" | "archive" | "text";
}

interface ComposerAttachment {
  localId: string;
  name: string;
  type: string;
  size: number;
  status: "uploading" | "ready" | "failed";
  uploaded?: FileAttachment;
  error?: string;
}

const COMPOSER_TOOLS = [
  { key: "attach", Icon: IconClip, label: "Attach a file" },
  { key: "mention", Icon: IconAt, label: "Mention someone" },
  { key: "emoji", Icon: IconSmile, label: "Add emoji" },
] as const;

const THREAD_TOOLS = [
  { key: "mention", Icon: IconAt, label: "Mention someone" },
  { key: "emoji", Icon: IconSmile, label: "Add emoji" },
  { key: "attach", Icon: IconClip, label: "Attach a file" },
] as const;

const EMOJI_OPTIONS: EmojiOption[] = [
  { emoji: "😀", name: "Grinning face", shortcodes: ["grinning", "smile"], category: "Smileys" },
  { emoji: "😂", name: "Face with tears of joy", shortcodes: ["joy", "laughing"], category: "Smileys" },
  { emoji: "😊", name: "Smiling face", shortcodes: ["blush", "smile"], category: "Smileys" },
  { emoji: "😍", name: "Heart eyes", shortcodes: ["heart_eyes", "love"], category: "Smileys" },
  { emoji: "😎", name: "Sunglasses", shortcodes: ["sunglasses", "cool"], category: "Smileys" },
  { emoji: "😭", name: "Loudly crying", shortcodes: ["sob", "cry"], category: "Smileys" },
  { emoji: "😅", name: "Sweat smile", shortcodes: ["sweat_smile", "relief"], category: "Smileys" },
  { emoji: "👍", name: "Thumbs up", shortcodes: ["thumbsup", "+1", "yes"], category: "Gestures" },
  { emoji: "👎", name: "Thumbs down", shortcodes: ["thumbsdown", "-1", "no"], category: "Gestures" },
  { emoji: "👏", name: "Clapping hands", shortcodes: ["clap", "applause"], category: "Gestures" },
  { emoji: "🙏", name: "Folded hands", shortcodes: ["pray", "thanks"], category: "Gestures" },
  { emoji: "🙌", name: "Raised hands", shortcodes: ["raised_hands", "celebrate"], category: "Gestures" },
  { emoji: "👀", name: "Eyes", shortcodes: ["eyes", "looking"], category: "Gestures" },
  { emoji: "✅", name: "Check mark", shortcodes: ["white_check_mark", "done"], category: "Work" },
  { emoji: "❌", name: "Cross mark", shortcodes: ["x", "no"], category: "Work" },
  { emoji: "🔥", name: "Fire", shortcodes: ["fire", "hot"], category: "Work" },
  { emoji: "🎉", name: "Party popper", shortcodes: ["tada", "party"], category: "Work" },
  { emoji: "🚀", name: "Rocket", shortcodes: ["rocket", "ship"], category: "Work" },
  { emoji: "💯", name: "Hundred points", shortcodes: ["100", "perfect"], category: "Work" },
  { emoji: "❤️", name: "Red heart", shortcodes: ["heart", "love"], category: "Work" },
  { emoji: "💡", name: "Light bulb", shortcodes: ["bulb", "idea"], category: "Objects" },
  { emoji: "📌", name: "Pushpin", shortcodes: ["pushpin", "pin"], category: "Objects" },
  { emoji: "📎", name: "Paperclip", shortcodes: ["paperclip", "attachment"], category: "Objects" },
  { emoji: "📅", name: "Calendar", shortcodes: ["calendar", "date"], category: "Objects" },
  { emoji: "🧵", name: "Thread", shortcodes: ["thread", "reply"], category: "Objects" },
  { emoji: "☕", name: "Coffee", shortcodes: ["coffee", "break"], category: "Objects" },
];

function formatTime(iso: string): string {
  return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}

function relativeAge(iso: string): string {
  const diff = Math.max(0, Date.now() - new Date(iso).getTime());
  const minute = 60_000;
  const hour = 60 * minute;
  const day = 24 * hour;
  if (diff < minute) return "just now";
  if (diff < hour) {
    const n = Math.floor(diff / minute);
    return `${n} ${n === 1 ? "minute" : "minutes"} ago`;
  }
  if (diff < day) {
    const n = Math.floor(diff / hour);
    return `${n} ${n === 1 ? "hour" : "hours"} ago`;
  }
  const n = Math.floor(diff / day);
  return `${n} ${n === 1 ? "day" : "days"} ago`;
}

function dayKey(iso: string): string {
  return new Date(iso).toDateString();
}

function dayLabel(iso: string): string {
  const date = new Date(iso);
  const today = new Date();
  const yesterday = new Date();
  yesterday.setDate(today.getDate() - 1);
  if (date.toDateString() === today.toDateString()) return "Today";
  if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
  return date.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" });
}

type FeedItem =
  | { kind: "message"; atIso: string; message: Message }
  | { kind: "call"; atIso: string; call: Call };

function mergeConversationFeed(messages: Message[], calls: Call[]): FeedItem[] {
  const items: FeedItem[] = [
    ...messages.map((message) => ({ kind: "message" as const, atIso: message.createdAt, message })),
    ...calls.map((call) => ({ kind: "call" as const, atIso: call.startedAt, call })),
  ];
  return items.sort((a, b) => {
    const delta = Date.parse(a.atIso) - Date.parse(b.atIso);
    if (delta !== 0) return delta;
    if (a.kind !== b.kind) return a.kind === "message" ? -1 : 1;
    if (a.kind === "message" && b.kind === "message") return (a.message.seq ?? 0) - (b.message.seq ?? 0);
    if (a.kind === "call" && b.kind === "call") return String(a.call.id).localeCompare(String(b.call.id));
    return 0;
  });
}

function callBelongsToConversation(call: Call, channelId: string, dmPeerId?: string | number | null) {
  if (call.channelId != null && String(call.channelId) === String(channelId)) return true;
  if (call.channelId != null) return false;
  if (dmPeerId == null) return false;
  return call.participants.some((participant) => String(participant.userId) === String(dmPeerId));
}

function upsertCall(current: Call[], call: Call) {
  const index = current.findIndex((row) => String(row.id) === String(call.id));
  if (index === -1) return [...current, call];
  const next = current.slice();
  next[index] = call;
  return next;
}

function callOtherNames(call: Call, myUserId: string | number | null, nameFor: (id: string | number) => string) {
  return call.participants
    .filter((participant) => String(participant.userId) !== String(myUserId))
    .map((participant) => nameFor(participant.userId));
}

function callFeedTitle(call: Call, myUserId: string | number | null, nameFor: (id: string | number) => string) {
  const outgoing = String(call.startedBy) === String(myUserId);
  const mine = call.participants.find((participant) => String(participant.userId) === String(myUserId));
  const others = callOtherNames(call, myUserId, nameFor);
  const who = others.length > 0 ? others.join(", ") : "Connect";
  const kindLabel = call.kind === "video" ? "video call" : call.kind === "connect" ? "Connect" : "call";
  if (call.status === "ringing") return outgoing ? `Calling ${who}` : `Incoming ${kindLabel} from ${who}`;
  if (call.status === "active") return call.title?.trim() || `Connect with ${who}`;
  if (call.status === "missed" || mine?.state === "missed") {
    return outgoing ? `No answer from ${who}` : `Missed ${kindLabel} from ${who}`;
  }
  if (mine?.state === "declined") return `Declined ${kindLabel}`;
  return call.title?.trim() || (outgoing ? `You called ${who}` : `${who} called you`);
}

function callFeedDetail(call: Call, myUserId: string | number | null) {
  const mine = call.participants.find((participant) => String(participant.userId) === String(myUserId));
  if (call.status === "ringing") return "Ringing…";
  if (call.status === "active") {
    const joined = call.participants.filter((participant) => participant.state === "joined").length;
    return joined > 0 ? `In progress · ${joined} on the call` : "In progress";
  }
  if (call.status === "missed" || mine?.state === "missed") return "Missed";
  if (mine?.state === "declined") return "Declined";
  if (call.durationSeconds != null) return formatDurationLabel(call.durationSeconds);
  return "Ended";
}

function isQuestionText(text: string | null | undefined): boolean {
  const trimmed = (text ?? "").trim();
  return trimmed.endsWith("?") || trimmed.startsWith("?");
}

function handleFromMember(row: MemberRow): string {
  const username = row.user.username?.trim();
  if (username) return username;
  const localPart = row.user.email.split("@")[0] ?? row.user.name;
  return localPart.toLowerCase().replace(/[^a-z0-9._-]+/g, "");
}

function findMentionTrigger(text: string, cursor: number): { start: number; query: string } | null {
  const beforeCursor = text.slice(0, cursor);
  const match = /(^|\s)@([a-zA-Z0-9._-]*)$/.exec(beforeCursor);
  if (!match) return null;
  return { start: match.index + match[1]!.length, query: match[2] ?? "" };
}

function renderMentionToken(token: string, key: string) {
  const clean = token.slice(1);
  const special = clean === "here" || clean === "channel" || clean === "everyone";
  return (
    <span className={special ? "msg-mention special" : "msg-mention"} key={key}>
      {token}
    </span>
  );
}

function renderTextWithMentions(text: string): ReactNode {
  const parts: ReactNode[] = [];
  const pattern = /(`[^`\n]+`|\*\*[^*\n]+\*\*|~~[^~\n]+~~|<u>[^<\n]+<\/u>|_[^_\n]+_|@(here|channel|everyone|[a-zA-Z0-9._-]{1,80}))/g;
  let lastIndex = 0;
  for (const match of text.matchAll(pattern)) {
    const index = match.index ?? 0;
    if (index > lastIndex) parts.push(text.slice(lastIndex, index));
    const token = match[0];
    const key = `${index}-${token}`;
    if (token.startsWith("@")) {
      parts.push(renderMentionToken(token, key));
    } else if (token.startsWith("**")) {
      parts.push(<strong className="msg-bold" key={key}>{renderTextWithMentions(token.slice(2, -2))}</strong>);
    } else if (token.startsWith("~~")) {
      parts.push(<s className="msg-strike" key={key}>{renderTextWithMentions(token.slice(2, -2))}</s>);
    } else if (token.startsWith("<u>")) {
      parts.push(<u className="msg-underline" key={key}>{renderTextWithMentions(token.slice(3, -4))}</u>);
    } else if (token.startsWith("_")) {
      parts.push(<em className="msg-italic" key={key}>{renderTextWithMentions(token.slice(1, -1))}</em>);
    } else {
      parts.push(<code className="msg-code" key={key}>{token.slice(1, -1)}</code>);
    }
    lastIndex = index + match[0].length;
  }
  if (lastIndex < text.length) parts.push(text.slice(lastIndex));
  return parts.length > 0 ? parts : text;
}

function formatFileSize(size: number): string {
  if (size < 1024) return `${size} B`;
  if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
  return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}

function blocksWithAttachments(text: string, attachments: FileAttachment[], richBlocks?: BlocksV1) {
  const base = richBlocks ?? {
    v: 1 as const,
    doc: { type: "doc", content: text ? [{ type: "paragraph", content: [{ type: "text", text }] }] : [] },
  };
  return {
    ...base,
    attachments,
    doc: {
      ...base.doc,
      attachments,
    },
  };
}

function messageAttachments(message: Message): FileAttachment[] {
  const blocks = message.blocks as { attachments?: unknown; doc?: { attachments?: unknown } } | null | undefined;
  const raw = Array.isArray(blocks?.doc?.attachments)
    ? blocks.doc.attachments
    : Array.isArray(blocks?.attachments)
      ? blocks.attachments
      : [];
  return raw.filter((item): item is FileAttachment => {
    const file = item as Partial<FileAttachment>;
    return Boolean(file.id && file.name && file.url && typeof file.size === "number");
  });
}

function fileKind(file: FileAttachment): "image" | "video" | "audio" | "pdf" | "file" {
  const lowerName = file.name.toLowerCase();
  if (file.type.startsWith("image/")) return "image";
  if (file.type.startsWith("video/")) return "video";
  if (file.type.startsWith("audio/")) return "audio";
  if (file.type === "application/pdf" || lowerName.endsWith(".pdf")) return "pdf";
  return "file";
}

function attachmentTitle(file: FileAttachment): string {
  if (!file.type.startsWith("image/")) return file.name;
  return file.name.replace(/\.[^.]+$/, "") || file.name;
}

function isAttachmentOnlyText(message: Message): boolean {
  const attachments = messageAttachments(message);
  if (attachments.length === 0) return false;
  const text = message.text.trim();
  if (!text) return true;
  return attachments.some((file) => text === file.name || text === attachmentTitle(file))
    || text === attachments.map((file) => file.name).join(", ")
    || text.split(", ").length > 1 && text.split(", ").every((part) => /\.[a-z0-9]{2,8}$/i.test(part.trim()));
}

function AttachmentPreview({
  file,
  onError,
  onOpenImage,
}: {
  file: FileAttachment;
  onError: (message: string) => void;
  onOpenImage?: (file: FileAttachment, url: string) => void;
}) {
  const [mediaUrl, setMediaUrl] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const kind = fileKind(file);

  useEffect(() => {
    if (kind === "file") return;
    let cancelled = false;
    let objectUrl: string | null = null;
    setLoading(true);
    const request = file.url.endsWith("/access")
      ? api.resolveFileUrl(file.url).then(({ url }) => url)
      : api.downloadFile(file.url).then((blob) => {
          objectUrl = URL.createObjectURL(blob.type === file.type ? blob : new Blob([blob], { type: file.type }));
          return objectUrl;
        });
    request
      .then((url) => {
        if (cancelled) return;
        setMediaUrl(url);
      })
      .catch((err) => {
        if (!cancelled) onError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });
    return () => {
      cancelled = true;
      if (objectUrl) URL.revokeObjectURL(objectUrl);
    };
  }, [file.url, file.type, kind, onError]);

  async function openFile() {
    try {
      if (mediaUrl && kind === "image" && onOpenImage) {
        onOpenImage(file, mediaUrl);
        return;
      }
      if (mediaUrl && kind !== "file") {
        window.open(mediaUrl, "_blank", "noopener,noreferrer");
        return;
      }
      const blob = await api.downloadFile(file.url);
      const url = URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = file.name;
      link.click();
      setTimeout(() => URL.revokeObjectURL(url), 1000);
    } catch (err) {
      onError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    }
  }

  if (kind === "image") {
    return (
      <div className="msg-attachment image">
        <div className="msg-attachment-title">
          <span>{attachmentTitle(file)}</span>
          <IconChevron size={14} />
        </div>
        <button type="button" className="msg-image-preview" onClick={openFile} aria-label={`Open ${file.name}`}>
          {mediaUrl ? <img src={mediaUrl} alt={file.name} /> : <span>{loading ? "Loading preview..." : "Preview unavailable"}</span>}
        </button>
      </div>
    );
  }

  if (kind === "video") {
    return (
      <div className="msg-attachment media">
        <div className="msg-attachment-title"><span>{file.name}</span><small>{formatFileSize(file.size)}</small></div>
        {mediaUrl ? <video className="msg-media-player" src={mediaUrl} controls preload="metadata" /> : <span>{loading ? "Loading video..." : "Video unavailable"}</span>}
      </div>
    );
  }

  if (kind === "audio") {
    return (
      <div className="msg-attachment media audio">
        <div className="msg-attachment-title"><span>{file.name}</span><small>{formatFileSize(file.size)}</small></div>
        {mediaUrl ? <audio className="msg-audio-player" src={mediaUrl} controls preload="metadata" /> : <span>{loading ? "Loading audio..." : "Audio unavailable"}</span>}
      </div>
    );
  }

  if (kind === "pdf") {
    return (
      <div className="msg-attachment document">
        <div className="msg-attachment-title">
          <span>PDF</span>
          <IconChevron size={14} />
        </div>
        <button type="button" className="msg-document-preview" onClick={openFile} aria-label={`Open ${file.name}`}>
          <span className="msg-file-icon pdf">
            <IconClip size={17} />
          </span>
          <span className="msg-document-copy">
            <strong>{file.name}</strong>
            <small>PDF - {formatFileSize(file.size)}</small>
          </span>
          {mediaUrl ? (
            <object className="msg-pdf-frame" data={mediaUrl} type="application/pdf" aria-label={`${file.name} preview`} />
          ) : (
            <span className="msg-document-fallback">{loading ? "Loading preview..." : "Open PDF"}</span>
          )}
        </button>
      </div>
    );
  }

  return (
    <button className="msg-file" type="button" onClick={openFile}>
      <span className="msg-file-icon">
        <IconClip size={16} />
      </span>
      <span className="msg-file-copy">
        <span className="msg-file-name">{file.name}</span>
        <span className="msg-file-meta">{file.type || "File"} - {formatFileSize(file.size)}</span>
      </span>
    </button>
  );
}

function ImageAttachmentTile({
  file,
  overflow,
  active = false,
  onOpen,
  onDownload,
  onDelete,
  canDelete,
  onError,
}: {
  file: FileAttachment;
  overflow?: number;
  active?: boolean;
  onOpen: (file: FileAttachment, url: string) => void;
  onDownload: (file: FileAttachment) => void;
  onDelete: (file: FileAttachment) => void;
  canDelete: boolean;
  onError: (message: string) => void;
}) {
  const [url, setUrl] = useState<string | null>(null);
  const [menuOpen, setMenuOpen] = useState(false);

  useEffect(() => {
    let cancelled = false;
    let objectUrl: string | null = null;
    const request = file.url.endsWith("/access")
      ? api.resolveFileUrl(file.url).then((result) => result.url)
      : api.downloadFile(file.url).then((blob) => {
          objectUrl = URL.createObjectURL(blob);
          return objectUrl;
        });
    request.then((nextUrl) => {
      if (!cancelled) setUrl(nextUrl);
    }).catch((error) => {
      if (!cancelled) onError(error instanceof Error ? error.message : String(error));
    });
    return () => {
      cancelled = true;
      if (objectUrl) URL.revokeObjectURL(objectUrl);
    };
  }, [file.url, onError]);

  return (
    <div className={`slack-image-tile${active ? " active" : ""}`}>
      <button className="slack-image-open" type="button" onClick={() => url && onOpen(file, url)} aria-label={`Open ${file.name}`}>
        {url ? <img src={url} alt={file.name} /> : <span>Loading…</span>}
        {overflow && overflow > 0 ? <span className="slack-image-overflow">+{overflow}</span> : null}
      </button>
      <div className="slack-image-actions">
        <button type="button" onClick={() => onDownload(file)} aria-label={`Download ${file.name}`} title="Download">
          <IconDownload size={16} />
        </button>
        <button type="button" onClick={() => setMenuOpen((value) => !value)} aria-label={`More actions for ${file.name}`} title="More actions" aria-expanded={menuOpen}>
          <IconMoreVertical size={16} />
        </button>
        {menuOpen && (
          <div className="slack-image-menu" role="menu">
            <button type="button" role="menuitem" onClick={() => { setMenuOpen(false); onDownload(file); }}>
              <IconDownload size={14} /> Download
            </button>
            {canDelete && (
              <button className="danger" type="button" role="menuitem" onClick={() => { setMenuOpen(false); onDelete(file); }}>
                <IconTrash size={14} /> Delete file
              </button>
            )}
          </div>
        )}
      </div>
    </div>
  );
}

function AttachmentCollection({
  files,
  workspaceId,
  channelId,
  canDelete,
  expanded = false,
  galleryGroupId,
  useThreadViewer = false,
  delegateGallery = false,
  onOpenThread,
  onError,
}: {
  files: FileAttachment[];
  workspaceId: string;
  channelId: string;
  canDelete: boolean;
  expanded?: boolean;
  galleryGroupId: string;
  useThreadViewer?: boolean;
  delegateGallery?: boolean;
  onOpenThread?: () => void;
  onError: (message: string) => void;
}) {
  const [deletedIds, setDeletedIds] = useState<Set<string>>(() => new Set());
  const visibleFiles = files.filter((file) => !deletedIds.has(String(file.id)));
  const images = visibleFiles.filter((file) => fileKind(file) === "image");
  const otherFiles = visibleFiles.filter((file) => fileKind(file) !== "image");
  const [gallery, setGallery] = useState<{ index: number; url: string } | null>(null);
  const [galleryMenuOpen, setGalleryMenuOpen] = useState(false);

  async function openImage(file: FileAttachment, knownUrl?: string) {
    if (delegateGallery) {
      window.dispatchEvent(new CustomEvent("slackwsh:gallery-open", {
        detail: { groupId: galleryGroupId, fileId: String(file.id) },
      }));
      return;
    }
    const index = images.findIndex((image) => String(image.id) === String(file.id));
    if (index < 0) return;
    onOpenThread?.();
    try {
      const url = knownUrl ?? (file.url.endsWith("/access")
        ? (await api.resolveFileUrl(file.url)).url
        : URL.createObjectURL(await api.downloadFile(file.url)));
      setGallery({ index, url });
    } catch (error) {
      onError(error instanceof Error ? error.message : String(error));
    }
  }

  function move(delta: number) {
    if (!gallery || images.length < 2) return;
    const index = (gallery.index + delta + images.length) % images.length;
    setGalleryMenuOpen(false);
    void openImage(images[index]!);
  }

  useEffect(() => {
    if (!gallery) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        setGallery(null);
        setGalleryMenuOpen(false);
      } else if (event.key === "ArrowLeft") {
        move(-1);
      } else if (event.key === "ArrowRight") {
        move(1);
      }
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [gallery, images.length]);

  useEffect(() => {
    if (expanded || !useThreadViewer || delegateGallery) return;
    const openRequestedImage = (event: Event) => {
      const detail = (event as CustomEvent<{ groupId: string; fileId: string }>).detail;
      if (detail?.groupId !== galleryGroupId) return;
      const file = images.find((image) => String(image.id) === detail.fileId);
      if (file) void openImage(file);
    };
    const closeGallery = () => {
      setGallery(null);
      setGalleryMenuOpen(false);
    };
    window.addEventListener("slackwsh:gallery-open", openRequestedImage);
    window.addEventListener("slackwsh:gallery-close", closeGallery);
    return () => {
      window.removeEventListener("slackwsh:gallery-open", openRequestedImage);
      window.removeEventListener("slackwsh:gallery-close", closeGallery);
    };
  }, [delegateGallery, expanded, galleryGroupId, images, useThreadViewer]);

  async function downloadFile(file: FileAttachment) {
    try {
      const blob = await api.downloadFile(file.url);
      const url = URL.createObjectURL(blob);
      const anchor = document.createElement("a");
      anchor.href = url;
      anchor.download = file.name;
      document.body.appendChild(anchor);
      anchor.click();
      anchor.remove();
      window.setTimeout(() => URL.revokeObjectURL(url), 1500);
    } catch (error) {
      onError(error instanceof ApiError ? JSON.stringify(error.body) : error instanceof Error ? error.message : String(error));
    }
  }

  async function downloadAll() {
    for (const file of visibleFiles) await downloadFile(file);
  }

  async function deleteFile(file: FileAttachment) {
    if (!window.confirm(`Delete ${file.name}? This removes it for everyone in this conversation.`)) return;
    try {
      await api.deleteFile(workspaceId, channelId, file.id);
      const deletedIndex = images.findIndex((image) => String(image.id) === String(file.id));
      setDeletedIds((current) => new Set(current).add(String(file.id)));
      if (gallery && String(images[gallery.index]?.id) === String(file.id)) {
        setGallery(null);
        setGalleryMenuOpen(false);
      } else if (gallery && deletedIndex >= 0 && deletedIndex < gallery.index) {
        setGallery((current) => current ? { ...current, index: current.index - 1 } : current);
      }
      window.dispatchEvent(new CustomEvent("slackwsh:gallery-file-deleted", {
        detail: { groupId: galleryGroupId, fileId: String(file.id) },
      }));
    } catch (error) {
      onError(error instanceof ApiError ? JSON.stringify(error.body) : error instanceof Error ? error.message : String(error));
    }
  }

  useEffect(() => {
    const handleDeletedFile = (event: Event) => {
      const detail = (event as CustomEvent<{ groupId: string; fileId: string }>).detail;
      if (detail?.groupId !== galleryGroupId) return;
      setDeletedIds((current) => new Set(current).add(detail.fileId));
      if (gallery && String(images[gallery.index]?.id) === detail.fileId) {
        setGallery(null);
        setGalleryMenuOpen(false);
      }
    };
    window.addEventListener("slackwsh:gallery-file-deleted", handleDeletedFile);
    return () => window.removeEventListener("slackwsh:gallery-file-deleted", handleDeletedFile);
  }, [gallery, galleryGroupId, images]);

  if (visibleFiles.length === 0) return null;

  const imageTiles = expanded ? images : images.slice(0, 4);
  const activeGalleryFile = gallery ? images[gallery.index] : undefined;

  return (
    <>
      <div className="slack-files-group">
        {visibleFiles.length > 1 && (
          <div className="slack-files-summary">
            <strong>{visibleFiles.length} files</strong>
            <span aria-hidden="true">▾</span>
            <button type="button" onClick={() => void downloadAll()}><IconDownload size={15} /> Download all</button>
          </div>
        )}
        {images.length > 0 && (
          <div className={`slack-image-grid count-${Math.min(images.length, 4)}${expanded ? " expanded" : ""}`}>
            {imageTiles.map((file, index) => (
              <ImageAttachmentTile
                key={file.id}
                file={file}
                overflow={!expanded && index === 3 && images.length > 4 ? images.length - 3 : undefined}
                onOpen={openImage}
                onDownload={(item) => void downloadFile(item)}
                onDelete={(item) => void deleteFile(item)}
                canDelete={canDelete}
                onError={onError}
              />
            ))}
          </div>
        )}
        {otherFiles.length > 0 && (
          <div className="msg-files">
            {otherFiles.map((file) => <AttachmentPreview file={file} key={file.id} onError={onError} />)}
          </div>
        )}
      </div>
      {gallery && (
        <div className={`media-gallery-backdrop${useThreadViewer ? " with-thread" : ""}`} role="dialog" aria-modal="true" aria-label="Image gallery" onMouseDown={(event) => {
          if (event.target === event.currentTarget) {
            setGallery(null);
            setGalleryMenuOpen(false);
          }
        }}>
          <section className="media-gallery-stage">
            <div className="media-gallery-title">{activeGalleryFile?.name}</div>
            {images.length > 1 && <button className="media-gallery-nav prev" type="button" onClick={() => move(-1)} aria-label="Previous image">‹</button>}
            <figure className="media-gallery-figure">
              <img src={gallery.url} alt={images[gallery.index]?.name ?? "Shared image"} />
              <figcaption>{images[gallery.index]?.name} · {gallery.index + 1} of {images.length}</figcaption>
            </figure>
            {activeGalleryFile && (
              <div className="media-gallery-toolbar">
                <button type="button" onClick={() => void downloadFile(activeGalleryFile)} title="Download" aria-label={`Download ${activeGalleryFile.name}`}>
                  <IconDownload size={19} />
                </button>
                <span>{gallery.index + 1} / {images.length}</span>
                <button type="button" onClick={() => setGalleryMenuOpen((open) => !open)} title="More actions" aria-label={`More actions for ${activeGalleryFile.name}`} aria-expanded={galleryMenuOpen}>
                  <IconMoreVertical size={19} />
                </button>
                {galleryMenuOpen && (
                  <div className="media-gallery-menu" role="menu">
                    <button type="button" role="menuitem" onClick={() => { setGalleryMenuOpen(false); void downloadFile(activeGalleryFile); }}>
                      <IconDownload size={15} /> Download
                    </button>
                    {canDelete && (
                      <button className="danger" type="button" role="menuitem" onClick={() => { setGalleryMenuOpen(false); void deleteFile(activeGalleryFile); }}>
                        <IconTrash size={15} /> Delete file
                      </button>
                    )}
                  </div>
                )}
              </div>
            )}
            {images.length > 1 && <button className="media-gallery-nav next" type="button" onClick={() => move(1)} aria-label="Next image">›</button>}
          </section>
          {!useThreadViewer && <aside className="media-gallery-rail" aria-label="All images">
            <div className="media-gallery-rail-head">
              <div>
                <strong>{images.length} files</strong>
                <button type="button" onClick={() => void downloadAll()}><IconDownload size={15} /> Download all</button>
              </div>
              <button className="media-gallery-close" type="button" onClick={() => { setGallery(null); setGalleryMenuOpen(false); }} aria-label="Close gallery"><IconX /></button>
            </div>
            <div className="media-gallery-rail-grid">
              {images.map((file, index) => (
                <ImageAttachmentTile
                  key={`gallery-${file.id}`}
                  file={file}
                  active={index === gallery.index}
                  onOpen={openImage}
                  onDownload={(item) => void downloadFile(item)}
                  onDelete={(item) => void deleteFile(item)}
                  canDelete={canDelete}
                  onError={onError}
                />
              ))}
            </div>
          </aside>}
        </div>
      )}
    </>
  );
}

/**
 * Room view — four-column Windshield shell:
 * global nav | workspace sidebar | main (tabs + feed + composer) | about / thread
 */
function ChannelView() {
  const router = useRouter();
  const params = useSearchParams();
  const workspaceId = params.get("workspaceId");
  const channelId = params.get("channelId");
  const linkedMessageId = params.get("messageId");
  const linkedThreadId = params.get("threadId");

  const [messages, setMessages] = useState<Message[]>([]);
  const [draft, setDraft] = useState("");
  const [draftBlocks, setDraftBlocks] = useState<BlocksV1 | null>(null);
  const [threadDraft, setThreadDraft] = useState("");
  const [threadDraftBlocks, setThreadDraftBlocks] = useState<BlocksV1 | null>(null);
  const [draftHydratedFor, setDraftHydratedFor] = useState<string | null>(null);
  const [threadDraftHydratedFor, setThreadDraftHydratedFor] = useState<string | null>(null);
  const [selectedFiles, setSelectedFiles] = useState<ComposerAttachment[]>([]);
  const [threadSelectedFiles, setThreadSelectedFiles] = useState<ComposerAttachment[]>([]);
  const [uploadingFiles, setUploadingFiles] = useState(false);
  const [mentionQuery, setMentionQuery] = useState<string | null>(null);
  const [mentionStart, setMentionStart] = useState<number | null>(null);
  const [activeMentionIndex, setActiveMentionIndex] = useState(0);
  const [emojiPicker, setEmojiPicker] = useState<EmojiPickerState | null>(null);
  const [emojiQuery, setEmojiQuery] = useState("");
  const [threadLoading, setThreadLoading] = useState(false);
  const [threadError, setThreadError] = useState<string | null>(null);
  const [threadMessages, setThreadMessages] = useState<Message[]>([]);
  const [threadFollowing, setThreadFollowing] = useState(false);
  const [threadFollowBusy, setThreadFollowBusy] = useState(false);
  const [historyLoading, setHistoryLoading] = useState(false);
  const [historyReady, setHistoryReady] = useState(false);
  const [hasOlderMessages, setHasOlderMessages] = useState(true);
  const [historyError, setHistoryError] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [socketStatus, setSocketStatus] = useState("connecting…");
  const [pending, setPending] = useState<Array<{ clientMsgId: string; text: string; status: OutboxStatus }>>([]);
  const [typingUsers, setTypingUsers] = useState<string[]>([]);
  const [channels, setChannels] = useState<ConversationRow[]>([]);
  const [members, setMembers] = useState<MemberRow[]>([]);
  const [presence, setPresence] = useState<Record<string, { status: string }>>({});
  const [myUserId, setMyUserId] = useState<number | null>(null);
  const [threadRootId, setThreadRootId] = useState<string | number | null>(null);
  const [showCreateChannel, setShowCreateChannel] = useState(false);
  const [newChannelName, setNewChannelName] = useState("");
  const [showDmPicker, setShowDmPicker] = useState(false);
  const [showClosedDms, setShowClosedDms] = useState(false);
  const [showDmActions, setShowDmActions] = useState(false);
  const [showRenameGroupDm, setShowRenameGroupDm] = useState(false);
  const [showConvertGroupDm, setShowConvertGroupDm] = useState(false);
  const [groupDmName, setGroupDmName] = useState("");
  const [showAddMembers, setShowAddMembers] = useState(false);
  const [memberDialogTab, setMemberDialogTab] = useState<"add" | "manage">("add");
  const [memberSearch, setMemberSearch] = useState("");
  const [channelMembers, setChannelMembers] = useState<MemberRow[]>([]);
  const [pendingAddIds, setPendingAddIds] = useState<Array<string | number>>([]);
  const [actionBusy, setActionBusy] = useState(false);
  const [roomTab, setRoomTab] = useState<RoomTab>("Messages");
  const [channelStarred, setChannelStarred] = useState(false);
  const [askMode, setAskMode] = useState(false);
  const [showAbout, setShowAbout] = useState(true);
  const [profileUserId, setProfileUserId] = useState<string | number | null>(null);
  const [about, setAbout] = useState<ChannelAbout | null>(null);
  const [aboutLoading, setAboutLoading] = useState(false);
  const [callPickerOpen, setCallPickerOpen] = useState(false);
  const [clipOpen, setClipOpen] = useState(false);
  const [decidedIds, setDecidedIds] = useState<Set<string>>(() => new Set());
  const [taskModal, setTaskModal] = useState<
    | { mode: "create"; message: Message }
    | { mode: "edit"; task: Task }
    | null
  >(null);
  /** messageId → task created from that message (this channel). */
  const [tasksBySourceMessageId, setTasksBySourceMessageId] = useState<Map<string, Task>>(() => new Map());
  const [forwardMessageTarget, setForwardMessageTarget] = useState<Message | null>(null);
  const [forwardBusy, setForwardBusy] = useState(false);
  const [forwardError, setForwardError] = useState<string | null>(null);
  const [actionNotice, setActionNotice] = useState<string | null>(null);
  const [actionMenuMsgId, setActionMenuMsgId] = useState<string | null>(null);
  const [threadActionMenuMsgId, setThreadActionMenuMsgId] = useState<string | null>(null);
  const [editingMessage, setEditingMessage] = useState<EditingMessageState | null>(null);
  const [deleteMessageTarget, setDeleteMessageTarget] = useState<DeleteMessageState | null>(null);
  const [messageMutationBusy, setMessageMutationBusy] = useState(false);
  const [messageMutationError, setMessageMutationError] = useState<string | null>(null);
  const [savedMessageIds, setSavedMessageIds] = useState<Set<string>>(() => new Set());
  const [unreadAssignedCount, setUnreadAssignedCount] = useState(0);
  const [activityUnreadCount, setActivityUnreadCount] = useState(0);
  const [taskBusy, setTaskBusy] = useState(false);
  const [taskError, setTaskError] = useState<string | null>(null);
  const [activeCall, setActiveCall] = useState<Call | null>(null);
  const [channelCalls, setChannelCalls] = useState<Call[]>([]);
  const [callBusy, setCallBusy] = useState(false);
  const [callError, setCallError] = useState<string | null>(null);
  const [missedCallCount, setMissedCallCount] = useState(0);
  const chatUnread = useUnreadTotal(workspaceId);
  useClearUnreadOnView(workspaceId, channelId);

  const storeRef = useRef<MessageStore | null>(null);
  const syncRef = useRef<ChannelSync | null>(null);
  const outboxRef = useRef<Outbox | null>(null);
  const socketRef = useRef<Socket | null>(null);
  const typingTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
  const membersRef = useRef<MemberRow[]>([]);
  const lastTypingEmitRef = useRef(0);
  const messagesRef = useRef<HTMLDivElement | null>(null);
  const bottomRef = useRef<HTMLDivElement | null>(null);
  const historyLoadingRef = useRef(false);
  const historyAnchorRef = useRef<{ messageId: string; viewportTop: number } | null>(null);
  const initialScrollDoneRef = useRef(false);
  const stickToBottomRef = useRef(true);
  const threadRootIdRef = useRef<string | number | null>(null);
  const pendingThreadSummariesRef = useRef(
    new Map<string, { rootId: string | number; minimumReplyCount: number }>(),
  );
  const composerInputRef = useRef<RichTextEditorHandle | null>(null);
  const threadInputRef = useRef<RichTextEditorHandle | null>(null);
  const fileInputRef = useRef<HTMLInputElement | null>(null);
  const threadFileInputRef = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    getCurrentUserId().then(setMyUserId);
  }, []);

  useMemberStatusRefresh(workspaceId, () => {
    if (!workspaceId) return;
    void api.members(workspaceId).then((res) => {
      const next = res.members as MemberRow[];
      membersRef.current = next;
      setMembers(next);
    }).catch(() => undefined);
    if (channelId) {
      void api.listChannelMembers(workspaceId, channelId)
        .then((res) => setChannelMembers(res.members as MemberRow[]))
        .catch(() => undefined);
    }
  });

  useEffect(() => {
    if (!workspaceId) return;
    let cancelled = false;
    let refreshing = false;
    const refresh = () => {
      if (refreshing) return;
      refreshing = true;
      void (async () => {
        const legacy = getSavedMessages(workspaceId);
        await Promise.all(legacy.map(async (item) => {
          try {
            await api.saveItem(workspaceId, item.id);
            removeLegacySavedMessage(workspaceId, item.id);
          } catch {
            // Keep inaccessible/failed legacy rows locally so a transient
            // outage never destroys the only remaining copy.
          }
        }));
        const res = await api.savedItems(workspaceId);
        if (!cancelled) setSavedMessageIds(new Set(res.items.map((item) => String(item.id))));
      })().catch(() => undefined).finally(() => { refreshing = false; });
    };
    const onSavedUpdated = (event: Event) => {
      const changedWorkspaceId = (event as CustomEvent<{ workspaceId?: string | number }>).detail?.workspaceId;
      if (changedWorkspaceId == null || String(changedWorkspaceId) === workspaceId) refresh();
    };
    refresh();
    window.addEventListener("slackwsh:saved-updated", onSavedUpdated);
    return () => {
      cancelled = true;
      window.removeEventListener("slackwsh:saved-updated", onSavedUpdated);
    };
  }, [workspaceId]);

  useEffect(() => {
    if (!workspaceId || !channelId) return;
    let cancelled = false;
    historyLoadingRef.current = false;
    historyAnchorRef.current = null;
    initialScrollDoneRef.current = false;
    stickToBottomRef.current = true;
    setHistoryReady(false);
    setHistoryLoading(false);
    setHasOlderMessages(true);
    setHistoryError(null);
    setMessages([]);
    setChannelCalls([]);
    const context = `${workspaceId}:${channelId}`;
    setDraft("");
    setDraftBlocks(null);
    setDraftHydratedFor(null);
    void api.drafts(workspaceId).then((res) => {
      if (cancelled) return;
      const existing = res.drafts.find(
        (item) => String(item.channelId) === channelId && item.threadRootMessageId == null,
      );
      setDraft(existing?.text ?? "");
      setDraftBlocks(existing?.blocks ?? null);
      setDraftHydratedFor(context);
    }).catch(() => {
      if (!cancelled) setDraftHydratedFor(context);
    });
    return () => { cancelled = true; };
  }, [workspaceId, channelId]);

  useEffect(() => {
    if (!workspaceId || !channelId) return;
    const context = `${workspaceId}:${channelId}`;
    if (draftHydratedFor !== context) return;
    const timer = window.setTimeout(() => {
      if (draft.trim()) void api.upsertDraft(workspaceId, { channelId, text: draft, blocks: draftBlocks ?? undefined }).catch(() => undefined);
      else void api.deleteDraft(workspaceId, { channelId }).catch(() => undefined);
    }, 700);
    return () => window.clearTimeout(timer);
  }, [workspaceId, channelId, draft, draftBlocks, draftHydratedFor]);

  useEffect(() => {
    if (!workspaceId || !channelId || threadRootId == null) {
      setThreadDraft("");
      setThreadDraftBlocks(null);
      setThreadDraftHydratedFor(null);
      return;
    }
    let cancelled = false;
    const rootId = String(threadRootId);
    const context = `${workspaceId}:${channelId}:${rootId}`;
    setThreadDraft("");
    setThreadDraftBlocks(null);
    setThreadDraftHydratedFor(null);
    void api.drafts(workspaceId).then((res) => {
      if (cancelled) return;
      const existing = res.drafts.find(
        (item) => String(item.channelId) === channelId && String(item.threadRootMessageId ?? "") === rootId,
      );
      setThreadDraft(existing?.text ?? "");
      setThreadDraftBlocks(existing?.blocks ?? null);
      setThreadDraftHydratedFor(context);
    }).catch(() => {
      if (!cancelled) setThreadDraftHydratedFor(context);
    });
    return () => { cancelled = true; };
  }, [workspaceId, channelId, threadRootId]);

  useEffect(() => {
    if (!workspaceId || !channelId || threadRootId == null) return;
    const rootId = String(threadRootId);
    const context = `${workspaceId}:${channelId}:${rootId}`;
    if (threadDraftHydratedFor !== context) return;
    const timer = window.setTimeout(() => {
      const input = { channelId, threadRootMessageId: rootId };
      if (threadDraft.trim()) void api.upsertDraft(workspaceId, { ...input, text: threadDraft, blocks: threadDraftBlocks ?? undefined }).catch(() => undefined);
      else void api.deleteDraft(workspaceId, input).catch(() => undefined);
    }, 700);
    return () => window.clearTimeout(timer);
  }, [workspaceId, channelId, threadRootId, threadDraft, threadDraftBlocks, threadDraftHydratedFor]);

  useEffect(() => {
    if (!workspaceId || !channelId) return;
    const onDraftUpdated = (event: Event) => {
      const detail = (event as CustomEvent<{
        workspaceId?: string | number;
        channelId?: string | number;
        threadRootMessageId?: string | number | null;
        text?: string;
        blocks?: BlocksV1 | null;
        deleted?: boolean;
      }>).detail;
      if (String(detail?.workspaceId ?? "") !== workspaceId || String(detail?.channelId ?? "") !== channelId) return;
      if (detail.threadRootMessageId == null) {
        setDraft(detail.deleted ? "" : (detail.text ?? ""));
        setDraftBlocks(detail.deleted ? null : (detail.blocks ?? null));
      } else if (threadRootId != null && String(detail.threadRootMessageId) === String(threadRootId)) {
        setThreadDraft(detail.deleted ? "" : (detail.text ?? ""));
        setThreadDraftBlocks(detail.deleted ? null : (detail.blocks ?? null));
      }
    };
    window.addEventListener("slackwsh:draft-updated", onDraftUpdated);
    return () => window.removeEventListener("slackwsh:draft-updated", onDraftUpdated);
  }, [workspaceId, channelId, threadRootId]);

  useEffect(() => {
    threadRootIdRef.current = threadRootId;
    if (threadRootId == null) setThreadMessages([]);
  }, [threadRootId]);

  useEffect(() => {
    const requestedRootId = params.get("threadRootId");
    if (!requestedRootId) return;
    threadRootIdRef.current = requestedRootId;
    setThreadRootId(requestedRootId);
    setShowAbout(false);
  }, [params]);

  useEffect(() => {
    if (!workspaceId || !channelId || threadRootId == null) {
      setThreadFollowing(false);
      return;
    }
    let cancelled = false;
    const applyStatus = (status?: { workspaceId: string | number; channelId: string | number; rootMessageId: string | number; following: boolean }) => {
      if (
        !status ||
        String(status.workspaceId) !== workspaceId ||
        String(status.channelId) !== channelId ||
        String(status.rootMessageId) !== String(threadRootId)
      ) return;
      setThreadFollowing(status.following);
    };
    void api.threadStatus(workspaceId, channelId, threadRootId).then((res) => {
      if (!cancelled) applyStatus(res.status);
    }).catch(() => undefined);
    const onUpdated = (event: Event) => {
      const detail = (event as CustomEvent<{ status?: Parameters<typeof applyStatus>[0] }>).detail;
      applyStatus(detail?.status);
    };
    window.addEventListener("slackwsh:thread-subscription-updated", onUpdated);
    return () => {
      cancelled = true;
      window.removeEventListener("slackwsh:thread-subscription-updated", onUpdated);
    };
  }, [workspaceId, channelId, threadRootId]);

  useEffect(() => {
    if (params.get("addMembers") === "1") {
      setMemberDialogTab("add");
      setMemberSearch("");
      setShowAddMembers(true);
    }
  }, [params]);

  /**
   * Hydrates the About panel (description, pins, apps, my own notification
   * prefs). Refetched rather than patched in place on every change — the
   * payload is small and four independent sub-resources would otherwise each
   * need their own merge path. DMs have no About panel, so they skip it.
   */
  const refreshAbout = useCallback(async () => {
    if (!workspaceId || !channelId) return;
    try {
      const next = await api.channelAbout(workspaceId, channelId);
      setAbout(next);
    } catch {
      setAbout(null);
    }
  }, [workspaceId, channelId]);

  useEffect(() => {
    if (!workspaceId || !channelId) return;
    let cancelled = false;
    setAbout(null);
    setAboutLoading(true);
    api
      .channelAbout(workspaceId, channelId)
      .then((res) => {
        if (!cancelled) setAbout(res);
      })
      .catch(() => {
        if (!cancelled) setAbout(null);
      })
      .finally(() => {
        if (!cancelled) setAboutLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [workspaceId, channelId]);

  useEffect(() => {
    if (!workspaceId || !channelId) return;
    let cancelled = false;
    api
      .listChannelMembers(workspaceId, channelId)
      .then((res) => {
        if (!cancelled) setChannelMembers(res.members as MemberRow[]);
      })
      .catch(() => {
        if (!cancelled) setChannelMembers([]);
      });
    return () => {
      cancelled = true;
    };
  }, [workspaceId, channelId]);

  useEffect(() => {
    if (!workspaceId || !channelId) return;
    let cancelled = false;

    api
      .listChannels(workspaceId)
      .then((res) => {
        if (!cancelled) {
          const rows = normalizeChannelRows(res.channels);
          setChannels(rows);
          hydrateUnreadCounts(workspaceId, rows);
        }
      })
      .catch(() => undefined);
    api
      .members(workspaceId)
      .then((res) => {
        if (!cancelled) {
          membersRef.current = res.members as MemberRow[];
          setMembers(res.members as MemberRow[]);
        }
      })
      .catch(() => undefined);
    api
      .presenceSnapshot(workspaceId)
      .then((res) => {
        if (!cancelled) setPresence(res.presence);
      })
      .catch(() => undefined);

    // History deliberately grows for the lifetime of the open conversation.
    // The MessageStore default is a 500-message realtime cache, which would
    // otherwise evict each newly prepended old page as soon as it arrived.
    const store = new MessageStore({ messagesPerChannel: Number.POSITIVE_INFINITY });
    const sync = new ChannelSync(
      channelId,
      store,
      (opts) => api.scrollback(workspaceId, channelId, opts).then((r) => r.messages as Message[]),
      0,
    );
    storeRef.current = store;
    syncRef.current = sync;
    outboxRef.current = new Outbox(
      new IndexedDbOutboxStorage(),
      async (entry) => {
        const sent = (await api.sendMessage(workspaceId, channelId, {
          clientMsgId: entry.clientMsgId,
          text: entry.text,
          blocks: entry.blocks,
          parentId: entry.parentId,
          isBroadcast: entry.isBroadcast,
        })) as Message;
        await sync.onLiveMessage(sent);
        if (!cancelled) {
          if (entry.parentId != null && String(entry.parentId) === String(threadRootIdRef.current)) {
            setThreadMessages((current) => {
              const byId = new Map(current.map((message) => [String(message.id), message]));
              byId.set(String(sent.id), sent);
              return Array.from(byId.values()).sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
            });
          }
          const pendingSummary = pendingThreadSummariesRef.current.get(entry.clientMsgId);
          if (pendingSummary) {
            applyMinimumThreadSummary(
              pendingSummary.rootId,
              pendingSummary.minimumReplyCount,
              sent.createdAt,
            );
            pendingThreadSummariesRef.current.delete(entry.clientMsgId);
          }
          setMessages(sync.getMessages());
        }
      },
      (clientMsgId, status) => {
        if (cancelled) return;
        setPending((prev) =>
          status === "sent"
            ? prev.filter((p) => p.clientMsgId !== clientMsgId)
            : prev.map((p) => (p.clientMsgId === clientMsgId ? { ...p, status } : p)),
        );
      },
    );

    sync
      .start()
      .then(() => {
        if (cancelled) return;
        const loaded = sync.getMessages();
        setMessages(loaded);
        setHistoryReady(true);
        setHasOlderMessages(sync.hasOlderMessages());
        const latestSeq = loaded.reduce((max, m) => Math.max(max, m.seq ?? 0), 0);
        if (latestSeq > 0) {
          void api.markChannelRead(workspaceId, channelId, latestSeq).catch(() => undefined);
        }
      })
      .catch((err) => {
        if (!cancelled) setHistoryReady(true);
        if (err instanceof ApiError && err.status === 401) void forceLogin(router);
        else setError(String(err));
      });

    outboxRef.current.replay().catch(() => undefined);

    getValidAccessToken().then((accessToken) => {
      if (!accessToken || cancelled) return;
      const socket = io(gatewayUrl(), gatewaySocketOptions((cb) => getValidAccessToken().then((token) => cb({ accessToken: token }))));
      socketRef.current = socket;
      socket.on("connect", () => {
        setSocketStatus("live");
        socket.emit("workspace:join", { workspaceId });
        socket.emit("channel:join", { workspaceId, channelId });
      });
      socket.on("connect_error", () => {
        setSocketStatus("offline — sending over HTTP");
      });
      socket.on("disconnect", () => {
        setSocketStatus("reconnecting…");
      });
      socket.on("message:created", async (payload: { message: Message; workspaceId?: string | number; channelId?: string | number }) => {
        const targetChannelId = payload.channelId ?? payload.message.channelId;
        if (String(targetChannelId) !== String(channelId)) return;

        await sync.onLiveMessage(payload.message);
        if (!cancelled) {
          const next = sync.getMessages();
          if (payload.message.parentId != null && String(payload.message.parentId) === String(threadRootIdRef.current)) {
            setThreadMessages((current) => {
              const byId = new Map(current.map((message) => [String(message.id), message]));
              byId.set(String(payload.message.id), payload.message);
              return Array.from(byId.values()).sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
            });
          }
          setMessages(next);
          if (!document.hidden && payload.message.seq) {
            void api.markChannelRead(workspaceId, channelId, payload.message.seq).catch(() => undefined);
          }
        }
      });

      const onBridge = async (event: Event) => {
        const detail = (event as CustomEvent).detail as
          | { message: Message; workspaceId?: string | number | null; channelId: string | number }
          | undefined;
        if (!detail || cancelled) return;
        if (String(detail.channelId) !== String(channelId)) {
          if (detail.workspaceId == null || String(detail.workspaceId) === String(workspaceId)) {
            api
              .listChannels(workspaceId)
              .then((res) => {
                if (!cancelled) setChannels(normalizeChannelRows(res.channels));
              })
              .catch(() => undefined);
          }
          return;
        }
        await sync.onLiveMessage(detail.message);
        if (!cancelled) {
          if (detail.message.parentId != null && String(detail.message.parentId) === String(threadRootIdRef.current)) {
            setThreadMessages((current) => {
              const byId = new Map(current.map((message) => [String(message.id), message]));
              byId.set(String(detail.message.id), detail.message);
              return Array.from(byId.values()).sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
            });
          }
          setMessages(sync.getMessages());
        }
      };
      window.addEventListener("slackwsh:message", onBridge);

      socket.on("message:edited", (payload: { message?: Message }) => {
        const updated = payload.message;
        if (!updated || cancelled || String(updated.channelId) !== String(channelId)) return;
        applyEditedMessage(updated);
      });

      socket.on(
        "message:deleted",
        (payload: { channelId?: string | number; messageId?: string | number; seq?: number }) => {
          if (
            cancelled ||
            payload.messageId == null ||
            typeof payload.seq !== "number" ||
            String(payload.channelId ?? "") !== String(channelId)
          ) return;
          applyDeletedMessage(payload.messageId, payload.seq);
        },
      );

      socket.on("typing:changed", (payload: { channelId: string | number; userId: string | number; isTyping: boolean }) => {
        if (String(payload.channelId) !== String(channelId) || cancelled) return;
        // Don't show your own typing indicator on your screen.
        void getCurrentUserId().then((me) => {
          if (!me || String(payload.userId) === String(me) || cancelled) return;
          const typingId = String(payload.userId);
          setTypingUsers((prev) => (prev.includes(typingId) ? prev : [...prev, typingId]));
          const existing = typingTimeoutsRef.current.get(typingId);
          if (existing) clearTimeout(existing);
          typingTimeoutsRef.current.set(
            typingId,
            setTimeout(() => {
              if (!cancelled) setTypingUsers((prev) => prev.filter((id) => id !== typingId));
              typingTimeoutsRef.current.delete(typingId);
            }, 4000),
          );
        });
      });
      // Pins and room-detail edits are other people's writes to the same
      // About panel — refetch it rather than reconstruct the change locally.
      socket.on("pin:changed", (payload: { channelId: string | number }) => {
        if (String(payload.channelId) !== String(channelId) || cancelled) return;
        void refreshAbout();
      });
      socket.on("channel:updated", (payload: { channel?: { id: string | number } }) => {
        if (cancelled || String(payload?.channel?.id ?? "") !== String(channelId)) return;
        void refreshAbout();
        api
          .listChannels(workspaceId)
          .then((res) => {
            if (!cancelled) setChannels(normalizeChannelRows(res.channels));
          })
          .catch(() => undefined);
      });
      socket.on(
        "read:updated",
        (payload: { channelMember?: { channelId: string | number; userId: string | number; mentionCount?: number; lastReadSeq?: number } }) => {
          const member = payload.channelMember;
          if (!member || cancelled || String(member.userId) !== String(myUserId ?? "")) return;
          clearUnread(workspaceId, member.channelId);
          setChannels((current) =>
            current.map((row) =>
              String(row.channel.id) === String(member.channelId)
                ? {
                    ...row,
                    member: {
                      ...(row.member ?? {}),
                      lastReadSeq: member.lastReadSeq,
                      mentionCount: member.mentionCount ?? 0,
                    },
                  }
                : row,
            ),
          );
        },
      );
      socket.on(
        "reaction:changed",
        (payload: { reaction?: { messageId: string | number; userId: string | number; emoji: string }; op?: "add" | "remove" }) => {
          const reaction = payload.reaction;
          if (!reaction || !payload.op || cancelled || String(reaction.userId) === String(myUserId ?? "")) return;
          updateReactionState(reaction.messageId, reaction.emoji, reaction.userId, payload.op);
        },
      );
      socket.on("presence:changed", (payload: { userId: string | number; status: string; lastSeen: string }) => {
        if (cancelled) return;
        setPresence((prev) => ({ ...prev, [String(payload.userId)]: { status: payload.status } }));
      });

      // Stash remover on the socket instance so the effect cleanup can run it.
      (socket as Socket & { __bridgeCleanup?: () => void }).__bridgeCleanup = () => {
        window.removeEventListener("slackwsh:message", onBridge);
      };
    });

    return () => {
      cancelled = true;
      const sock = socketRef.current as (Socket & { __bridgeCleanup?: () => void }) | null;
      sock?.__bridgeCleanup?.();
      sock?.disconnect();
      for (const timeout of typingTimeoutsRef.current.values()) clearTimeout(timeout);
    };
  }, [workspaceId, channelId, myUserId]);

  useLayoutEffect(() => {
    const scroller = messagesRef.current;
    if (!scroller) return;

    const historyAnchor = historyAnchorRef.current;
    if (historyAnchor) {
      const anchor = document.getElementById(`msg-${historyAnchor.messageId}`);
      if (anchor) {
        scroller.scrollTop += anchor.getBoundingClientRect().top - historyAnchor.viewportTop;
      }
      historyAnchorRef.current = null;
      return;
    }

    if (!initialScrollDoneRef.current) {
      scroller.scrollTop = scroller.scrollHeight;
      initialScrollDoneRef.current = true;
      return;
    }

    if (stickToBottomRef.current) scroller.scrollTop = scroller.scrollHeight;
  }, [messages.length, pending.length]);

  async function loadOlderMessages() {
    const sync = syncRef.current;
    const scroller = messagesRef.current;
    if (!sync || !scroller || historyLoadingRef.current || !hasOlderMessages) return;

    historyLoadingRef.current = true;
    setHistoryLoading(true);
    setHistoryError(null);

    const oldestVisibleRoot = sync.getMessages().find((message) => message.parentId == null);
    if (oldestVisibleRoot) {
      const anchor = document.getElementById(`msg-${oldestVisibleRoot.id}`);
      if (anchor) {
        historyAnchorRef.current = {
          messageId: String(oldestVisibleRoot.id),
          viewportTop: anchor.getBoundingClientRect().top,
        };
      }
    }

    try {
      const older = await sync.loadOlder();
      setHasOlderMessages(sync.hasOlderMessages());
      if (older.length > 0) {
        setMessages(sync.getMessages());
      } else {
        historyAnchorRef.current = null;
      }
    } catch (err) {
      historyAnchorRef.current = null;
      setHistoryError(err instanceof Error ? err.message : "Could not load older messages.");
    } finally {
      historyLoadingRef.current = false;
      setHistoryLoading(false);
    }
  }

  function onMessagesScroll(event: ReactUIEvent<HTMLDivElement>) {
    const scroller = event.currentTarget;
    stickToBottomRef.current = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 120;
    if (initialScrollDoneRef.current && scroller.scrollTop < 120) {
      void loadOlderMessages();
    }
  }

  function updateMentionState(value: string, cursor: number) {
    const trigger = findMentionTrigger(value, cursor);
    if (!trigger) {
      setMentionQuery(null);
      setMentionStart(null);
      setActiveMentionIndex(0);
      return;
    }
    setMentionQuery(trigger.query);
    setMentionStart(trigger.start);
    setActiveMentionIndex(0);
  }

  function onDraftChange(value: string, blocks: BlocksV1, cursor: number) {
    setDraft(value);
    setDraftBlocks(blocks);
    updateMentionState(value, cursor);
    const now = Date.now();
    if (now - lastTypingEmitRef.current < 1500) return; // light client-side throttle on top of the server's
    lastTypingEmitRef.current = now;
    socketRef.current?.emit("typing:start", { workspaceId, channelId });
  }

  async function retryFailed() {
    await outboxRef.current?.replay();
  }

  async function submitMessage(text: string, blocks: BlocksV1, parentId?: string | number) {
    if (!text.trim() || !outboxRef.current || !channelId) return;
    const clientMsgId = crypto.randomUUID();
    // Rendered immediately, before the network attempt — the outbox already
    // persists first (§5.3); this just makes that visible instead of the
    // UI going silent until the server acks.
    if (parentId == null) setPending((prev) => [...prev, { clientMsgId, text, status: "pending" }]);
    else {
      pendingThreadSummariesRef.current.set(clientMsgId, {
        rootId: parentId,
        minimumReplyCount: currentThreadReplyCount(parentId) + 1,
      });
    }
    try {
      await outboxRef.current.enqueue({
        clientMsgId,
        channelId,
        text,
        blocks,
        parentId: parentId == null ? null : String(parentId),
        createdAt: new Date().toISOString(),
      });
      setMessages(syncRef.current!.getMessages());
    } catch (err) {
      pendingThreadSummariesRef.current.delete(clientMsgId);
      setError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    }
  }

  async function submitMessageWithAttachments(text: string, blocks: BlocksV1, attachments: FileAttachment[], parentId?: string | number) {
    if (!workspaceId || !channelId || attachments.length === 0) return;
    const bodyText = text.trim();
    const clientMsgId = crypto.randomUUID();
    const minimumReplyCount = parentId == null ? null : currentThreadReplyCount(parentId) + 1;
    try {
      const sent = (await api.sendMessage(workspaceId, channelId, {
        clientMsgId,
        text: bodyText,
        blocks: blocksWithAttachments(bodyText, attachments, blocks),
        parentId: parentId == null ? null : String(parentId),
      })) as Message;
      await syncRef.current?.onLiveMessage(sent);
      if (parentId != null && String(parentId) === String(threadRootIdRef.current)) {
        setThreadMessages((current) => {
          const byId = new Map(current.map((message) => [String(message.id), message]));
          byId.set(String(sent.id), sent);
          return Array.from(byId.values()).sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
        });
      }
      if (parentId != null && minimumReplyCount != null) {
        applyMinimumThreadSummary(parentId, minimumReplyCount, sent.createdAt);
      }
      setMessages(syncRef.current?.getMessages() ?? messages);
    } catch (err) {
      setError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
      throw err;
    }
  }

  function onFilesPicked(files: FileList | File[] | null, surface: MessageSurface = "main") {
    if (!files || !workspaceId || !channelId) return;
    setError(null);
    const current = surface === "thread" ? threadSelectedFiles : selectedFiles;
    const setCurrent = surface === "thread" ? setThreadSelectedFiles : setSelectedFiles;
    const picked = Array.from(files).slice(0, Math.max(0, 10 - current.length));
    const next = picked.map((file) => ({
      localId: crypto.randomUUID(),
      name: file.name,
      type: file.type || "application/octet-stream",
      size: file.size,
      status: "uploading" as const,
    }));
    setCurrent((prev) => [...prev, ...next]);
    const input = surface === "thread" ? threadFileInputRef.current : fileInputRef.current;
    if (input) input.value = "";

    for (const [index, file] of picked.entries()) {
      const localId = next[index]!.localId;
      void api
        .uploadFile(workspaceId, channelId, file)
        .then((uploaded) => {
          setCurrent((prev) =>
            prev.map((item) => (item.localId === localId ? { ...item, status: "ready", uploaded } : item)),
          );
        })
        .catch((err) => {
          const message = err instanceof ApiError ? JSON.stringify(err.body) : String(err);
          setCurrent((prev) =>
            prev.map((item) => (item.localId === localId ? { ...item, status: "failed", error: message } : item)),
          );
          setError(message);
        });
    }
  }

  function removeSelectedFile(index: number, surface: MessageSurface = "main") {
    const current = surface === "thread" ? threadSelectedFiles : selectedFiles;
    const file = current[index];
    (surface === "thread" ? setThreadSelectedFiles : setSelectedFiles)((prev) => prev.filter((_, i) => i !== index));
    if (file?.uploaded && workspaceId && channelId) {
      void api.deleteFile(workspaceId, channelId, file.uploaded.id).catch(() => undefined);
    }
  }

  function acceptDroppedFiles(event: React.DragEvent, surface: MessageSurface) {
    event.preventDefault();
    if (event.dataTransfer.files.length > 0) onFilesPicked(event.dataTransfer.files, surface);
  }

  function acceptPastedFiles(event: React.ClipboardEvent, surface: MessageSurface) {
    const files = Array.from(event.clipboardData.items)
      .filter((item) => item.kind === "file")
      .map((item) => item.getAsFile())
      .filter((file): file is File => Boolean(file));
    if (files.length === 0) return;
    event.preventDefault();
    onFilesPicked(files, surface);
  }

  function renderAttachments(message: Message, surface: MessageSurface = "main") {
    const attachments = messageAttachments(message);
    if (attachments.length === 0 || message.deletedAt || !workspaceId || !channelId) return null;
    const myRole = members.find((row) => String(row.user.id) === String(myUserId))?.member.role;
    const canDelete = String(message.authorId) === String(myUserId) || myRole === "owner" || myRole === "admin";
    const threadViewer = !viewingDm;
    const galleryGroupId = String(message.id);
    return (
      <AttachmentCollection
        files={attachments}
        workspaceId={workspaceId}
        channelId={channelId}
        canDelete={canDelete}
        expanded={surface === "thread"}
        galleryGroupId={galleryGroupId}
        useThreadViewer={threadViewer}
        delegateGallery={threadViewer && surface === "thread" && message.parentId == null}
        onOpenThread={threadViewer ? () => {
          setProfileUserId(null);
          setShowAbout(false);
          setThreadRootId(message.parentId ?? message.id);
        } : undefined}
        onError={setError}
      />
    );
  }

  async function send(e: React.FormEvent) {
    e.preventDefault();
    if (!workspaceId || !channelId) return;
    if (askMode && !draft.trim().endsWith("?")) composerInputRef.current?.insertText("?");
    const richValue = composerInputRef.current?.getValue() ?? {
      text: draft,
      blocks: draftBlocks ?? { v: 1 as const, doc: { type: "doc", content: draft ? [{ type: "paragraph", content: [{ type: "text", text: draft }] }] : [] } },
    };
    let text = richValue.text.trim();
    const blocks = richValue.blocks;
    if (!text && selectedFiles.length === 0) return;
    if (selectedFiles.length > 0) {
      setUploadingFiles(true);
      setError(null);
      try {
        if (selectedFiles.some((file) => file.status === "uploading")) throw new Error("File is still uploading");
        const failed = selectedFiles.find((file) => file.status === "failed");
        if (failed) throw new Error(failed.error ?? "File upload failed");
        const uploaded = selectedFiles.map((file) => file.uploaded).filter((file): file is FileAttachment => Boolean(file));
        if (uploaded.length === 0) throw new Error("No files uploaded");
        await submitMessageWithAttachments(text, blocks, uploaded);
        composerInputRef.current?.clear();
        setDraft("");
        setDraftBlocks(null);
        void api.deleteDraft(workspaceId, { channelId }).catch(() => undefined);
        setSelectedFiles([]);
        setAskMode(false);
      } catch (err) {
        setError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
      } finally {
        setUploadingFiles(false);
      }
    } else {
      await submitMessage(text, blocks);
      composerInputRef.current?.clear();
      setDraft("");
      setDraftBlocks(null);
      setAskMode(false);
      if (workspaceId && channelId) void api.deleteDraft(workspaceId, { channelId }).catch(() => undefined);
    }
  }

  async function sendThreadReply(e: React.FormEvent) {
    e.preventDefault();
    if (!workspaceId || !channelId || !threadRootId) return;
    const richValue = threadInputRef.current?.getValue();
    const text = (richValue?.text ?? threadDraft).trim();
    const blocks = richValue?.blocks ?? threadDraftBlocks ?? { v: 1, doc: { type: "doc", content: [] } };
    if (!text && threadSelectedFiles.length === 0) return;
    if (threadSelectedFiles.length > 0) {
      if (threadSelectedFiles.some((file) => file.status === "uploading")) return;
      const failed = threadSelectedFiles.find((file) => file.status === "failed");
      if (failed) {
        setThreadError(failed.error ?? "File upload failed");
        return;
      }
      const uploaded = threadSelectedFiles.map((file) => file.uploaded).filter((file): file is FileAttachment => Boolean(file));
      await submitMessageWithAttachments(text, blocks, uploaded, threadRootId);
      threadInputRef.current?.clear();
      setThreadSelectedFiles([]);
    } else {
      await submitMessage(text, blocks, threadRootId);
      threadInputRef.current?.clear();
    }
    setThreadFollowing(true);
    setThreadDraft("");
    setThreadDraftBlocks(null);
    void api.deleteDraft(workspaceId, { channelId, threadRootMessageId: threadRootId }).catch(() => undefined);
  }

  async function toggleThreadFollowing() {
    if (!workspaceId || !channelId || threadRootId == null || threadFollowBusy) return;
    const next = !threadFollowing;
    setThreadFollowing(next);
    setThreadFollowBusy(true);
    setThreadError(null);
    try {
      const result = await api.setThreadFollowing(workspaceId, channelId, threadRootId, next);
      setThreadFollowing(result.status.following);
    } catch (err) {
      setThreadFollowing(!next);
      setThreadError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setThreadFollowBusy(false);
    }
  }

  async function createChannel(e: React.FormEvent) {
    e.preventDefault();
    if (!workspaceId || !newChannelName.trim()) return;
    setActionBusy(true);
    try {
      const created = await api.createChannel(workspaceId, { name: newChannelName.trim(), type: "public" });
      setNewChannelName("");
      setShowCreateChannel(false);
      router.push(`${channelPath(workspaceId, String(created.id))}&addMembers=1`);
    } catch (err) {
      setError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setActionBusy(false);
    }
  }

  async function addSelectedMembers() {
    if (!workspaceId || !channelId || pendingAddIds.length === 0) return;
    setActionBusy(true);
    setError(null);
    try {
      const result = await api.addChannelMembers(
        workspaceId,
        channelId,
        pendingAddIds.map((id) => String(id)),
      );
      const refreshed = await api.listChannelMembers(workspaceId, channelId);
      setChannelMembers(refreshed.members as MemberRow[]);
      setPendingAddIds([]);
      setShowAddMembers(false);
      if (result.added.length === 0) setError("Those people are already in this channel.");
    } catch (err) {
      setError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setActionBusy(false);
    }
  }

  // Dismiss the message action menu the way a menu is expected to close:
  // anywhere outside it, or Escape. The pointerdown listener is registered
  // only while a menu is open so idle rows cost nothing.
  useEffect(() => {
    if (!actionMenuMsgId) return;
    function onPointerDown(e: PointerEvent) {
      if (!(e.target as HTMLElement | null)?.closest(".msg-hover-actions")) setActionMenuMsgId(null);
    }
    function onKeyDown(e: KeyboardEvent) {
      if (e.key === "Escape") setActionMenuMsgId(null);
    }
    window.addEventListener("pointerdown", onPointerDown);
    window.addEventListener("keydown", onKeyDown);
    return () => {
      window.removeEventListener("pointerdown", onPointerDown);
      window.removeEventListener("keydown", onKeyDown);
    };
  }, [actionMenuMsgId]);

  // Tasks badge: unread assignment notifications, not open-todo count.
  useEffect(() => {
    if (!workspaceId) return;
    let cancelled = false;
    function refresh() {
      api
        .unreadAssignedTasks(workspaceId!)
        .then((res) => {
          if (!cancelled) setUnreadAssignedCount(res.unread);
        })
        .catch(() => undefined);
    }
    refresh();
    window.addEventListener("slackwsh:task", refresh);
    window.addEventListener("slackwsh:tasks-seen", refresh);
    return () => {
      cancelled = true;
      window.removeEventListener("slackwsh:task", refresh);
      window.removeEventListener("slackwsh:tasks-seen", refresh);
    };
  }, [workspaceId]);

  // Messages that already have a task: chip flips to "Task created" → open it.
  useEffect(() => {
    if (!workspaceId || !channelId) {
      setTasksBySourceMessageId(new Map());
      return;
    }
    let cancelled = false;
    function applyTasks(rows: Task[]) {
      const next = new Map<string, Task>();
      for (const task of rows) {
        if (task.sourceMessageId == null) continue;
        if (String(task.channelId) !== String(channelId)) continue;
        next.set(String(task.sourceMessageId), task);
      }
      setTasksBySourceMessageId(next);
    }
    function loadLinkedTasks() {
      api
        .listTasks(workspaceId!, { channelId: channelId!, limit: 200 })
        .then((res) => {
          if (!cancelled) applyTasks(res.tasks);
        })
        .catch(() => {
          if (!cancelled) setTasksBySourceMessageId(new Map());
        });
    }
    loadLinkedTasks();
    function onTaskEvent(e: Event) {
      const detail = (e as CustomEvent).detail as {
        type?: string;
        task?: Task;
        taskId?: number;
        workspaceId?: number;
      };
      if (!workspaceId || String(detail.workspaceId ?? detail.task?.workspaceId) !== String(workspaceId)) return;
      if (detail.type === "task:deleted" && detail.taskId != null) {
        setTasksBySourceMessageId((prev) => {
          const next = new Map(prev);
          for (const [messageId, task] of next) {
            if (String(task.id) === String(detail.taskId)) next.delete(messageId);
          }
          return next;
        });
        return;
      }
      if (detail.task && String(detail.task.channelId) === String(channelId) && detail.task.sourceMessageId != null) {
        setTasksBySourceMessageId((prev) => {
          const next = new Map(prev);
          next.set(String(detail.task!.sourceMessageId), detail.task!);
          return next;
        });
        return;
      }
      loadLinkedTasks();
    }
    window.addEventListener("slackwsh:task", onTaskEvent);
    return () => {
      cancelled = true;
      window.removeEventListener("slackwsh:task", onTaskEvent);
    };
  }, [workspaceId, channelId]);

  useEffect(() => {
    if (!workspaceId) return;
    let cancelled = false;
    function refreshActivityCount() {
      void api.mentionActivity(workspaceId!).then((res) => {
        if (!cancelled) setActivityUnreadCount(res.results.filter((hit) => !hit.readAt).length);
      }).catch(() => undefined);
    }
    const onActivityChanged = (event: Event) => {
      const changedWorkspaceId = (event as CustomEvent<{ workspaceId?: string | number }>).detail?.workspaceId;
      if (changedWorkspaceId != null && String(changedWorkspaceId) !== workspaceId) return;
      refreshActivityCount();
    };
    refreshActivityCount();
    window.addEventListener("slackwsh:message", onActivityChanged);
    window.addEventListener("slackwsh:activity-read-updated", onActivityChanged);
    return () => {
      cancelled = true;
      window.removeEventListener("slackwsh:message", onActivityChanged);
      window.removeEventListener("slackwsh:activity-read-updated", onActivityChanged);
    };
  }, [workspaceId]);

  useEffect(() => {
    if (!workspaceId) return;
    let cancelled = false;
    function refreshMissed() {
      api
        .unseenMissedCalls(workspaceId!)
        .then((res) => {
          if (!cancelled) setMissedCallCount(res.unseen);
        })
        .catch(() => undefined);
    }
    refreshMissed();
    window.addEventListener("slackwsh:call", refreshMissed);
    window.addEventListener("slackwsh:calls-seen", refreshMissed);
    return () => {
      cancelled = true;
      window.removeEventListener("slackwsh:call", refreshMissed);
      window.removeEventListener("slackwsh:calls-seen", refreshMissed);
    };
  }, [workspaceId]);

  useEffect(() => {
    if (!workspaceId || !channelId) return;
    let cancelled = false;
    const conversationId = String(channelId);
    const peerId = channels.find((row) => String(row.channel.id) === conversationId)?.dmPeer?.id ?? null;
    void (async () => {
      try {
        const [byChannel, byPeer] = await Promise.all([
          api.listCalls(workspaceId, { channelId: conversationId, limit: 100 }),
          peerId != null
            ? api.listCalls(workspaceId, { withUserId: String(peerId), limit: 100 })
            : Promise.resolve({ calls: [] as Call[] }),
        ]);
        if (cancelled) return;
        const merged = new Map<string, Call>();
        for (const call of [...byChannel.calls, ...byPeer.calls]) {
          if (callBelongsToConversation(call, conversationId, peerId)) merged.set(String(call.id), call);
        }
        setChannelCalls(Array.from(merged.values()));
      } catch {
        if (!cancelled) setChannelCalls([]);
      }
    })();
    return () => {
      cancelled = true;
    };
  }, [workspaceId, channelId, channels]);

  useEffect(() => {
    if (!workspaceId || !channelId) return;
    const conversationId = String(channelId);
    const peerId = channels.find((row) => String(row.channel.id) === conversationId)?.dmPeer?.id ?? null;
    function onCallEvent(e: Event) {
      const detail = (e as CustomEvent).detail as { call?: Call } | undefined;
      const call = detail?.call;
      if (!call || String(call.workspaceId) !== String(workspaceId)) return;
      if (callBelongsToConversation(call, conversationId, peerId)) {
        setChannelCalls((current) => upsertCall(current, call));
      }
      setActiveCall((current) => {
        if (!current || Number(current.id) !== Number(call.id)) return current;
        return call.status === "ended" || call.status === "missed" ? null : call;
      });
    }
    window.addEventListener("slackwsh:call", onCallEvent);
    return () => window.removeEventListener("slackwsh:call", onCallEvent);
  }, [workspaceId, channelId, channels]);

  async function submitTaskModal(values: TaskModalValues) {
    if (!workspaceId || !taskModal) return;
    setTaskBusy(true);
    setTaskError(null);
    try {
      if (taskModal.mode === "create") {
        if (!channelId) return;
        const task = await api.createTaskFromMessage(workspaceId, channelId, String(taskModal.message.id), {
          title: values.title,
          description: values.description || undefined,
          assigneeUserId: values.assigneeUserId,
          dueAt: values.dueAt,
        });
        setTasksBySourceMessageId((prev) => {
          const next = new Map(prev);
          if (task.sourceMessageId != null) next.set(String(task.sourceMessageId), task);
          return next;
        });
      } else {
        const original = taskModal.task;
        const patch: Parameters<typeof api.updateTask>[2] = {};
        if (values.title !== original.title) patch.title = values.title;
        if (values.description !== (original.description ?? "")) patch.description = values.description || null;
        if (values.dueAt !== original.dueAt) patch.dueAt = values.dueAt;
        if (Object.keys(patch).length > 0) await api.updateTask(workspaceId, String(original.id), patch);
        if (values.assigneeUserId !== (original.assigneeUserId != null ? String(original.assigneeUserId) : null)) {
          await api.assignTask(workspaceId, String(original.id), values.assigneeUserId);
        }
        if (values.status !== original.status) {
          await api.updateTaskStatus(workspaceId, String(original.id), values.status);
        }
        const refreshed = {
          ...original,
          title: values.title,
          description: values.description || null,
          dueAt: values.dueAt,
          assigneeUserId: values.assigneeUserId != null ? Number(values.assigneeUserId) : null,
          status: values.status,
        } as Task;
        setTasksBySourceMessageId((prev) => {
          const next = new Map(prev);
          if (refreshed.sourceMessageId != null) next.set(String(refreshed.sourceMessageId), refreshed);
          return next;
        });
      }
      setTaskModal(null);
    } catch (err) {
      setTaskError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setTaskBusy(false);
    }
  }

  async function deleteTaskFromModal() {
    if (!workspaceId || taskModal?.mode !== "edit") return;
    if (!window.confirm(`Delete “${taskModal.task.title}”? You can find it later under Deleted.`)) return;
    setTaskBusy(true);
    setTaskError(null);
    try {
      const taskId = taskModal.task.id;
      const sourceId = taskModal.task.sourceMessageId;
      await api.deleteTask(workspaceId, String(taskId));
      setTasksBySourceMessageId((prev) => {
        const next = new Map(prev);
        if (sourceId != null) next.delete(String(sourceId));
        return next;
      });
      setTaskModal(null);
    } catch (err) {
      setTaskError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setTaskBusy(false);
    }
  }

  async function startDmCall(kind: "audio" | "video") {
    if (!workspaceId || !channelId || !activeRow?.dmPeer || myUserId == null) return;
    setCallBusy(true);
    setCallError(null);
    try {
      const call = await api.startCall(workspaceId, {
        kind,
        inviteeUserIds: [activeRow.dmPeer.id],
        channelId,
        title: dmTitle(activeRow),
      });
      setActiveCall(call);
    } catch (err) {
      setCallError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setCallBusy(false);
    }
  }

  async function startChannelConnect() {
    if (!workspaceId || !channelId || viewingDm) return;
    const live = channelCalls.find((row) => (row.status === "active" || row.status === "ringing") && row.kind === "connect");
    setCallBusy(true);
    setCallError(null);
    try {
      const call = live
        ? await api.joinCall(workspaceId, String(live.id))
        : await api.startConnect(workspaceId, channelId);
      setActiveCall(call);
    } catch (err) {
      setCallError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setCallBusy(false);
    }
  }

  async function joinChannelCall(call: Call) {
    if (!workspaceId) return;
    const alreadyJoined =
      myUserId != null &&
      call.participants.some((participant) => String(participant.userId) === String(myUserId) && participant.state === "joined");
    if (alreadyJoined && (call.status === "ringing" || call.status === "active")) {
      setActiveCall(call);
      return;
    }
    setCallBusy(true);
    setCallError(null);
    try {
      const next = await api.joinCall(workspaceId, String(call.id));
      setActiveCall(next);
    } catch (err) {
      setCallError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setCallBusy(false);
    }
  }

  async function leaveActiveCall() {
    const call = activeCall;
    if (!workspaceId || !call) return;
    setActiveCall(null);
    try {
      await api.leaveCall(workspaceId, String(call.id));
    } catch {
      // Local overlay is already gone; a stale row will age out on refresh.
    }
  }

  async function endActiveCall() {
    const call = activeCall;
    if (!workspaceId || !call) return;
    setActiveCall(null);
    try {
      await api.endCall(workspaceId, String(call.id));
    } catch {
      // Same as leave — the UI should not stay up on a failed hangup.
    }
  }

  function togglePendingAdd(userId: string | number) {
    setPendingAddIds((prev) =>
      prev.some((id) => String(id) === String(userId))
        ? prev.filter((id) => String(id) !== String(userId))
        : [...prev, userId],
    );
  }

  function openChannel(nextChannelId: string | number) {
    if (!workspaceId || !nextChannelId) return;
    router.push(channelPath(workspaceId, String(nextChannelId)));
  }

  async function openDm(userId: string | number) {
    if (!workspaceId || !userId || String(userId) === String(myUserId)) return;
    setActionBusy(true);
    setError(null);
    try {
      const existing = dmByUser.get(String(userId));
      if (existing) {
        const existingRow = channels.find((row) => String(row.channel.id) === String(existing));
        if (existingRow?.member?.isClosed) await setDmClosed(existing, false);
        openChannel(existing);
        return;
      }
      const dm = await api.createDm(workspaceId, String(userId));
      if (!dm?.id) throw new Error("DM create returned no channel id");
      setShowDmPicker(false);
      router.push(channelPath(workspaceId, String(dm.id)));
    } catch (err) {
      setError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setActionBusy(false);
    }
  }

  async function setDmClosed(targetChannelId: string | number, closed: boolean) {
    if (!workspaceId) return;
    setActionBusy(true);
    setError(null);
    try {
      await api.setDmClosed(workspaceId, String(targetChannelId), closed);
      setChannels((current) => current.map((row) =>
        String(row.channel.id) === String(targetChannelId)
          ? { ...row, member: { ...(row.member ?? {}), isClosed: closed } }
          : row,
      ));
      setShowDmActions(false);
      if (closed && String(targetChannelId) === String(channelId)) {
        const fallback = channels.find((row) => !isDmChannel(row.channel.type));
        router.push(fallback ? channelPath(workspaceId, String(fallback.channel.id)) : `/workspace?id=${workspaceId}`);
      }
    } catch (err) {
      setError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setActionBusy(false);
    }
  }

  async function renameGroupDm() {
    if (!workspaceId || !channelId || !groupDmName.trim()) return;
    setActionBusy(true);
    setError(null);
    try {
      await api.updateChannel(workspaceId, channelId, { name: groupDmName.trim() });
      const refreshed = await api.listChannels(workspaceId);
      setChannels(normalizeChannelRows(refreshed.channels));
      setShowRenameGroupDm(false);
      setShowDmActions(false);
    } catch (err) {
      setError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setActionBusy(false);
    }
  }

  async function convertGroupDm() {
    if (!workspaceId || !channelId || !groupDmName.trim()) return;
    setActionBusy(true);
    setError(null);
    try {
      await api.convertGroupDm(workspaceId, channelId, groupDmName.trim());
      const refreshed = await api.listChannels(workspaceId);
      setChannels(normalizeChannelRows(refreshed.channels));
      setShowConvertGroupDm(false);
      setShowDmActions(false);
      setShowAbout(true);
    } catch (err) {
      setError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setActionBusy(false);
    }
  }

  async function removeMemberFromActiveChannel(memberUserId: string | number) {
    if (!workspaceId || !channelId) return;
    setActionBusy(true);
    setError(null);
    try {
      await api.removeChannelMember(workspaceId, channelId, String(memberUserId));
      setChannelMembers((current) => current.filter((row) => String(row.user.id) !== String(memberUserId)));
      await refreshAbout();
    } catch (err) {
      setError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setActionBusy(false);
    }
  }

  function openUserProfile(userId: string | number) {
    setProfileUserId(userId);
    setActionMenuMsgId(null);
    setThreadRootId(null);
    setThreadMessages([]);
    setShowAbout(false);
  }

  async function startProfileConnect(profile: MemberRow) {
    if (!workspaceId || String(profile.user.id) === String(myUserId)) return;
    setCallBusy(true);
    setCallError(null);
    try {
      const call = await api.startCall(workspaceId, {
        kind: "audio",
        inviteeUserIds: [profile.user.id],
        channelId: channels.find((row) => String(row.dmPeer?.id) === String(profile.user.id))?.channel.id ?? null,
        title: `Connect with ${profile.member.displayName?.trim() || profile.user.name}`,
      });
      setActiveCall(call);
    } catch (err) {
      setCallError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    } finally {
      setCallBusy(false);
    }
  }

  const activeRow = channels.find((row) => String(row.channel.id) === String(channelId)) ?? null;
  const activeChannel = activeRow?.channel;
  const memberById = useMemo(
    () => new Map(members.map((row) => [String(row.user.id), row.user])),
    [members],
  );
  const nameById = useMemo(
    () => new Map(members.map((row) => [String(row.user.id), row.user.name])),
    [members],
  );
  const avatarById = useMemo(
    () => new Map(members.map((row) => [String(row.user.id), row.user.avatarUrl])),
    [members],
  );
  const profileRow = useMemo(
    () =>
      members.find((row) => String(row.user.id) === String(profileUserId)) ??
      channelMembers.find((row) => String(row.user.id) === String(profileUserId)) ??
      null,
    [channelMembers, members, profileUserId],
  );

  /** Top-level messages only; replies live in the thread panel (ADR-014). */
  const rootMessages = useMemo(() => messages.filter((m) => !m.parentId && !m.deletedAt), [messages]);
  const feedItems = useMemo(() => mergeConversationFeed(rootMessages, channelCalls), [rootMessages, channelCalls]);
  const threadRoot = useMemo(
    () => messages.find((m) => String(m.id) === String(threadRootId) && !m.deletedAt) ?? null,
    [messages, threadRootId],
  );
  const threadReplies = useMemo(() => threadMessages.filter((message) => !message.deletedAt), [threadMessages]);

  function currentThreadReplyCount(rootId: string | number) {
    const storeChannelId = String(channelId ?? "");
    return (
      storeRef.current?.getMessage(storeChannelId, rootId)?.threadReplyCount ??
      messages.find((message) => String(message.id) === String(rootId))?.threadReplyCount ??
      0
    );
  }

  /**
   * A successful HTTP response proves that the reply exists. Update the root
   * immediately instead of depending on the best-effort socket echo. The
   * server's message:edited event will still replace this minimum with its
   * exact authoritative count.
   */
  function applyMinimumThreadSummary(
    rootId: string | number,
    minimumReplyCount: number,
    replyCreatedAt: string,
  ) {
    const storeChannelId = String(channelId ?? "");
    const root =
      storeRef.current?.getMessage(storeChannelId, rootId) ??
      messages.find((message) => String(message.id) === String(rootId));
    if (!root) return;
    const lastReplyAt =
      !root.threadLastReplyAt || new Date(replyCreatedAt).getTime() > new Date(root.threadLastReplyAt).getTime()
        ? replyCreatedAt
        : root.threadLastReplyAt;
    const updated: Message = {
      ...root,
      threadReplyCount: Math.max(root.threadReplyCount, minimumReplyCount),
      threadLastReplyAt: lastReplyAt,
    };
    syncRef.current?.onMessageEdited(updated);
    if (syncRef.current) setMessages(syncRef.current.getMessages());
    else setMessages((current) => current.map((message) => (String(message.id) === String(rootId) ? updated : message)));
  }

  useEffect(() => {
    if (!workspaceId || !channelId || threadRootId == null) return;
    let cancelled = false;
    const rootId = String(threadRootId);
    setThreadLoading(true);
    setThreadError(null);

    api
      .threadMessages(workspaceId, channelId, rootId)
      .then((res) => {
        if (cancelled) return;
        const replies = res.messages;
        // The request begins as soon as the panel opens. If the user sends the
        // first reply before that original empty response returns, preserve the
        // live reply instead of replacing it with the stale empty snapshot.
        setThreadMessages((current) => {
          const byId = new Map(replies.map((message) => [String(message.id), message]));
          for (const message of current) {
            if (String(message.parentId) === rootId) byId.set(String(message.id), message);
          }
          return Array.from(byId.values()).sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
        });
        const latestSeq = replies.reduce((max, reply) => Math.max(max, reply.seq ?? 0), 0);
        if (latestSeq > 0) void api.markThreadRead(workspaceId, channelId, rootId, latestSeq).catch(() => undefined);
      })
      .catch((err) => {
        if (!cancelled) setThreadError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
      })
      .finally(() => {
        if (!cancelled) setThreadLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [workspaceId, channelId, threadRootId]);

  useEffect(() => {
    if (!historyReady || !workspaceId || !channelId || !linkedMessageId) return;
    let cancelled = false;
    const rootId = linkedThreadId ?? linkedMessageId;

    void (async () => {
      try {
        let root = storeRef.current?.getMessage(channelId, rootId)
          ?? messages.find((message) => String(message.id) === rootId);
        if (!root) {
          root = (await api.message(workspaceId, channelId, rootId)).message;
          if (cancelled) return;
          syncRef.current?.onMessageEdited(root);
          setMessages(syncRef.current?.getMessages() ?? ((current) => {
            const byId = new Map(current.map((message) => [String(message.id), message]));
            byId.set(String(root!.id), root!);
            return [...byId.values()].sort((left, right) => left.seq - right.seq);
          }));
        }
        if (cancelled) return;
        if (linkedThreadId) {
          setProfileUserId(null);
          setShowAbout(false);
          setThreadRootId(rootId);
        } else {
          window.setTimeout(() => jumpToMessage(linkedMessageId), 60);
        }
      } catch (err) {
        if (!cancelled) setError(err instanceof ApiError ? JSON.stringify(err.body) : "Could not open the linked message.");
      }
    })();

    return () => { cancelled = true; };
    // Message ids are the stable deep-link identity. The initial history gate
    // prevents a fetched old message from being overwritten by first load.
  }, [historyReady, workspaceId, channelId, linkedMessageId, linkedThreadId]);

  useEffect(() => {
    if (!linkedThreadId || !linkedMessageId || String(threadRootId) !== linkedThreadId || threadLoading) return;
    if (!threadMessages.some((message) => String(message.id) === linkedMessageId)) return;
    const timer = window.setTimeout(() => {
      const node = document.getElementById(`thread-msg-${linkedMessageId}`);
      node?.scrollIntoView({ behavior: "smooth", block: "center" });
      node?.classList.add("msg-flash");
      window.setTimeout(() => node?.classList.remove("msg-flash"), 1600);
    }, 60);
    return () => window.clearTimeout(timer);
  }, [linkedMessageId, linkedThreadId, threadLoading, threadMessages, threadRootId]);

  const pinnedIds = useMemo(
    () => new Set((about?.pins ?? []).map((pin) => String(pin.messageId))),
    [about],
  );

  async function togglePin(messageId: string | number, pinned: boolean) {
    if (!workspaceId || !channelId) return;
    try {
      if (pinned) await api.unpinMessage(workspaceId, channelId, String(messageId));
      else await api.pinMessage(workspaceId, channelId, String(messageId));
      await refreshAbout();
    } catch (err) {
      setError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
    }
  }

  /** Scroll a message into view from the About panel's pinned list. */
  function jumpToMessage(messageId: string | number) {
    const node = document.getElementById(`msg-${messageId}`);
    if (!node) return;
    node.scrollIntoView({ behavior: "smooth", block: "center" });
    node.classList.add("msg-flash");
    setTimeout(() => node.classList.remove("msg-flash"), 1600);
  }

  function messagePermalink(message: Message): string {
    return buildMessagePermalink(window.location.origin, {
      workspaceId: workspaceId!,
      channelId: message.channelId,
      messageId: message.id,
      threadRootId: message.parentId,
    });
  }

  function showActionNotice(message: string) {
    setActionNotice(message);
    window.setTimeout(() => setActionNotice((current) => current === message ? null : current), 2400);
  }

  async function copyToClipboard(value: string) {
    if (navigator.clipboard?.writeText) {
      await navigator.clipboard.writeText(value);
      return;
    }
    const input = document.createElement("textarea");
    input.value = value;
    input.style.position = "fixed";
    input.style.opacity = "0";
    document.body.appendChild(input);
    input.select();
    const copied = document.execCommand("copy");
    input.remove();
    if (!copied) throw new Error("Clipboard is unavailable");
  }

  async function copyMessageLink(message: Message) {
    setActionMenuMsgId(null);
    setThreadActionMenuMsgId(null);
    try {
      await copyToClipboard(messagePermalink(message));
      showActionNotice("Message link copied");
    } catch {
      setError("Could not copy the message link.");
    }
  }

  async function shareMessage(message: Message) {
    setActionMenuMsgId(null);
    setThreadActionMenuMsgId(null);
    const url = messagePermalink(message);
    const source = viewingDm ? conversationTitle : `#${activeChannel?.name ?? "channel"}`;
    try {
      if (navigator.share) {
        await navigator.share({
          title: `${nameFor(message.authorId)} in ${source}`,
          text: message.text.slice(0, 240),
          url,
        });
        showActionNotice("Message shared");
      } else {
        await copyToClipboard(url);
        showActionNotice("Sharing is unavailable, so the link was copied");
      }
    } catch (err) {
      if (err instanceof DOMException && err.name === "AbortError") return;
      try {
        await copyToClipboard(url);
        showActionNotice("Message link copied");
      } catch {
        setError("Could not share this message.");
      }
    }
  }

  function openForwardMessage(message: Message) {
    setActionMenuMsgId(null);
    setThreadActionMenuMsgId(null);
    setForwardError(null);
    setForwardMessageTarget(message);
  }

  async function forwardMessage(destinationIds: string[], note: string) {
    if (!workspaceId || !channelId || !forwardMessageTarget || forwardBusy) return;
    setForwardBusy(true);
    setForwardError(null);
    try {
      const result = await api.forwardMessage(workspaceId, channelId, forwardMessageTarget.id, {
        destinations: destinationIds.map((destinationId) => ({ channelId: Number(destinationId), clientMsgId: crypto.randomUUID() })),
        note,
      });
      if (syncRef.current) {
        for (const sent of result.messages.filter((message) => String(message.channelId) === channelId)) {
          await syncRef.current.onLiveMessage(sent);
        }
        setMessages(syncRef.current.getMessages());
      }
      setForwardMessageTarget(null);
      showActionNotice(`Forwarded to ${result.messages.length} conversation${result.messages.length === 1 ? "" : "s"}`);
      const destinationId = destinationIds[0];
      if (destinationId && destinationId !== channelId) {
        router.push(channelPath(workspaceId, destinationId));
      }
    } catch (err) {
      setForwardError(err instanceof ApiError ? JSON.stringify(err.body) : "Could not forward this message. Check destination permissions and try again.");
    } finally {
      setForwardBusy(false);
    }
  }

  const textChannels = useMemo(() => onlyTextChannels(channels), [channels]);
  const dmByUser = useMemo(() => dmChannelByUserId(channels), [channels]);
  const dmMembers = useMemo(
    () => members.filter((row) => String(row.user.id) !== String(myUserId)),
    [members, myUserId],
  );
  const channelMemberIds = useMemo(
    () => new Set(channelMembers.map((row) => String(row.user.id))),
    [channelMembers],
  );
  const addableMembers = useMemo(
    () =>
      members.filter(
        (row) =>
          String(row.user.id) !== String(myUserId) &&
          !channelMemberIds.has(String(row.user.id)) &&
          !row.member.deactivatedAt,
      ),
    [members, myUserId, channelMemberIds],
  );
  const filteredAddableMembers = useMemo(() => {
    const query = memberSearch.trim().toLowerCase();
    if (!query) return addableMembers;
    return addableMembers.filter((row) =>
      row.user.name.toLowerCase().includes(query) || row.user.email.toLowerCase().includes(query),
    );
  }, [addableMembers, memberSearch]);
  const filteredCurrentMembers = useMemo(() => {
    const query = memberSearch.trim().toLowerCase();
    if (!query) return channelMembers;
    return channelMembers.filter((row) =>
      row.user.name.toLowerCase().includes(query) || row.user.email.toLowerCase().includes(query),
    );
  }, [channelMembers, memberSearch]);
  const selectedAddMembers = useMemo(
    () => members.filter((row) => pendingAddIds.some((id) => String(id) === String(row.user.id))),
    [members, pendingAddIds],
  );
  const mentionOptions = useMemo<MentionOption[]>(() => {
    if (mentionQuery == null) return [];
    const query = mentionQuery.toLowerCase();
    const specials: MentionOption[] = [
      { kind: "special", handle: "here", label: "@here", description: "Notify active members in this conversation" },
      { kind: "special", handle: "channel", label: "@channel", description: "Notify everyone in this channel" },
      { kind: "special", handle: "everyone", label: "@everyone", description: "Notify the whole workspace" },
    ];
    const activeMembers = members.filter((row) => !row.member.deactivatedAt && String(row.user.id) !== String(myUserId));
    const orderedMembers = [...activeMembers].sort((a, b) => {
      const aInChannel = channelMemberIds.has(String(a.user.id)) ? 0 : 1;
      const bInChannel = channelMemberIds.has(String(b.user.id)) ? 0 : 1;
      if (aInChannel !== bInChannel) return aInChannel - bInChannel;
      return a.user.name.localeCompare(b.user.name);
    });
    const people: MentionOption[] = orderedMembers.map((row) => {
      const handle = handleFromMember(row);
      return {
        kind: "user",
        userId: row.user.id,
        handle,
        label: row.user.name,
        description: `@${handle}`,
      };
    });
    return [...specials, ...people]
      .filter((option) => {
        if (!query) return true;
        return (
          option.handle.toLowerCase().includes(query) ||
          option.label.toLowerCase().includes(query) ||
          option.description.toLowerCase().includes(query)
        );
      })
      .slice(0, 8);
  }, [channelMemberIds, members, mentionQuery, myUserId]);
  const emojiOptions = useMemo(() => {
    const query = emojiQuery.trim().toLowerCase().replace(/^:/, "");
    if (!query) return EMOJI_OPTIONS;
    return EMOJI_OPTIONS.filter(
      (option) =>
        option.name.toLowerCase().includes(query) ||
        option.shortcodes.some((shortcode) => shortcode.toLowerCase().includes(query)),
    );
  }, [emojiQuery]);

  useEffect(() => {
    if (activeMentionIndex >= mentionOptions.length) setActiveMentionIndex(0);
  }, [activeMentionIndex, mentionOptions.length]);

  function insertMention(option: MentionOption) {
    composerInputRef.current?.replaceCurrentMention(`@${option.handle} `);
    setMentionQuery(null);
    setMentionStart(null);
    setActiveMentionIndex(0);
  }

  function openMentionPicker() {
    composerInputRef.current?.insertText(`${draft && !/\s$/.test(draft) ? " " : ""}@`);
  }

  function openEmojiPicker(next: EmojiPickerState) {
    setEmojiQuery("");
    setMentionQuery(null);
    setMentionStart(null);
    setEmojiPicker((current) =>
      current &&
      current.mode === next.mode &&
      (current.mode !== "reaction" || (next.mode === "reaction" && String(current.messageId) === String(next.messageId)))
        ? null
        : next,
    );
  }

  function insertEmoji(option: EmojiOption) {
    if (emojiPicker?.mode !== "composer" && emojiPicker?.mode !== "thread") return;
    const isThread = emojiPicker.mode === "thread";
    (isThread ? threadInputRef.current : composerInputRef.current)?.insertText(option.emoji);
    setEmojiPicker(null);
  }

  function applyEditedMessage(updated: Message) {
    const storeChannelId = String(channelId ?? updated.channelId);
    const cached = storeRef.current?.getMessage(storeChannelId, updated.id);
    const merged: Message = {
      ...cached,
      ...updated,
      reactions: updated.reactions ?? cached?.reactions,
    };

    syncRef.current?.onMessageEdited(merged);
    if (syncRef.current) setMessages(syncRef.current.getMessages());
    else setMessages((current) => current.map((message) => (String(message.id) === String(merged.id) ? merged : message)));
    setThreadMessages((current) =>
      current.map((message) =>
        String(message.id) === String(merged.id)
          ? { ...message, ...merged, reactions: merged.reactions ?? message.reactions }
          : message,
      ),
    );
    setEditingMessage((current) => (current && String(current.message.id) === String(merged.id) ? null : current));
  }

  function applyDeletedMessage(messageId: string | number, seq: number) {
    const storeChannelId = String(channelId ?? "");
    const deletedMessage = storeRef.current?.getMessage(storeChannelId, messageId);
    syncRef.current?.onMessageDeleted(messageId, seq);
    if (deletedMessage?.parentId != null && syncRef.current && storeRef.current) {
      const root = storeRef.current.getMessage(storeChannelId, deletedMessage.parentId);
      if (root) {
        const remainingReplies = storeRef.current
          .getMessages(storeChannelId)
          .filter((message) => String(message.parentId) === String(root.id) && !message.deletedAt);
        const lastReply = remainingReplies.reduce<Message | null>(
          (latest, message) => (!latest || message.seq > latest.seq ? message : latest),
          null,
        );
        syncRef.current.onMessageEdited({
          ...root,
          revision: root.revision + 1,
          threadReplyCount: remainingReplies.length,
          threadLastReplyAt: lastReply?.createdAt ?? null,
        });
      }
    }
    if (syncRef.current) setMessages(syncRef.current.getMessages());
    else {
      setMessages((current) =>
        current.map((message) =>
          String(message.id) === String(messageId)
            ? { ...message, text: "", deletedAt: message.deletedAt ?? new Date().toISOString() }
            : message,
        ),
      );
    }
    setThreadMessages((current) =>
      current.map((message) =>
        String(message.id) === String(messageId)
          ? { ...message, text: "", deletedAt: message.deletedAt ?? new Date().toISOString() }
          : message,
      ),
    );
    setEditingMessage((current) => (current && String(current.message.id) === String(messageId) ? null : current));
    setDeleteMessageTarget((current) => (current && String(current.message.id) === String(messageId) ? null : current));
    setActionMenuMsgId((current) => (current === String(messageId) ? null : current));
    setThreadActionMenuMsgId((current) => (current === String(messageId) ? null : current));
    if (String(threadRootIdRef.current ?? "") === String(messageId)) {
      setThreadRootId(null);
      setShowAbout(true);
    }
  }

  function beginMessageEdit(message: Message, surface: MessageSurface) {
    setActionMenuMsgId(null);
    setThreadActionMenuMsgId(null);
    setEmojiPicker(null);
    setMessageMutationError(null);
    setEditingMessage({ message, surface, draft: message.text, blocks: message.blocks });
  }

  function cancelMessageEdit() {
    if (messageMutationBusy) return;
    setEditingMessage(null);
  }

  async function saveMessageEdit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!workspaceId || !channelId || !editingMessage || messageMutationBusy) return;
    const text = editingMessage.draft.trim();
    if (!text) {
      setMessageMutationError("Message cannot be empty.");
      return;
    }
    if (text === editingMessage.message.text && JSON.stringify(editingMessage.blocks) === JSON.stringify(editingMessage.message.blocks)) {
      setEditingMessage(null);
      return;
    }

    setMessageMutationBusy(true);
    setMessageMutationError(null);
    try {
      const attachments = messageAttachments(editingMessage.message);
      const updated = await api.editMessage(workspaceId, channelId, String(editingMessage.message.id), {
        text,
        blocks: attachments.length > 0
          ? blocksWithAttachments(text, attachments, editingMessage.blocks)
          : editingMessage.blocks,
      });
      applyEditedMessage(updated);
    } catch (err) {
      setMessageMutationError(err instanceof ApiError ? JSON.stringify(err.body) : err instanceof Error ? err.message : "Message edit failed");
    } finally {
      setMessageMutationBusy(false);
    }
  }

  async function confirmDeleteMessage() {
    if (!workspaceId || !channelId || !deleteMessageTarget || messageMutationBusy) return;
    const target = deleteMessageTarget.message;
    setMessageMutationBusy(true);
    setMessageMutationError(null);
    try {
      await api.deleteMessage(workspaceId, channelId, String(target.id));
      applyDeletedMessage(target.id, target.seq);
    } catch (err) {
      setMessageMutationError(err instanceof ApiError ? JSON.stringify(err.body) : err instanceof Error ? err.message : "Message delete failed");
    } finally {
      setMessageMutationBusy(false);
    }
  }

  async function toggleSavedMessage(message: Message) {
    if (!workspaceId) return;
    const messageId = String(message.id);
    setActionMenuMsgId(null);
    setThreadActionMenuMsgId(null);
    const wasSaved = savedMessageIds.has(messageId);
    setSavedMessageIds((current) => {
      const next = new Set(current);
      if (wasSaved) next.delete(messageId);
      else next.add(messageId);
      return next;
    });
    try {
      if (wasSaved) await api.unsaveItem(workspaceId, messageId);
      else await api.saveItem(workspaceId, messageId);
    } catch (err) {
      setSavedMessageIds((current) => {
        const next = new Set(current);
        if (wasSaved) next.add(messageId);
        else next.delete(messageId);
        return next;
      });
      setError(err instanceof ApiError ? JSON.stringify(err.body) : "Could not update saved items");
    }
  }

  async function markMessageUnread(message: Message) {
    if (!workspaceId || !channelId) return;
    setActionMenuMsgId(null);
    setMessageMutationError(null);
    try {
      const result = await api.markMessageUnread(workspaceId, channelId, message.id);
      setChannelUnread(workspaceId, channelId, result.unreadCount);
    } catch (err) {
      setMessageMutationError(
        err instanceof ApiError ? JSON.stringify(err.body) : err instanceof Error ? err.message : "Could not mark message unread",
      );
    }
  }

  function renderEditableMessageText(message: Message, surface: MessageSurface, className: string) {
    const active =
      editingMessage?.surface === surface && String(editingMessage.message.id) === String(message.id)
        ? editingMessage
        : null;

    if (message.deletedAt) return null;
    if (active) {
      return (
        <form className="message-edit-form" onSubmit={saveMessageEdit}>
          <RichTextEditor
            autoFocus
            value={active.draft}
            blocks={active.blocks}
            placeholder="Edit message"
            ariaLabel="Edit message"
            compact
            onChange={(value) => setEditingMessage((current) => (current ? { ...current, draft: value.text, blocks: value.blocks } : current))}
            onSubmit={() => undefined}
            onSpecialKey={(event) => {
              if (event.key === "Escape") {
                event.preventDefault();
                cancelMessageEdit();
                return true;
              }
              if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
                event.preventDefault();
                document.activeElement?.closest("form")?.requestSubmit();
                return true;
              }
              return false;
            }}
          />
          <div className="message-edit-actions">
            <span>Esc to cancel · Ctrl+Enter to save</span>
            <button type="button" onClick={cancelMessageEdit} disabled={messageMutationBusy}>Cancel</button>
            <button
              type="submit"
              className="save"
              disabled={messageMutationBusy || !active.draft.trim() || (
                active.draft.trim() === active.message.text && JSON.stringify(active.blocks) === JSON.stringify(active.message.blocks)
              )}
            >
              {messageMutationBusy ? "Saving..." : "Save changes"}
            </button>
          </div>
          {messageMutationError && <p className="message-mutation-error" role="alert">{messageMutationError}</p>}
        </form>
      );
    }
    const forwarded = forwardedMessageFromBlocks(message.blocks);
    if (isAttachmentOnlyText(message) && !forwarded) return null;
    return (
      <>
        {message.text.trim() && (
          <RichTextMessage
            blocks={message.blocks}
            fallback={message.text}
            className={className}
            renderText={renderTextWithMentions}
          />
        )}
        {forwarded && <ForwardedMessageCard snapshot={forwarded} workspaceId={message.workspaceId} renderText={renderTextWithMentions} />}
      </>
    );
  }

  function updateReactionState(messageId: string | number, emoji: string, userId: string | number, op: "add" | "remove") {
    const apply = (message: Message): Message => {
      if (String(message.id) !== String(messageId)) return message;
      const reactions = [...(message.reactions ?? [])];
      const index = reactions.findIndex((reaction) => reaction.emoji === emoji);
      const current = index >= 0 ? reactions[index]! : { emoji, count: 0, reactedByMe: false };
      const isMe = myUserId != null && String(userId) === String(myUserId);
      const next =
        op === "add"
          ? { ...current, count: current.count + 1, reactedByMe: current.reactedByMe || isMe }
          : { ...current, count: Math.max(0, current.count - 1), reactedByMe: isMe ? false : current.reactedByMe };
      if (index >= 0) reactions[index] = next;
      else reactions.push(next);
      return { ...message, reactions: reactions.filter((reaction) => reaction.count > 0) };
    };
    setMessages((current) => current.map(apply));
    setThreadMessages((current) => current.map(apply));
  }

  async function toggleReaction(message: Message, emoji: string) {
    if (!workspaceId || !channelId) return;
    const reacted = (message.reactions ?? []).some((reaction) => reaction.emoji === emoji && reaction.reactedByMe);
    updateReactionState(message.id, emoji, myUserId ?? "me", reacted ? "remove" : "add");
    try {
      if (reacted) await api.removeReaction(workspaceId, channelId, String(message.id), emoji);
      else await api.addReaction(workspaceId, channelId, String(message.id), emoji);
    } catch (err) {
      updateReactionState(message.id, emoji, myUserId ?? "me", reacted ? "add" : "remove");
      setError(err instanceof Error ? err.message : "Reaction failed");
    }
  }

  async function reactWithEmoji(option: EmojiOption) {
    if (emojiPicker?.mode !== "reaction") return;
    const message =
      messages.find((m) => String(m.id) === String(emojiPicker.messageId)) ??
      threadMessages.find((m) => String(m.id) === String(emojiPicker.messageId));
    setEmojiPicker(null);
    if (message) await toggleReaction(message, option.emoji);
  }

  function chooseEmoji(option: EmojiOption) {
    if (emojiPicker?.mode === "reaction") void reactWithEmoji(option);
    else insertEmoji(option);
  }

  function renderEmojiPicker(anchor: "composer" | "message") {
    if (!emojiPicker) return null;
    const byCategory = emojiOptions.reduce<Record<EmojiOption["category"], EmojiOption[]>>(
      (acc, option) => {
        acc[option.category].push(option);
        return acc;
      },
      { Smileys: [], Gestures: [], Work: [], Objects: [] },
    );
    return (
      <div className={anchor === "composer" ? "emoji-menu composer-emoji-menu" : "emoji-menu message-emoji-menu"}>
        <div className="emoji-menu-head">
          <IconSmile size={15} />
          <input
            value={emojiQuery}
            onChange={(e) => setEmojiQuery(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === "Escape") {
                e.preventDefault();
                setEmojiPicker(null);
              }
              if (e.key === "Enter" && emojiOptions[0]) {
                e.preventDefault();
                chooseEmoji(emojiOptions[0]);
              }
            }}
            autoFocus
            placeholder="Search emoji"
            aria-label="Search emoji"
          />
          <button type="button" aria-label="Close emoji picker" onClick={() => setEmojiPicker(null)}>
            <IconX size={13} />
          </button>
        </div>
        <div className="emoji-grid" role="listbox" aria-label="Emoji">
          {(["Smileys", "Gestures", "Work", "Objects"] as const).map((category) =>
            byCategory[category].length > 0 ? (
              <div className="emoji-category" key={category}>
                <span>{category}</span>
                <div>
                  {byCategory[category].map((option) => (
                    <button
                      type="button"
                      key={`${category}-${option.emoji}-${option.shortcodes[0]}`}
                      title={`:${option.shortcodes[0]}:`}
                      aria-label={option.name}
                      onClick={() => chooseEmoji(option)}
                    >
                      {option.emoji}
                    </button>
                  ))}
                </div>
              </div>
            ) : null,
          )}
          {emojiOptions.length === 0 && <p className="emoji-empty">No emoji found</p>}
        </div>
      </div>
    );
  }

  function handleComposerKeyDown(e: KeyboardEvent): boolean {
    if (mentionQuery != null && mentionOptions.length > 0) {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        setActiveMentionIndex((index) => (index + 1) % mentionOptions.length);
        return true;
      }
      if (e.key === "ArrowUp") {
        e.preventDefault();
        setActiveMentionIndex((index) => (index - 1 + mentionOptions.length) % mentionOptions.length);
        return true;
      }
      if (e.key === "Enter" || e.key === "Tab") {
        e.preventDefault();
        insertMention(mentionOptions[activeMentionIndex] ?? mentionOptions[0]!);
        return true;
      }
      if (e.key === "Escape") {
        e.preventDefault();
        setMentionQuery(null);
        setMentionStart(null);
        setActiveMentionIndex(0);
        return true;
      }
    }
    return false;
  }

  const me = myUserId != null ? memberById.get(String(myUserId)) : undefined;
  const viewingDm = activeChannel ? isDmChannel(activeChannel.type) : false;
  const conversationTitle = activeRow
    ? viewingDm
      ? dmTitle(activeRow)
      : (activeChannel?.name ?? "channel")
    : "channel";
  const facePileMembers = viewingDm
    ? members.filter((row) => channelMemberIds.has(String(row.user.id)) || String(row.user.id) === String(myUserId))
    : channelMembers;
  const memberCountLabel = viewingDm
    ? `${facePileMembers.length || members.length} people`
    : `${channelMembers.length || 1} member${(channelMembers.length || 1) === 1 ? "" : "s"}`;
  const isPrivateRoom = activeChannel?.type === "private";
  const openQuestions = rootMessages.filter(
    (m) => !m.deletedAt && isQuestionText(m.text) && String(m.authorId) !== String(myUserId),
  );
  const ownerLabel = channelMembers.find((row) => row.member.role === "owner" || row.member.role === "admin")?.user
    .name?.split(" ")[0];

  const typingNames = typingUsers
    .filter((id) => String(id) !== String(myUserId))
    .map((id) => memberById.get(String(id))?.name?.split(" ")[0])
    .filter((n): n is string => Boolean(n));

  function nameFor(userId: string | number): string {
    const key = String(userId);
    return memberById.get(key)?.name ?? `User ${key.slice(0, 6)}`;
  }

  function threadParticipantIds(rootMessageId: string | number): Array<string | number> {
    const ids = threadMessages
      .filter((reply) => String(reply.parentId) === String(rootMessageId))
      .map((reply) => reply.authorId);
    const unique = Array.from(new Set(ids.map(String))).slice(0, 4);
    return unique.length > 0 ? unique : [messages.find((message) => String(message.id) === String(rootMessageId))?.authorId ?? rootMessageId];
  }

  if (!workspaceId || !channelId) {
    return (
      <main className="page">
        <div className="empty-state">
          Missing workspaceId/channelId — go back to <Link href="/workspaces">workspaces</Link>.
        </div>
      </main>
    );
  }

  return (
    <main className="app windshield">
      <GlobalNav
        workspaceId={workspaceId}
        myUserId={myUserId}
        meName={me?.name}
        meAvatarUrl={me?.avatarUrl}
        activityCount={activityUnreadCount}
        taskCount={unreadAssignedCount}
        chatCount={chatUnread}
        missedCallCount={missedCallCount}
      />
      <WorkspaceSidebar
        workspaceId={workspaceId}
        workspaceLabel="Workspace"
        active="chat"
        variant="chat"
        channels={textChannels}
        conversations={channels}
        members={members}
        presence={presence}
        myUserId={myUserId}
        meName={me?.name}
        activeChannelId={channelId}
        taskCount={unreadAssignedCount}
        onOpenChannel={openChannel}
        onOpenDm={openDm}
        onCreateChannel={() => setShowCreateChannel((v) => !v)}
        onStartConnect={() => setCallPickerOpen(true)}
        onReopenDms={() => setShowClosedDms(true)}
        createChannelSlot={
          showCreateChannel ? (
            <form className="inline-create" onSubmit={createChannel}>
              <input
                value={newChannelName}
                onChange={(e) => setNewChannelName(e.target.value)}
                placeholder="new-room"
                aria-label="New room name"
                required
              />
              <button type="submit" disabled={actionBusy}>
                {actionBusy ? "…" : "Create"}
              </button>
            </form>
          ) : null
        }
        dmPickerSlot={
          showDmPicker ? (
            <div className="dm-picker">
              {dmMembers.map((row) => (
                <button
                  key={String(row.user.id)}
                  type="button"
                  className="dm-picker-row"
                  onClick={() => openDm(row.user.id)}
                  disabled={actionBusy}
                >
                  {row.user.name}
                </button>
              ))}
              {dmMembers.length === 0 && (
                <div className="list-row-sub" style={{ padding: "6px 10px" }}>
                  Invite teammates to start DMs.
                </div>
              )}
            </div>
          ) : null
        }
      />

      {/* ── main pane ────────────────────────────────────────────────── */}
      <section className="main">
        <header className="main-head">
          <div className="main-head-row">
            <div className="main-head-left">
              <div className="main-head-title-row">
                {!viewingDm && (
                  <button
                    type="button"
                    className={channelStarred ? "main-head-star active" : "main-head-star"}
                    aria-label={channelStarred ? "Remove channel from favorites" : "Add channel to favorites"}
                    aria-pressed={channelStarred}
                    onClick={() => setChannelStarred((value) => !value)}
                  >
                    <IconStar size={18} />
                  </button>
                )}
                {!viewingDm && isPrivateRoom && (
                  <span className="main-head-lock" aria-label="Private room">
                    <IconLock size={16} />
                  </span>
                )}
                {!viewingDm && !isPrivateRoom && <span className="main-head-hash">#</span>}
                {viewingDm && activeRow?.dmPeer && (
                  <button
                    type="button"
                    className="user-avatar-trigger"
                    aria-label={`Open ${activeRow.dmPeer.name} profile`}
                    onClick={() => openUserProfile(activeRow.dmPeer!.id)}
                  >
                    <UserAvatar
                      className="list-row-avatar"
                      userId={activeRow.dmPeer.id}
                      name={activeRow.dmPeer.name}
                      avatarUrl={activeRow.dmPeer.avatarUrl}
                      style={{ marginRight: 8 }}
                    />
                  </button>
                )}
                {viewingDm && activeRow?.dmPeer ? (
                  <button
                    type="button"
                    className="main-head-title user-name-trigger"
                    aria-label={`Open ${activeRow.dmPeer.name} profile`}
                    onClick={() => openUserProfile(activeRow.dmPeer!.id)}
                  >
                    {conversationTitle}
                  </button>
                ) : (
                  <h1 className="main-head-title">{conversationTitle}</h1>
                )}
                {!viewingDm && <IconChevron size={16} />}
              </div>
              {viewingDm ? (
                <div className="main-head-meta">
                  <span className="dm-status">
                    <span
                      className="dot"
                      style={{
                        background:
                          activeRow?.dmPeer && presence[String(activeRow.dmPeer.id)]?.status === "active"
                            ? "var(--online)"
                            : "var(--offline)",
                      }}
                    />
                    {activeRow?.dmPeer && presence[String(activeRow.dmPeer.id)]?.status === "active"
                      ? "Available"
                      : "Away"}
                  </span>
                </div>
              ) : (
                <div className="main-head-meta">
                  {ownerLabel && (
                    <>
                      <span>{ownerLabel} owns</span>
                      <span className="dotsep">·</span>
                    </>
                  )}
                  <span>{memberCountLabel}</span>
                  {openQuestions.length > 0 && (
                    <>
                      <span className="dotsep">·</span>
                      <button
                        className="alert"
                        type="button"
                        onClick={() => {
                          setRoomTab("Messages");
                          setAskMode(false);
                        }}
                      >
                        {openQuestions.length} open question{openQuestions.length === 1 ? "" : "s"} for you
                      </button>
                    </>
                  )}
                </div>
              )}
            </div>

            <div className="main-head-right">
              {!viewingDm && (facePileMembers.length > 0 || members.length > 0) && (
                <button
                  type="button"
                  className="head-action head-member-count"
                  aria-label={`${facePileMembers.length || members.length} channel members`}
                  onClick={() => {
                    setProfileUserId(null);
                    setThreadRootId(null);
                    setShowAbout(true);
                  }}
                >
                  <IconUsers size={17} />
                  <span>{facePileMembers.length || members.length}</span>
                </button>
              )}
              {viewingDm && (
                <>
                  <button
                    className="head-action"
                    type="button"
                    aria-label="Call"
                    title="Start audio call"
                    disabled={callBusy || !activeRow?.dmPeer}
                    onClick={() => void startDmCall("audio")}
                  >
                    <IconPhone />
                  </button>
                  <button
                    className="head-action"
                    type="button"
                    aria-label="Video call"
                    title="Start video call"
                    disabled={callBusy || !activeRow?.dmPeer}
                    onClick={() => void startDmCall("video")}
                  >
                    <IconVideo />
                  </button>
                </>
              )}
              {!viewingDm && (
                <>
                  <button
                    className="head-action head-connect"
                    type="button"
                    aria-label={channelCalls.some((row) => row.kind === "connect" && (row.status === "active" || row.status === "ringing")) ? "Join Connect" : "Start a Connect"}
                    disabled={callBusy}
                    onClick={() => void startChannelConnect()}
                  >
                    <IconVideo size={17} />
                    <span>
                      {channelCalls.some((row) => row.kind === "connect" && (row.status === "active" || row.status === "ringing"))
                        ? "Join Connect"
                        : "Connect"}
                    </span>
                  </button>
                  <button
                    className="head-action"
                    type="button"
                    aria-label="Call people"
                    title="Ring specific people"
                    onClick={() => setCallPickerOpen(true)}
                  >
                    <IconPhone size={17} />
                  </button>
                </>
              )}
              <div className="head-action-wrap">
                <button
                  className="head-action"
                  type="button"
                  aria-label={viewingDm ? "Direct message actions" : showAbout && !threadRootId ? "Hide about panel" : "More"}
                  aria-expanded={viewingDm ? showDmActions : undefined}
                  onClick={() => {
                    if (viewingDm) {
                      setShowDmActions((open) => !open);
                      return;
                    }
                    setProfileUserId(null);
                    setThreadRootId(null);
                    setShowAbout((v) => !v);
                  }}
                >
                  <IconMore />
                </button>
                {viewingDm && showDmActions && activeChannel && (
                  <div className="head-manage-menu" role="menu">
                    {activeChannel.type === "group_dm" && (
                      <>
                        <button type="button" role="menuitem" onClick={() => {
                          setGroupDmName(activeChannel.name ?? conversationTitle);
                          setShowRenameGroupDm(true);
                          setShowDmActions(false);
                        }}><IconEdit size={14} /> Rename group DM</button>
                        <button type="button" role="menuitem" onClick={() => {
                          setGroupDmName(activeChannel.name ?? "private-room");
                          setShowConvertGroupDm(true);
                          setShowDmActions(false);
                        }}><IconLock size={14} /> Convert to private channel</button>
                      </>
                    )}
                    <button className="danger" type="button" role="menuitem" disabled={actionBusy} onClick={() => void setDmClosed(activeChannel.id, true)}>
                      <IconX size={14} /> Close conversation
                    </button>
                  </div>
                )}
              </div>
            </div>
          </div>

          {!viewingDm && (
            <div className="tabs" role="tablist" aria-label="Room sections">
              {ROOM_TABS.map((tab) => (
                <button
                  key={tab}
                  type="button"
                  role="tab"
                  aria-selected={roomTab === tab}
                  className={roomTab === tab ? "tab active" : "tab"}
                  onClick={() => setRoomTab(tab)}
                >
                  {tab === "Messages" && <IconMessages size={16} />}
                  {tab === "Files & links" && <IconClip size={16} />}
                  {tab === "Pins" && <IconPin size={15} />}
                  <span>{tab}</span>
                </button>
              ))}
              <button type="button" className="tab-add" aria-label="Add tab" title="Not built yet" disabled>
                <IconPlus size={14} />
              </button>
            </div>
          )}
        </header>

        {roomTab !== "Messages" && !viewingDm ? (
          roomTab === "Files & links" ? <FilesBrowser workspaceId={workspaceId} channelId={channelId} compact /> : <div className="main-pane-empty">Pins are available in the room details panel.</div>
        ) : (
          <>
            <div
              ref={messagesRef}
              className={viewingDm ? "messages dm-message-list" : "messages"}
              onScroll={onMessagesScroll}
            >
              <div className="message-history-status" aria-live="polite">
                {historyLoading && <span>Loading older messages&hellip;</span>}
                {!historyLoading && historyError && (
                  <button type="button" onClick={() => void loadOlderMessages()}>
                    Could not load older messages. Retry
                  </button>
                )}
                {!historyLoading && !historyError && !hasOlderMessages && feedItems.length > 0 && (
                  <span>You&rsquo;ve reached the beginning of this conversation.</span>
                )}
              </div>
              {feedItems.length === 0 && pending.length === 0 && (
                <div className="empty-state" style={{ marginTop: 24 }}>
                  No messages yet. Start the conversation below.
                </div>
              )}

              {feedItems.map((item, index) => {
                const prev = feedItems[index - 1];
                const showDivider = !prev || dayKey(prev.atIso) !== dayKey(item.atIso);
                const dividerLabel = showDivider ? dayLabel(item.atIso) : null;
                const divider = showDivider ? (
                      <div className={dividerLabel === "Today" ? "day-divider today" : "day-divider"}>
                        <i />
                        <span>
                          {dividerLabel}
                          <IconChevron size={13} />
                        </span>
                        <i />
                        {dividerLabel === "Today" && <em>New</em>}
                      </div>
                ) : null;

                if (item.kind === "call") {
                  const call = item.call;
                  const live = call.status === "ringing" || call.status === "active";
                  const mine = call.participants.find((participant) => String(participant.userId) === String(myUserId));
                  const missed = call.status === "missed" || mine?.state === "missed";
                  const alreadyOn =
                    mine?.state === "joined" && live && String(activeCall?.id) === String(call.id);
                  const others = call.participants.filter((participant) => String(participant.userId) !== String(myUserId));
                  const tone = live ? "live" : missed ? "missed" : "ended";
                  return (
                    <div key={`call-${call.id}`}>
                      {divider}
                      <article className={`chat-call ${tone}`}>
                        <span className="chat-call-icon" aria-hidden="true">
                          {call.kind === "video" ? <IconVideo size={16} /> : <IconPhone size={16} />}
                        </span>
                        <div className="chat-call-body">
                          <strong className="chat-call-title">{callFeedTitle(call, myUserId, nameFor)}</strong>
                          <span className="chat-call-meta">
                            {callFeedDetail(call, myUserId)}
                            <span className="dotsep">·</span>
                            {formatTime(call.startedAt)}
                            {call.kind === "video" && <span className="chat-call-tag">Video</span>}
                          </span>
                        </div>
                        <div className="chat-call-actions">
                          {live && (
                            <button
                              className="chat-call-join"
                              type="button"
                              disabled={callBusy}
                              onClick={() => void joinChannelCall(call)}
                            >
                              {alreadyOn || (mine?.state === "joined" && live) ? "Return" : "Join"}
                            </button>
                          )}
                          {!live && others.length > 0 && (
                            <button
                              className="chat-call-back"
                              type="button"
                              disabled={callBusy}
                              aria-label="Call back"
                              title="Call back"
                              onClick={() =>
                                viewingDm
                                  ? void startDmCall(call.kind === "video" ? "video" : "audio")
                                  : void (async () => {
                                      if (!workspaceId) return;
                                      setCallBusy(true);
                                      setCallError(null);
                                      try {
                                        const next = await api.startCall(workspaceId, {
                                          kind: call.kind === "video" ? "video" : "audio",
                                          inviteeUserIds: others.map((participant) => participant.userId),
                                          channelId,
                                          title: call.title,
                                        });
                                        setActiveCall(next);
                                      } catch (err) {
                                        setCallError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
                                      } finally {
                                        setCallBusy(false);
                                      }
                                    })()
                              }
                            >
                              {call.kind === "video" ? <IconVideo size={15} /> : <IconPhone size={15} />}
                              Call back
                            </button>
                          )}
                        </div>
                      </article>
                    </div>
                  );
                }

                const m = item.message;
                const color = avatarColor(String(m.authorId));
                const mine = String(m.authorId) === String(myUserId);
                const question = !m.deletedAt && isQuestionText(m.text);
                const taskIntent = m.deletedAt ? null : detectTaskIntent(m.text);
                const menuOpen = actionMenuMsgId === String(m.id);
                const editingHere = editingMessage?.surface === "main" && String(editingMessage.message.id) === String(m.id);
                const decided = decidedIds.has(String(m.id));
                const askedYou = question && !mine && !decided;
                const hasAttachments = messageAttachments(m).length > 0;
                const msgClass = viewingDm
                  ? `msg dm-bubble ${mine ? "mine" : "theirs"}${hasAttachments ? " has-attachments" : ""}`
                  : question
                    ? "msg question-card"
                    : decided
                      ? "msg decided-card"
                      : "msg";

                return (
                  <div
                    key={m.id}
                    id={`msg-${m.id}`}
                    className={viewingDm ? `dm-row ${mine ? "mine" : "theirs"}` : undefined}
                  >
                    {divider}
                    <article className={msgClass}>
                      {!m.deletedAt && (
                        // Hover-revealed action toolbar, the convention every
                        // chat client lands on: nothing in the resting state,
                        // an overflow menu on hover/focus. `open` pins it so
                        // the toolbar doesn't vanish under the popover when
                        // the pointer leaves the row.
                        <div className={menuOpen ? "msg-hover-actions open" : "msg-hover-actions"}>
                          <button
                            type="button"
                            className="msg-action-btn"
                            aria-label="Add a reaction"
                            title="Add reaction"
                            onClick={() => openEmojiPicker({ mode: "reaction", messageId: m.id })}
                          >
                            <IconSmile size={15} />
                          </button>
                          {emojiPicker?.mode === "reaction" && String(emojiPicker.messageId) === String(m.id) && renderEmojiPicker("message")}
                          <button
                            type="button"
                            className="msg-action-btn"
                            aria-label="Forward message"
                            title="Forward message"
                            onClick={() => openForwardMessage(m)}
                          >
                            <IconForward size={15} />
                          </button>
                          <button
                            type="button"
                            className="msg-action-btn"
                            aria-label="More actions"
                            title="More actions"
                            aria-haspopup="menu"
                            aria-expanded={menuOpen}
                            onClick={() => setActionMenuMsgId(menuOpen ? null : String(m.id))}
                          >
                            <IconMoreVertical size={15} />
                          </button>
                          {menuOpen && (
                            <div className="msg-action-menu" role="menu" aria-label="Message actions">
                              <button
                                type="button"
                                role="menuitem"
                                className="msg-action-item"
                                onClick={() => void markMessageUnread(m)}
                              >
                                <IconMessages size={13} />
                                <span>Mark unread from here</span>
                              </button>
                              <button
                                type="button"
                                role="menuitem"
                                className="msg-action-item"
                                onClick={() => toggleSavedMessage(m)}
                              >
                                <IconStar size={13} />
                                <span>{savedMessageIds.has(String(m.id)) ? "Remove from saved" : "Save for later"}</span>
                              </button>
                              <button type="button" role="menuitem" className="msg-action-item" onClick={() => void copyMessageLink(m)}>
                                <IconLink size={13} />
                                <span>Copy link</span>
                              </button>
                              <button type="button" role="menuitem" className="msg-action-item" onClick={() => void shareMessage(m)}>
                                <IconShare size={13} />
                                <span>Share</span>
                              </button>
                              <button
                                type="button"
                                role="menuitem"
                                className="msg-action-item"
                                onClick={() => {
                                  setActionMenuMsgId(null);
                                  setTaskError(null);
                                  const linked = tasksBySourceMessageId.get(String(m.id));
                                  if (linked) setTaskModal({ mode: "edit", task: linked });
                                  else setTaskModal({ mode: "create", message: m });
                                }}
                              >
                                <IconCheck size={13} />
                                <span>
                                  {tasksBySourceMessageId.has(String(m.id))
                                    ? "Open task"
                                    : "Create task from this message"}
                                </span>
                              </button>
                              {!viewingDm && (
                                <button
                                  type="button"
                                  role="menuitem"
                                  className="msg-action-item"
                                  onClick={() => {
                                    setActionMenuMsgId(null);
                                    setProfileUserId(null);
                                    setThreadRootId(m.id);
                                    setShowAbout(false);
                                  }}
                                >
                                  <IconChevron />
                                  <span>Reply in thread</span>
                                </button>
                              )}
                              {!viewingDm && about?.capabilities.canPin && (
                                <button
                                  type="button"
                                  role="menuitem"
                                  className="msg-action-item"
                                  onClick={() => {
                                    setActionMenuMsgId(null);
                                    void togglePin(m.id, pinnedIds.has(String(m.id)));
                                  }}
                                >
                                  <IconPin size={13} />
                                  <span>{pinnedIds.has(String(m.id)) ? "Unpin from this room" : "Pin to this room"}</span>
                                </button>
                              )}
                              {mine && (
                                <>
                                  <button
                                    type="button"
                                    role="menuitem"
                                    className="msg-action-item"
                                    onClick={() => beginMessageEdit(m, "main")}
                                  >
                                    <IconEdit size={13} />
                                    <span>Edit message</span>
                                  </button>
                                  <button
                                    type="button"
                                    role="menuitem"
                                    className="msg-action-item danger"
                                    onClick={() => {
                                      setActionMenuMsgId(null);
                                      setMessageMutationError(null);
                                      setDeleteMessageTarget({ message: m, surface: "main" });
                                    }}
                                  >
                                    <IconTrash size={13} />
                                    <span>Delete message</span>
                                  </button>
                                </>
                              )}
                            </div>
                          )}
                        </div>
                      )}
                      {(!viewingDm || !mine) && (
                        <button
                          type="button"
                          className="user-avatar-trigger"
                          aria-label={`Open ${nameFor(m.authorId)} profile`}
                          onClick={() => openUserProfile(m.authorId)}
                        >
                          <UserAvatar
                            className="msg-avatar"
                            userId={m.authorId}
                            name={nameFor(m.authorId)}
                            avatarUrl={memberById.get(String(m.authorId))?.avatarUrl}
                            style={{ background: color.bg, color: color.fg }}
                          />
                        </button>
                      )}
                      <div className="msg-body">
                        {!viewingDm && (
                          <div className="msg-meta">
                            <button type="button" className="msg-who user-name-trigger" onClick={() => openUserProfile(m.authorId)}>
                              {nameFor(m.authorId)}
                            </button>
                            <span className="msg-time">{formatTime(m.createdAt)}</span>
                            {m.editedAt && <span className="msg-edited">(edited)</span>}
                            {pinnedIds.has(String(m.id)) && (
                              <span className="msg-tag pinned">
                                <IconPin size={10} /> Pinned
                              </span>
                            )}
                            {question && !decided && <span className="msg-tag open">Open</span>}
                            {askedYou && <span className="msg-tag asked">Asked you</span>}
                            {decided && (
                              <span className="msg-tag decided">
                                <IconCheck size={10} /> Decided
                              </span>
                            )}
                          </div>
                        )}
                        {viewingDm && (
                          <div className="msg-meta dm-only">
                            <span className="msg-time">{formatTime(m.createdAt)}</span>
                            {m.editedAt && <span className="msg-edited">(edited)</span>}
                          </div>
                        )}
                        {renderEditableMessageText(m, "main", question && !viewingDm ? "msg-text question" : "msg-text")}
                        {renderAttachments(m)}
                        {askedYou && !viewingDm && !editingHere && (
                          <div className="msg-question-actions">
                            <button
                              type="button"
                              className="msg-answer"
                              onClick={() => {
                                setProfileUserId(null);
                                setThreadRootId(m.id);
                                setShowAbout(false);
                              }}
                            >
                              Answer
                            </button>
                            <button
                              type="button"
                              className="msg-decide"
                              onClick={() => setDecidedIds((prevSet) => new Set(prevSet).add(String(m.id)))}
                            >
                              Mark decided
                            </button>
                          </div>
                        )}
                        {(tasksBySourceMessageId.has(String(m.id)) || taskIntent?.isTask) && !editingHere && (
                          <div className="msg-task-suggest">
                            {tasksBySourceMessageId.has(String(m.id)) ? (
                              <button
                                type="button"
                                className="msg-task-cta created"
                                title="Open the task created from this message"
                                onClick={() => {
                                  const linked = tasksBySourceMessageId.get(String(m.id));
                                  if (!linked) return;
                                  setTaskError(null);
                                  setTaskModal({ mode: "edit", task: linked });
                                }}
                              >
                                <IconCheck size={12} />
                                <span>Task created</span>
                              </button>
                            ) : (
                              <button
                                type="button"
                                className="msg-task-cta"
                                title={`Looks like a task — ${taskIntent?.reason ?? ""}`}
                                onClick={() => {
                                  setTaskError(null);
                                  setTaskModal({ mode: "create", message: m });
                                }}
                              >
                                <IconCheck size={12} />
                                <span>Create task</span>
                              </button>
                            )}
                            <span className="msg-task-why">
                              {tasksBySourceMessageId.has(String(m.id)) ? "Linked to this message" : "Looks like a task"}
                            </span>
                          </div>
                        )}
                        {!m.deletedAt && (m.reactions ?? []).length > 0 && (
                          <div className="msg-reactions">
                            {(m.reactions ?? []).map((reaction) => (
                              <button
                                className={reaction.reactedByMe ? "reaction on" : "reaction"}
                                type="button"
                                key={`${m.id}-${reaction.emoji}`}
                                aria-label={`${reaction.emoji} ${reaction.count}`}
                                onClick={() => void toggleReaction(m, reaction.emoji)}
                              >
                                <span>{reaction.emoji}</span>
                                <span>{reaction.count}</span>
                              </button>
                            ))}
                          </div>
                        )}
                        {m.threadReplyCount > 0 && !viewingDm && (
                          <button
                            className="msg-thread-link"
                            type="button"
                            onClick={() => {
                              setProfileUserId(null);
                              setThreadRootId(m.id);
                              setShowAbout(false);
                            }}
                          >
                            <span className="msg-thread-avatars">
                              {threadParticipantIds(m.id).map((userId) => {
                                const participantColor = avatarColor(String(userId));
                                return (
                                  <UserAvatar
                                    className="msg-thread-avatar"
                                    userId={userId}
                                    name={nameFor(userId)}
                                    avatarUrl={memberById.get(String(userId))?.avatarUrl}
                                    style={{ background: participantColor.bg, color: participantColor.fg }}
                                    key={`${m.id}-${userId}`}
                                  />
                                );
                              })}
                            </span>
                            <span className="msg-thread-count">
                              {m.threadReplyCount} {m.threadReplyCount === 1 ? "reply" : "replies"}
                            </span>
                            {m.threadLastReplyAt && <span className="msg-thread-last">Last reply {relativeAge(m.threadLastReplyAt)}</span>}
                          </button>
                        )}
                      </div>
                    </article>
                  </div>
                );
              })}

              {pending.map((p) => {
                const color = avatarColor(myUserId == null ? null : String(myUserId));
                const body = (
                  <>
                    {!viewingDm && (
                      <button type="button" className="user-avatar-trigger" aria-label="Open your profile" onClick={() => myUserId && openUserProfile(myUserId)}>
                        <UserAvatar
                          className="msg-avatar"
                          userId={myUserId}
                          name={me?.name}
                          avatarUrl={me?.avatarUrl}
                          style={{ background: color.bg, color: color.fg }}
                        />
                      </button>
                    )}
                    <div className="msg-body">
                      <div className="msg-meta">
                        {!viewingDm && (
                          <button
                            type="button"
                            className="msg-who user-name-trigger"
                            onClick={() => myUserId && openUserProfile(myUserId)}
                          >
                            {me?.name ?? "You"}
                          </button>
                        )}
                        <span className={p.status === "failed" ? "msg-tag open" : "msg-tag decided"}>
                          {p.status === "pending" ? "Sending" : "Failed"}
                        </span>
                      </div>
                      <p className="msg-text" style={{ opacity: p.status === "failed" ? 1 : 0.6 }}>
                        {renderTextWithMentions(p.text)}
                      </p>
                    </div>
                  </>
                );
                // Match confirmed own messages: DM bubbles need the dm-row.mine
                // wrapper or the optimistic "Sending" row sits on the left and
                // jumps right once the server ack arrives.
                if (viewingDm) {
                  return (
                    <div key={p.clientMsgId} className="dm-row mine">
                      <article className="msg dm-bubble mine">{body}</article>
                    </div>
                  );
                }
                return (
                  <article key={p.clientMsgId} className="msg">
                    {body}
                  </article>
                );
              })}

              {error && <p className="error-text">{error}</p>}
              <div ref={bottomRef} />
            </div>

            <div className="typing-line" aria-live="polite">
              {typingNames.length === 1 && `${typingNames[0]} is typing…`}
              {typingNames.length > 1 && `${typingNames.slice(0, 2).join(" and ")} are typing…`}
            </div>

            <form className="composer" onSubmit={send} onDragOver={(event) => event.preventDefault()} onDrop={(event) => acceptDroppedFiles(event, "main")}>
              <div className="composer-box">
                {mentionQuery != null && mentionOptions.length > 0 && (
                  <div className="mention-menu" role="listbox" aria-label="Mention suggestions">
                    {mentionOptions.map((option, index) => {
                      const active = index === activeMentionIndex;
                      const color = option.kind === "user" ? avatarColor(String(option.userId)) : null;
                      return (
                        <button
                          key={`${option.kind}-${option.handle}`}
                          type="button"
                          role="option"
                          aria-selected={active}
                          className={active ? "mention-option active" : "mention-option"}
                          onMouseEnter={() => setActiveMentionIndex(index)}
                          onClick={() => insertMention(option)}
                        >
                          {option.kind === "user" ? (
                            <span className="mention-avatar" style={{ background: color?.bg, color: color?.fg }}>
                              {initials(option.label)}
                            </span>
                          ) : (
                            <span className="mention-avatar special">
                              <IconAt size={14} />
                            </span>
                          )}
                          <span className="mention-copy">
                            <strong>{option.kind === "user" ? option.label : option.label}</strong>
                            <small>{option.description}</small>
                          </span>
                        </button>
                      );
                    })}
                  </div>
                )}
                {emojiPicker?.mode === "composer" && renderEmojiPicker("composer")}
                <RichTextEditor
                  ref={composerInputRef}
                  value={draft}
                  blocks={draftBlocks}
                  onChange={(next, cursor) => onDraftChange(next.text, next.blocks, cursor)}
                  onSpecialKey={handleComposerKeyDown}
                  onPasteFiles={(files) => onFilesPicked(files, "main")}
                  onSubmit={() => document.activeElement?.closest("form")?.requestSubmit()}
                  placeholder={
                    askMode
                      ? "Ask a question…"
                      : viewingDm
                        ? `Message ${conversationTitle}`
                        : `Message ${activeChannel?.name ?? "room"}`
                  }
                  ariaLabel={
                    askMode
                      ? "Ask a question"
                      : viewingDm
                        ? `Message ${conversationTitle}`
                        : `Message ${activeChannel?.name ?? "room"}`
                  }
                />
                <input
                  ref={fileInputRef}
                  id="composer-file-input"
                  type="file"
                  multiple
                  className="composer-file-input"
                  onChange={(e) => onFilesPicked(e.target.files)}
                />
                {selectedFiles.length > 0 && (
                  <div className="composer-files">
                    {selectedFiles.map((file, index) => (
                      <span className={file.status === "failed" ? "composer-file-chip failed" : "composer-file-chip"} key={file.localId}>
                        <IconClip size={13} />
                        <span>{file.name}</span>
                        <small>
                          {file.status === "uploading"
                            ? "Uploading"
                            : file.status === "failed"
                              ? "Failed"
                              : formatFileSize(file.size)}
                        </small>
                        <button type="button" aria-label={`Remove ${file.name}`} onClick={() => removeSelectedFile(index)}>
                          <IconX size={12} />
                        </button>
                      </span>
                    ))}
                  </div>
                )}
                {clipOpen && (
                  <ClipRecorder
                    onComplete={(file) => {
                      setClipOpen(false);
                      onFilesPicked([file]);
                    }}
                    onCancel={() => setClipOpen(false)}
                  />
                )}
                <div className="composer-row">
                  <label className="composer-attach" htmlFor="composer-file-input" aria-label="Add attachment" title="Attach a file">
                    <IconPlus size={16} />
                  </label>
                  <button
                    className="composer-tool"
                    type="button"
                    aria-label="Record a clip"
                    title="Voice or video clip"
                    onClick={() => setClipOpen((open) => !open)}
                  >
                    <IconMic />
                  </button>
                  {COMPOSER_TOOLS.map(({ key, Icon, label }) => {
                    if (key === "attach") {
                      return (
                        <label className="composer-tool" key={key} htmlFor="composer-file-input" aria-label={label} title={label}>
                          <Icon />
                        </label>
                      );
                    }
                    if (key === "mention") {
                      return (
                        <button className="composer-tool" type="button" key={key} aria-label={label} title={label} onClick={openMentionPicker}>
                          <Icon />
                        </button>
                      );
                    }
                    return (
                      <button
                        className="composer-tool"
                        type="button"
                        key={key}
                        aria-label={label}
                        title={label}
                        onClick={() => openEmojiPicker({ mode: "composer" })}
                      >
                        <Icon />
                      </button>
                    );
                  })}
                  <button
                    className={askMode ? "composer-ask active" : "composer-ask"}
                    type="button"
                    aria-pressed={askMode}
                    onClick={() => setAskMode((v) => !v)}
                  >
                    <IconHelp size={14} />
                    Ask a question
                  </button>
                  <ComposerRewriteBar editorRef={composerInputRef} draftText={draft} />
                  <span className="composer-spacer" />
                  {pending.some((p) => p.status === "failed") && (
                    <button className="composer-retry" type="button" onClick={retryFailed}>
                      Retry failed
                    </button>
                  )}
                  <span className="composer-send-split">
                    <button
                      className={draft.trim() || selectedFiles.length > 0 ? "composer-send ready" : "composer-send"}
                      type="submit"
                      aria-label="Send message"
                      disabled={uploadingFiles || selectedFiles.some((file) => file.status === "uploading")}
                    >
                      <span className="composer-send-label">
                        {uploadingFiles || selectedFiles.some((file) => file.status === "uploading") ? "Uploading" : "Send"}
                      </span>
                      <IconSend />
                    </button>
                    <button
                      className="composer-send-menu"
                      type="button"
                      aria-label="Send options"
                      title="Not built yet"
                      disabled
                    >
                      <IconChevron size={14} />
                    </button>
                  </span>
                </div>
              </div>
            </form>
          </>
        )}
      </section>

      {/* ── thread panel ─────────────────────────────────────────────── */}
      {threadRoot && (
        <aside className="thread" aria-label="Thread">
          <div className="thread-head">
            <span>Thread</span>
            <button
              className={threadFollowing ? "thread-follow active" : "thread-follow"}
              type="button"
              onClick={toggleThreadFollowing}
              disabled={threadFollowBusy}
              aria-pressed={threadFollowing}
            >
              {threadFollowBusy ? "Saving..." : threadFollowing ? "Following" : "Follow"}
            </button>
            <button
              className="thread-close"
              type="button"
              onClick={() => {
                window.dispatchEvent(new Event("slackwsh:gallery-close"));
                setProfileUserId(null);
                setThreadRootId(null);
                setThreadMessages([]);
                setShowAbout(true);
              }}
              aria-label="Close thread"
            >
              <IconX />
            </button>
          </div>

          <div className="thread-body">
            {(() => {
              const color = avatarColor(String(threadRoot.authorId));
              const mine = String(threadRoot.authorId) === String(myUserId);
              const menuOpen = threadActionMenuMsgId === String(threadRoot.id);
              return (
                <div className="thread-msg root" id={`thread-msg-${threadRoot.id}`}>
                  {!threadRoot.deletedAt && (
                    <div className={menuOpen ? "msg-hover-actions open" : "msg-hover-actions"}>
                      <button
                        type="button"
                        className="msg-action-btn"
                        aria-label="Add a reaction"
                        title="Add reaction"
                        onClick={() => openEmojiPicker({ mode: "reaction", messageId: threadRoot.id })}
                      >
                        <IconSmile size={15} />
                      </button>
                      <button type="button" className="msg-action-btn" aria-label="Forward message" title="Forward message" onClick={() => openForwardMessage(threadRoot)}>
                        <IconForward size={15} />
                      </button>
                      <button
                        type="button"
                        className="msg-action-btn"
                        aria-label="More actions"
                        aria-haspopup="menu"
                        aria-expanded={menuOpen}
                        onClick={() => setThreadActionMenuMsgId(menuOpen ? null : String(threadRoot.id))}
                      >
                        <IconMoreVertical size={15} />
                      </button>
                      {menuOpen && (
                        <div className="msg-action-menu" role="menu" aria-label="Thread message actions">
                          <button type="button" role="menuitem" className="msg-action-item" onClick={() => toggleSavedMessage(threadRoot)}>
                            <IconStar size={13} />
                            <span>{savedMessageIds.has(String(threadRoot.id)) ? "Remove from saved" : "Save for later"}</span>
                          </button>
                          <button type="button" role="menuitem" className="msg-action-item" onClick={() => void copyMessageLink(threadRoot)}><IconLink size={13} /><span>Copy link</span></button>
                          <button type="button" role="menuitem" className="msg-action-item" onClick={() => void shareMessage(threadRoot)}><IconShare size={13} /><span>Share</span></button>
                          {mine && (
                            <>
                              <button type="button" role="menuitem" className="msg-action-item" onClick={() => beginMessageEdit(threadRoot, "thread")}>
                                <IconEdit size={13} />
                                <span>Edit message</span>
                              </button>
                              <button
                                type="button"
                                role="menuitem"
                                className="msg-action-item danger"
                                onClick={() => {
                                  setThreadActionMenuMsgId(null);
                                  setMessageMutationError(null);
                                  setDeleteMessageTarget({ message: threadRoot, surface: "thread" });
                                }}
                              >
                                <IconTrash size={13} />
                                <span>Delete message</span>
                              </button>
                            </>
                          )}
                        </div>
                      )}
                    </div>
                  )}
                  <button
                    type="button"
                    className="user-avatar-trigger"
                    aria-label={`Open ${nameFor(threadRoot.authorId)}'s profile`}
                    onClick={() => openUserProfile(threadRoot.authorId)}
                  >
                    <UserAvatar
                      className="thread-avatar"
                      userId={threadRoot.authorId}
                      name={nameFor(threadRoot.authorId)}
                      avatarUrl={memberById.get(String(threadRoot.authorId))?.avatarUrl}
                      style={{ background: color.bg, color: color.fg }}
                    />
                  </button>
                  <div className="thread-msg-body">
                    <div className="thread-meta">
                      <button
                        type="button"
                        className="thread-who user-name-trigger"
                        onClick={() => openUserProfile(threadRoot.authorId)}
                      >
                        {nameFor(threadRoot.authorId)}
                      </button>
                      <span className="thread-time">{formatTime(threadRoot.createdAt)}</span>
                      {threadRoot.editedAt && <span className="msg-edited">(edited)</span>}
                    </div>
                    {renderEditableMessageText(threadRoot, "thread", "thread-text")}
                    {renderAttachments(threadRoot, "thread")}
                    {!threadRoot.deletedAt && (
                      <div className="msg-reactions">
                        {(threadRoot.reactions ?? []).map((reaction) => (
                          <button
                            className={reaction.reactedByMe ? "reaction on" : "reaction"}
                            type="button"
                            key={`${threadRoot.id}-${reaction.emoji}`}
                            aria-label={`${reaction.emoji} ${reaction.count}`}
                            onClick={() => void toggleReaction(threadRoot, reaction.emoji)}
                          >
                            <span>{reaction.emoji}</span>
                            <span>{reaction.count}</span>
                          </button>
                        ))}
                        <button
                          className="reaction-add"
                          type="button"
                          aria-label="Add a reaction"
                          title="Add reaction"
                          onClick={() => openEmojiPicker({ mode: "reaction", messageId: threadRoot.id })}
                        >
                          <IconSmile />
                        </button>
                        {emojiPicker?.mode === "reaction" &&
                          String(emojiPicker.messageId) === String(threadRoot.id) &&
                          renderEmojiPicker("message")}
                      </div>
                    )}
                  </div>
                </div>
              );
            })()}

            <div className="thread-count-row">
              <span>
                {threadLoading ? "Loading replies..." : `${threadReplies.length} ${threadReplies.length === 1 ? "reply" : "replies"}`}
              </span>
              <i />
            </div>

            {threadError && <p className="error-text">{threadError}</p>}

            {threadReplies.map((r) => {
              const color = avatarColor(String(r.authorId));
              const mine = String(r.authorId) === String(myUserId);
              const menuOpen = threadActionMenuMsgId === String(r.id);
              return (
                <div className="thread-msg" id={`thread-msg-${r.id}`} key={r.id}>
                  {!r.deletedAt && (
                    <div className={menuOpen ? "msg-hover-actions open" : "msg-hover-actions"}>
                      <button
                        type="button"
                        className="msg-action-btn"
                        aria-label="Add a reaction"
                        title="Add reaction"
                        onClick={() => openEmojiPicker({ mode: "reaction", messageId: r.id })}
                      >
                        <IconSmile size={15} />
                      </button>
                      <button type="button" className="msg-action-btn" aria-label="Forward message" title="Forward message" onClick={() => openForwardMessage(r)}>
                        <IconForward size={15} />
                      </button>
                      <button
                        type="button"
                        className="msg-action-btn"
                        aria-label="More actions"
                        aria-haspopup="menu"
                        aria-expanded={menuOpen}
                        onClick={() => setThreadActionMenuMsgId(menuOpen ? null : String(r.id))}
                      >
                        <IconMoreVertical size={15} />
                      </button>
                      {menuOpen && (
                        <div className="msg-action-menu" role="menu" aria-label="Thread reply actions">
                          <button type="button" role="menuitem" className="msg-action-item" onClick={() => toggleSavedMessage(r)}>
                            <IconStar size={13} />
                            <span>{savedMessageIds.has(String(r.id)) ? "Remove from saved" : "Save for later"}</span>
                          </button>
                          <button type="button" role="menuitem" className="msg-action-item" onClick={() => void copyMessageLink(r)}><IconLink size={13} /><span>Copy link</span></button>
                          <button type="button" role="menuitem" className="msg-action-item" onClick={() => void shareMessage(r)}><IconShare size={13} /><span>Share</span></button>
                          {mine && (
                            <>
                              <button type="button" role="menuitem" className="msg-action-item" onClick={() => beginMessageEdit(r, "thread")}>
                                <IconEdit size={13} />
                                <span>Edit message</span>
                              </button>
                              <button
                                type="button"
                                role="menuitem"
                                className="msg-action-item danger"
                                onClick={() => {
                                  setThreadActionMenuMsgId(null);
                                  setMessageMutationError(null);
                                  setDeleteMessageTarget({ message: r, surface: "thread" });
                                }}
                              >
                                <IconTrash size={13} />
                                <span>Delete message</span>
                              </button>
                            </>
                          )}
                        </div>
                      )}
                    </div>
                  )}
                  <button
                    type="button"
                    className="user-avatar-trigger"
                    aria-label={`Open ${nameFor(r.authorId)}'s profile`}
                    onClick={() => openUserProfile(r.authorId)}
                  >
                    <UserAvatar
                      className="thread-avatar"
                      userId={r.authorId}
                      name={nameFor(r.authorId)}
                      avatarUrl={memberById.get(String(r.authorId))?.avatarUrl}
                      style={{ background: color.bg, color: color.fg }}
                    />
                  </button>
                  <div className="thread-msg-body">
                    <div className="thread-meta">
                      <button
                        type="button"
                        className="thread-who user-name-trigger"
                        onClick={() => openUserProfile(r.authorId)}
                      >
                        {nameFor(r.authorId)}
                      </button>
                      <span className="thread-time">{formatTime(r.createdAt)}</span>
                      {r.editedAt && <span className="msg-edited">(edited)</span>}
                    </div>
                    {renderEditableMessageText(r, "thread", "thread-text")}
                    {renderAttachments(r, "thread")}
                    {!r.deletedAt && (
                      <div className="msg-reactions">
                        {(r.reactions ?? []).map((reaction) => (
                          <button
                            className={reaction.reactedByMe ? "reaction on" : "reaction"}
                            type="button"
                            key={`${r.id}-${reaction.emoji}`}
                            aria-label={`${reaction.emoji} ${reaction.count}`}
                            onClick={() => void toggleReaction(r, reaction.emoji)}
                          >
                            <span>{reaction.emoji}</span>
                            <span>{reaction.count}</span>
                          </button>
                        ))}
                        <button
                          className="reaction-add"
                          type="button"
                          aria-label="Add a reaction"
                          title="Add reaction"
                          onClick={() => openEmojiPicker({ mode: "reaction", messageId: r.id })}
                        >
                          <IconSmile />
                        </button>
                        {emojiPicker?.mode === "reaction" && String(emojiPicker.messageId) === String(r.id) && renderEmojiPicker("message")}
                      </div>
                    )}
                  </div>
                </div>
              );
            })}
          </div>

          <form className="thread-composer" onSubmit={sendThreadReply} onDragOver={(event) => event.preventDefault()} onDrop={(event) => acceptDroppedFiles(event, "thread")}>
            <div className="thread-composer-box">
              {emojiPicker?.mode === "thread" && renderEmojiPicker("composer")}
              <RichTextEditor
                ref={threadInputRef}
                value={threadDraft}
                blocks={threadDraftBlocks}
                compact
                onChange={(next) => {
                  setThreadDraft(next.text);
                  setThreadDraftBlocks(next.blocks);
                }}
                onPasteFiles={(files) => onFilesPicked(files, "thread")}
                onSubmit={() => document.activeElement?.closest("form")?.requestSubmit()}
                placeholder="Reply in thread…"
                ariaLabel="Reply in thread"
              />
              <input
                ref={threadFileInputRef}
                id="thread-file-input"
                type="file"
                multiple
                className="composer-file-input"
                onChange={(event) => onFilesPicked(event.target.files, "thread")}
              />
              {threadSelectedFiles.length > 0 && (
                <div className="composer-files thread-files">
                  {threadSelectedFiles.map((file, index) => (
                    <span className={file.status === "failed" ? "composer-file-chip failed" : "composer-file-chip"} key={file.localId}>
                      <IconClip size={13} />
                      <span>{file.name}</span>
                      <small>{file.status === "uploading" ? "Uploading" : file.status === "failed" ? "Failed" : formatFileSize(file.size)}</small>
                      <button type="button" aria-label={`Remove ${file.name}`} onClick={() => removeSelectedFile(index, "thread")}><IconX size={12} /></button>
                    </span>
                  ))}
                </div>
              )}
              <div className="composer-row">
                {THREAD_TOOLS.map(({ key, Icon, label }) =>
                  key === "attach" ? (
                    <label className="thread-tool" key={key} htmlFor="thread-file-input" aria-label={label} title={label}>
                      <Icon />
                    </label>
                  ) : key === "emoji" ? (
                    <button
                      className="thread-tool"
                      type="button"
                      key={key}
                      aria-label={label}
                      title={label}
                      onClick={() => openEmojiPicker({ mode: "thread" })}
                    >
                      <Icon />
                    </button>
                  ) : (
                    <button className="thread-tool" type="button" key={key} aria-label={label} title="Not built yet" disabled>
                      <Icon />
                    </button>
                  ),
                )}
                <span className="composer-spacer" />
                <button
                  className={threadDraft.trim() || threadSelectedFiles.length > 0 ? "thread-send ready" : "thread-send"}
                  type="submit"
                  aria-label="Send reply"
                  disabled={threadSelectedFiles.some((file) => file.status === "uploading")}
                >
                  <IconSend />
                </button>
              </div>
            </div>
          </form>
        </aside>
      )}

      {/* ── about panel ──────────────────────────────────────────────── */}
      {!threadRoot && !profileRow && showAbout && !viewingDm && (
        <RoomAbout
          workspaceId={workspaceId}
          channelId={channelId}
          channelName={activeChannel?.name}
          channelType={activeChannel?.type}
          members={channelMembers.length > 0 ? channelMembers : members}
          about={about}
          loading={aboutLoading}
          onClose={() => setShowAbout(false)}
          onAddMembers={() => {
            setMemberDialogTab("manage");
            setMemberSearch("");
            setShowAddMembers(true);
          }}
          onRefresh={refreshAbout}
          onJumpToMessage={jumpToMessage}
          onOpenProfile={openUserProfile}
          onLeft={() => router.push(`/workspace?id=${workspaceId}`)}
          onDeleted={() => {
            setChannels((current) => current.filter((row) => String(row.channel.id) !== String(channelId)));
            router.push(`/workspace?id=${workspaceId}`);
          }}
        />
      )}

      {profileRow && (
        <UserProfilePanel
          profile={profileRow}
          presence={presence[String(profileRow.user.id)]?.status}
          isSelf={String(profileRow.user.id) === String(myUserId)}
          busy={callBusy}
          onClose={() => setProfileUserId(null)}
          onMessage={() => {
            setProfileUserId(null);
            void openDm(profileRow.user.id);
          }}
          onConnect={() => void startProfileConnect(profileRow)}
          onEdit={() => router.push(`/settings?workspaceId=${workspaceId}`)}
        />
      )}

      {callPickerOpen && (
        <NewCallModal
          members={members.map((row) => ({ user: row.user }))}
          presence={presence}
          busy={callBusy}
          error={callError}
          onClose={() => setCallPickerOpen(false)}
          onSubmit={async (values: NewCallSubmit) => {
            setCallPickerOpen(false);
            setCallBusy(true);
            setCallError(null);
            try {
              const call = await api.startCall(workspaceId, {
                kind: values.kind,
                inviteeUserIds: values.userIds,
                channelId,
                title: values.title || null,
              });
              setActiveCall(call);
            } catch (err) {
              setCallError(err instanceof ApiError ? JSON.stringify(err.body) : String(err));
            } finally {
              setCallBusy(false);
            }
          }}
        />
      )}

      {activeCall && myUserId != null && (
        <CallOverlay
          workspaceId={workspaceId}
          call={activeCall}
          myUserId={myUserId}
          nameById={nameById}
          avatarById={avatarById}
          onLeave={() => void leaveActiveCall()}
          onEnd={() => void endActiveCall()}
        />
      )}

      {callError && (
        <p className="error-text" style={{ position: "fixed", bottom: 24, left: "50%", transform: "translateX(-50%)", zIndex: 1200 }}>
          {callError}
        </p>
      )}

      <CommandPalette workspaceId={workspaceId} channels={textChannels.map((row) => row.channel)} />

      {actionNotice && <div className="message-action-toast" role="status" aria-live="polite"><IconCheck />{actionNotice}</div>}

      {forwardMessageTarget && (
        <ForwardMessageModal
          message={forwardMessageTarget}
          authorName={nameFor(forwardMessageTarget.authorId)}
          sourceLabel={viewingDm ? conversationTitle : `#${activeChannel?.name ?? "channel"}`}
          conversations={channels}
          busy={forwardBusy}
          error={forwardError}
          onClose={() => { if (!forwardBusy) setForwardMessageTarget(null); }}
          onSubmit={(destinationIds, note) => void forwardMessage(destinationIds, note)}
        />
      )}

      {showClosedDms && (
        <div className="channel-invite-backdrop" role="presentation" onClick={() => setShowClosedDms(false)}>
          <div className="channel-invite-modal compact" role="dialog" aria-modal="true" aria-labelledby="closed-dms-title" onClick={(e) => e.stopPropagation()}>
            <div className="channel-invite-head">
              <div><h2 id="closed-dms-title">Reopen a direct message</h2><p>Closed conversations keep their complete message history.</p></div>
              <button className="thread-close" type="button" onClick={() => setShowClosedDms(false)} aria-label="Close"><IconX /></button>
            </div>
            <div className="channel-invite-list">
              {channels.filter((row) => isDmChannel(row.channel.type) && row.member?.isClosed).map((row) => (
                <button key={row.channel.id} type="button" className="channel-invite-row" disabled={actionBusy} onClick={async () => {
                  await setDmClosed(row.channel.id, false);
                  setShowClosedDms(false);
                  openChannel(row.channel.id);
                }}>
                  <span className="channel-invite-meta"><strong>{dmTitle(row)}</strong><span>{row.channel.type === "group_dm" ? "Group DM" : "Direct message"}</span></span>
                  <span className="screen-btn primary">Reopen</span>
                </button>
              ))}
              {!channels.some((row) => isDmChannel(row.channel.type) && row.member?.isClosed) && <div className="empty-state">No closed direct messages.</div>}
            </div>
          </div>
        </div>
      )}

      {(showRenameGroupDm || showConvertGroupDm) && (
        <div className="channel-invite-backdrop" role="presentation" onClick={() => { setShowRenameGroupDm(false); setShowConvertGroupDm(false); }}>
          <form className="channel-invite-modal compact" role="dialog" aria-modal="true" aria-labelledby="group-dm-action-title" onSubmit={(e) => {
            e.preventDefault();
            void (showConvertGroupDm ? convertGroupDm() : renameGroupDm());
          }} onClick={(e) => e.stopPropagation()}>
            <div className="channel-invite-head">
              <div>
                <h2 id="group-dm-action-title">{showConvertGroupDm ? "Convert to private channel" : "Rename group DM"}</h2>
                <p>{showConvertGroupDm ? "Messages, files and all participants will move into the private channel." : "Choose a recognizable name for this conversation."}</p>
              </div>
              <button className="thread-close" type="button" onClick={() => { setShowRenameGroupDm(false); setShowConvertGroupDm(false); }} aria-label="Close"><IconX /></button>
            </div>
            <div className="channel-manage-form">
              <label><span>{showConvertGroupDm ? "Private channel name" : "Group name"}</span><input autoFocus required maxLength={80} value={groupDmName} onChange={(e) => setGroupDmName(e.target.value)} /></label>
            </div>
            <div className="channel-invite-foot">
              <button type="button" className="screen-btn" onClick={() => { setShowRenameGroupDm(false); setShowConvertGroupDm(false); }}>Cancel</button>
              <button type="submit" className="screen-btn primary" disabled={actionBusy || !groupDmName.trim()}>{actionBusy ? "Saving…" : showConvertGroupDm ? "Convert" : "Rename"}</button>
            </div>
          </form>
        </div>
      )}

      {showAddMembers && !viewingDm && (
        <div className="channel-invite-backdrop" role="presentation" onClick={() => setShowAddMembers(false)}>
          <div className="channel-invite-modal slack-member-dialog" role="dialog" aria-modal="true" aria-labelledby="channel-invite-title" onClick={(event) => event.stopPropagation()}>
            <div className="channel-invite-head slack-member-head">
              <div>
                <h2 id="channel-invite-title">{memberDialogTab === "add" ? "Add people" : "Manage members"}</h2>
                <p>#{activeChannel?.name ?? "channel"}</p>
              </div>
              <button className="thread-close slack-dialog-close" type="button" onClick={() => setShowAddMembers(false)} aria-label="Close"><IconX /></button>
            </div>

            <div className="slack-member-tabs" role="tablist" aria-label="Channel member actions">
              <button type="button" role="tab" aria-selected={memberDialogTab === "add"} className={memberDialogTab === "add" ? "active" : ""} onClick={() => { setMemberDialogTab("add"); setMemberSearch(""); }}>Add people</button>
              <button type="button" role="tab" aria-selected={memberDialogTab === "manage"} className={memberDialogTab === "manage" ? "active" : ""} onClick={() => { setMemberDialogTab("manage"); setMemberSearch(""); }}>Manage members <span>{channelMembers.length}</span></button>
            </div>

            {memberDialogTab === "add" && selectedAddMembers.length > 0 && (
              <div className="slack-member-chips" aria-label="Selected people">
                {selectedAddMembers.map((row) => (
                  <span className="slack-member-chip" key={`selected-${row.user.id}`}>
                    <UserAvatar className="slack-member-chip-avatar" userId={row.user.id} name={row.user.name} avatarUrl={row.user.avatarUrl} />
                    <span>{row.user.name}</span>
                    <button type="button" aria-label={`Remove ${row.user.name} from selection`} onClick={() => togglePendingAdd(row.user.id)}><IconX /></button>
                  </span>
                ))}
              </div>
            )}

            <label className="slack-member-search">
              <IconSearch />
              <input autoFocus value={memberSearch} onChange={(event) => setMemberSearch(event.target.value)} placeholder={memberDialogTab === "add" ? "Search by name or email" : "Find a member"} aria-label={memberDialogTab === "add" ? "Search people to add" : "Search channel members"} />
            </label>

            <div className="channel-invite-list slack-member-list">
              {memberDialogTab === "add" ? (
                <>
                  {filteredAddableMembers.map((row) => {
                    const selected = pendingAddIds.some((id) => String(id) === String(row.user.id));
                    return (
                      <button key={String(row.user.id)} type="button" className={selected ? "channel-invite-row selected" : "channel-invite-row"} onClick={() => togglePendingAdd(row.user.id)}>
                        <UserAvatar className="list-row-avatar" userId={row.user.id} name={row.user.name} avatarUrl={row.user.avatarUrl} />
                        <span className="channel-invite-meta"><strong>{row.user.name}</strong><span>{row.user.email}</span></span>
                        <span className="slack-member-checkbox" aria-hidden="true">{selected && <IconCheck />}</span>
                      </button>
                    );
                  })}
                  {filteredAddableMembers.length === 0 && (
                    <div className="slack-member-empty"><strong>{addableMembers.length === 0 ? "Everyone is already here" : "No people found"}</strong><span>{addableMembers.length === 0 ? `All workspace members are already in #${activeChannel?.name ?? "channel"}.` : "Try another name or email address."}</span></div>
                  )}
                </>
              ) : (
                <>
                  {filteredCurrentMembers.map((row) => (
                    <div className="channel-invite-row" key={`current-${row.user.id}`}>
                      <UserAvatar className="list-row-avatar" userId={row.user.id} name={row.user.name} avatarUrl={row.user.avatarUrl} />
                      <span className="channel-invite-meta"><strong>{row.user.name}{String(row.user.id) === String(myUserId) ? " (you)" : ""}</strong><span>{row.member.role}</span></span>
                      {about?.capabilities.canRemoveMembers && String(row.user.id) !== String(myUserId) && activeChannel?.name?.toLowerCase() !== "general" && (
                        <button className="slack-member-remove" type="button" disabled={actionBusy} onClick={() => { if (window.confirm(`Remove ${row.user.name} from #${activeChannel?.name ?? "channel"}?`)) void removeMemberFromActiveChannel(row.user.id); }}>Remove</button>
                      )}
                    </div>
                  ))}
                  {filteredCurrentMembers.length === 0 && <div className="slack-member-empty"><strong>No members found</strong><span>Try another name or email address.</span></div>}
                </>
              )}
            </div>

            <div className="channel-invite-foot slack-member-foot">
              <Link href={`/people?workspaceId=${workspaceId}&add=1`}>Invite someone new to this workspace</Link>
              {memberDialogTab === "add" && <button className="slack-member-add" type="button" disabled={actionBusy || pendingAddIds.length === 0} onClick={() => void addSelectedMembers()}>{actionBusy ? "Adding..." : pendingAddIds.length > 0 ? `Add ${pendingAddIds.length}` : "Add"}</button>}
            </div>
          </div>
        </div>
      )}

      {false && showAddMembers && !viewingDm && (
        <div className="channel-invite-backdrop" role="presentation" onClick={() => setShowAddMembers(false)}>
          <div
            className="channel-invite-modal"
            role="dialog"
            aria-modal="true"
            aria-labelledby="channel-invite-title"
            onClick={(e) => e.stopPropagation()}
          >
            <div className="channel-invite-head">
              <div>
                <h2 id="channel-invite-title">Add people to #{activeChannel?.name ?? "channel"}</h2>
                <p>Choose workspace members who should join this channel.</p>
              </div>
              <button className="thread-close" type="button" onClick={() => setShowAddMembers(false)} aria-label="Close">
                <IconX />
              </button>
            </div>

            <div className="channel-invite-list">
              {channelMembers.length > 0 && (
                <div className="channel-member-management">
                  <strong>Current members</strong>
                  {channelMembers.map((row) => (
                    <div className="channel-invite-row" key={`current-${row.user.id}`}>
                      <UserAvatar className="list-row-avatar" userId={row.user.id} name={row.user.name} avatarUrl={row.user.avatarUrl} />
                      <span className="channel-invite-meta"><strong>{row.user.name}</strong><span>{row.member.role}</span></span>
                      {about?.capabilities.canRemoveMembers && String(row.user.id) !== String(myUserId) && activeChannel?.name?.toLowerCase() !== "general" && (
                        <button className="screen-btn danger compact" type="button" disabled={actionBusy} onClick={() => {
                          if (window.confirm(`Remove ${row.user.name} from #${activeChannel?.name ?? "channel"}?`)) void removeMemberFromActiveChannel(row.user.id);
                        }}>Remove</button>
                      )}
                    </div>
                  ))}
                </div>
              )}
              <strong className="channel-invite-subtitle">Add workspace members</strong>
              {addableMembers.map((row) => {
                const selected = pendingAddIds.some((id) => String(id) === String(row.user.id));
                const color = avatarColor(String(row.user.id));
                return (
                  <button
                    key={String(row.user.id)}
                    type="button"
                    className={selected ? "channel-invite-row selected" : "channel-invite-row"}
                    onClick={() => togglePendingAdd(row.user.id)}
                  >
                    <span className="list-row-avatar" style={{ background: color.bg, color: color.fg }}>
                      {initials(row.user.name)}
                    </span>
                    <span className="channel-invite-meta">
                      <strong>{row.user.name}</strong>
                      <span>{row.user.email}</span>
                    </span>
                    <span className="channel-invite-check" aria-hidden="true">
                      {selected ? "✓" : ""}
                    </span>
                  </button>
                );
              })}
              {addableMembers.length === 0 && (
                <div className="empty-state" style={{ padding: "18px 8px" }}>
                  Everyone in this workspace is already in the channel — or invite someone new below.
                </div>
              )}
            </div>

            <div className="channel-invite-foot">
              <Link className="screen-btn" href={`/people?workspaceId=${workspaceId}&add=1`}>
                Invite new to workspace
              </Link>
              <button
                className="screen-btn primary"
                type="button"
                disabled={actionBusy || pendingAddIds.length === 0}
                onClick={() => void addSelectedMembers()}
              >
                {actionBusy ? "Adding…" : `Add ${pendingAddIds.length}`}
              </button>
            </div>
          </div>
        </div>
      )}

      {deleteMessageTarget && (
        <div
          className="channel-invite-backdrop"
          role="presentation"
          onClick={() => {
            if (!messageMutationBusy) setDeleteMessageTarget(null);
          }}
        >
          <div
            className="channel-invite-modal confirm"
            role="alertdialog"
            aria-modal="true"
            aria-labelledby="delete-message-title"
            aria-describedby="delete-message-description"
            onClick={(event) => event.stopPropagation()}
          >
            <div className="channel-invite-head">
              <div>
                <h2 id="delete-message-title">Delete message?</h2>
                <p id="delete-message-description">This action cannot be undone.</p>
              </div>
              <button
                className="thread-close"
                type="button"
                onClick={() => setDeleteMessageTarget(null)}
                aria-label="Close"
                disabled={messageMutationBusy}
              >
                <IconX />
              </button>
            </div>
            <blockquote className="message-delete-preview">
              {deleteMessageTarget.message.text || "Message with attachment"}
            </blockquote>
            {messageMutationError && <p className="message-delete-error" role="alert">{messageMutationError}</p>}
            <div className="channel-invite-foot">
              <button type="button" className="screen-btn" onClick={() => setDeleteMessageTarget(null)} disabled={messageMutationBusy}>
                Cancel
              </button>
              <button type="button" className="screen-btn danger" onClick={() => void confirmDeleteMessage()} disabled={messageMutationBusy}>
                {messageMutationBusy ? "Deleting..." : "Delete message"}
              </button>
            </div>
          </div>
        </div>
      )}

      {taskModal && (
        <TaskModal
          title={taskModal.mode === "create" ? "Create task" : "Edit task"}
          mode={taskModal.mode}
          busy={taskBusy}
          error={taskError}
          initial={
            taskModal.mode === "create"
              ? {
                  title: (taskModal.message.text || "(attachment)").slice(0, 80),
                  description: taskModal.message.text || "",
                  dueAt: parseTaskDueAt(taskModal.message.text),
                }
              : {
                  title: taskModal.task.title,
                  description: taskModal.task.description ?? "",
                  assigneeUserId:
                    taskModal.task.assigneeUserId != null ? String(taskModal.task.assigneeUserId) : null,
                  dueAt: taskModal.task.dueAt,
                  channelId: taskModal.task.channelId != null ? String(taskModal.task.channelId) : null,
                  status: taskModal.task.status,
                }
          }
          members={channelMembers.length > 0 ? channelMembers : members}
          channels={textChannels}
          lockedChannelName={viewingDm ? conversationTitle : `#${activeChannel?.name ?? "channel"}`}
          createdByName={
            taskModal.mode === "edit"
              ? myUserId != null && String(taskModal.task.createdBy) === String(myUserId)
                ? "you"
                : nameById.get(String(taskModal.task.createdBy)) ?? "Someone"
              : undefined
          }
          onClose={() => setTaskModal(null)}
          onSubmit={(values) => void submitTaskModal(values)}
          onDelete={taskModal.mode === "edit" ? () => void deleteTaskFromModal() : undefined}
        />
      )}
    </main>
  );
}

export default function ChannelPage() {
  return (
    <Suspense fallback={<main className="page" />}>
      <ChannelView />
    </Suspense>
  );
}
