/** * @dsh-local/im-conversation-tabs — client 半边(**E 单主交付**)。 * * ## 落点 = C′(2026-09-23 用户拍板,⛔ 不再征询) * 接管 dsh 会话区 `[data-slot="main.conversation"]`(旧名 `conversation` 兜底), * 顶部一行 tab:`dsh 会话` / `群组会话 1` / … / `群组会话 5`。 * * ## 🔴 四条硬约束(源 = E 单 §四-4 / §五 / 入口 §2 环境注意) * ① **多选择器兜底 + 失效可见提示** —— 官方 0.1.5-rc.1 把 `conversation` 改名 * `main.conversation`,单一选择器 ⇒ **静默失效**(MCN 被咬过「点了没反应」)。 * ⇒ 本文件失配时**在页面上打出可见提示条**,⛔ 绝不静默。 * ② **flex 由 CSS 规则 + `!important` 驱动,⛔ 不用 inline style** —— React 重渲染 * 会话区会重置 inline style(MCN 注释已写明)⇒ 布局一律走 `#imtabs-style` 规则。 * ③ **卸载必须反向还原 DOM**(会话区插回原位),否则切走再回来结构会坏。 * ④ **IM 数据走同源相对路径 `/api/im/**`** —— 实例内带 `sid` cookie ⇒ * `imAuth` 认**用户本人**;⛔ 不硬编码主机 / 区域(多区域联邦:数据不动、请求动)。 * * ## 🔌 帧协议 = **op 制**(⛔ 不是 `type`)—— 第 13 棒修的就是这里 * 契约源 = 平台 `src/im/ws.ts`。**两侧字面量必须精确对齐**: * - **出站**(客户端 → 服务端,只发这三种,见 `WS_OPS_OUT`): * `{op:'subscribe', roomId}` · `{op:'resume', roomId, since}` · `{op:'ping', ts}` * 🔴 **`ping` 的坑**:`ws.ts` 的模块注释把客户端心跳写成 `{op:'pong'}`,但**实现里没有 * `case 'pong'`** ⇒ 发 `pong` 会落进 `default` ⇒ `reject('bad-op')` ⇒ **`socket.destroy()`**。 * ⇒ 只认实现,不认注释;`{op:'ping'}` 才是已实现的客户端心跳(服务端回 `pong`)。 * - **入站**(服务端 → 客户端,全量兜住,见 `WS_OPS_IN`):`hello` · `subscribed` · * `unsubscribed` · `message`(`messages` 是**数组**)· `messages`(补拉结果,同形)· * `presence`(`members` 是**数组**)· `sent` · `error` · `pong`。 * ⛔ `error` 不得静默丢弃 —— 服务端 `reject()` 的场景(`bad-op` / `not-a-member` / * 预算拦下)都要让用户**看得见**。 * - 🔴 **为什么错一个 op 就是"连上就断"**:服务端对**任何**未认 op 都走 * `reject()`(回一帧 `error` 后 `destroy()`)⇒ 客户端一轮重连风暴、一条消息看不到。 * 第 12 棒 §15.7 记的 P0 即此。 * * ## 🔀 双模传输(第 16 棒 · 选型单 §十六)—— 应用层帧**两侧逐字相同** * 连接层可以是**平台进程内**(`native`)也可以是**外部连接层**(`gateway`)。本文件按 * `GET /api/im/transport` 下发的 `protocol` 选一套**信封**,⛔ **不许硬编码**(那是部署态)。 * - `native`(默认):照上面「op 制」走,**逐字不变**。 * - `gateway`:信封换成外部层协议,应用层帧仍是同一个 `{op:…}` 对象: * `{id, connect:{token}}` → `{id, subscribe:{channel, token}}` → 收 `push.pub.data.frame` * 后 `JSON.parse` 回原帧,**照原路径分发**(`onAppFrame`)。 * ⚠️ 此时**不发** `{op:'ping'}`:外部层按 `client.ping_interval` 发**协议级 ping**(一个**空对象** * `{}`)⇒ 客户端**按连接回一个空对象**即可(回错形态 / 漏回 ⇒ 连接被判 `3012 no pong` 或 * `3501 bad request` 断开,见 47 三向实测)。保活死线(②③两条)在两种模式下**都**生效。 * * ## 🔴 保活口径(与 `src/im/keepalive.ts` 同源) * ① 周期 = `planKeepalive()` 同款口径:空窗 `26000 ms` ÷ `3` 个周期 ⇒ **8666 ms**; * ② 🔴 **计时起点 = 本连接自己的 `open` 时刻**,⛔ 不是模块加载 / 组件挂载时刻 * (E-2 的适配器缺陷正是"整进程统一计时"⇒ 10k 一次掉 2773 条); * ③ 超空窗仍无**任何入向流量** ⇒ 主动 `close()`(半开链路检测,对应垫片的 `onExpire`)。 * ⚠️ 第 16 棒修正一处旧口径:外部层**不是**"不发任何服务端 ping" —— 它不发 **WS 控制帧**, * 但**会发协议级 JSON ping**(`{}`)。保活仍**必须**由客户端兜住(平台侧拨不到外部层的连接), * 所以 ①②③ 一条都不删。 * * ## ⛔ 不触 R2 * 全程只走官方公开扩展面(`data-slot` 可寻址 DOM 槽)⇒ ⛔ 不改 dsh 主程序 / * client bundle / 缓存。外壳形状照 MCN 已上生产的做法(`dsh-plugin-mcn/lib/client.js`)。 * * ## 借鉴(⛔ 不引整站) * 借鉴优秀群聊项目的**交互与信息架构**(E 单 §四-5):房间列表的呈现、在线态点、 * 加入/退出的按钮位置与二次确认、消息气泡与 @ 选择器 —— **自建组件**, * ⛔ 不引 Rocket.Chat / Mattermost 那类整站应用(数据模型不同构,剥离成本 ≈ 重写)。 * * @module @dsh-local/im-conversation-tabs/client */ window.__ModuleLoader__.load({ id: 'im-conversation-tabs', factory: (require) => { var module = { exports: {} } var exports = module.exports Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }) const react = require('react') const jsx = require('react/jsx-runtime') const { createPortal } = require('react-dom') // ── 常量 ──────────────────────────────────────────────────────────────── /** 群组 tab 上限(E 单 §四-4 拍板 = 5)。⛔ 超限具名提示,不静默丢弃。 */ const MAX_GROUP_TABS = 5 /** i18n 命名空间(与 dsh 官方 locale 扩展点一致)。 */ const NS = 'im-conversation-tabs' /** 同源相对路径基址(⛔ 不硬编码主机 / 区域)。 */ const API = '/api/im' /** 重连退避(E 单 §六.1-8:60 s 内尝试 ≤ 8 次,2^n 封顶 + 抖动)。 */ const RECONNECT_BASE_MS = 1000 const RECONNECT_MAX_MS = 15000 const RECONNECT_JITTER_MS = 400 /** * 保活计划(🔴 与 `src/im/keepalive.ts` 的 `planKeepalive()` **同源同值**)。 * 空窗 26 s = E-2 实测外部连接层断连阈值(`IM_GATEWAY_OBSERVED_IDLE_MS`); * ÷ 3 个周期 ⇒ 周期 8666 ms ⇒ ping 三次才用完一个空窗。 */ const WS_IDLE_TIMEOUT_MS = 26000 const WS_PING_CYCLES = 3 const WS_PING_MS = Math.max(1000, Math.floor(WS_IDLE_TIMEOUT_MS / WS_PING_CYCLES)) /** 死线扫描粒度(对应垫片的 `sweepMs`:**一个**定时器扫死线,⛔ 不是每周期一个定时器)。 */ const WS_PING_TICK_MS = 1000 /** * 🔴 出站帧**只允许**这些 op(源 = 平台 `src/im/ws.ts:264-347` 的 `switch` 的已实现分支)。 * ⛔ 不在此列的 op ⇒ 服务端 `reject('bad-op')` + `socket.destroy()`。 * ⚠️ `pong` **不在这里** —— 它是**服务端回帧**;模块注释把它写成客户端帧是**注释与实现不一致**。 */ const WS_OPS_OUT = ['subscribe', 'resume', 'ping'] /** 入站 op 全量(源 = `ws.ts` 的推帧 + `hub.ts` 的扇出帧)⇒ ⛔ 不许有漏网分支。 */ const WS_OPS_IN = ['hello', 'subscribed', 'unsubscribed', 'message', 'messages', 'presence', 'sent', 'error', 'pong'] // ── 网关协议(第 16 棒 · 选型单 §十六)────────────────────────────────────── /** * 两套协议的名字(**由平台下发**,⛔ 客户端不猜)。 * `op` = 平台进程内通道(原路径)|`gateway` = 外部连接层。 */ const PROTOCOL_OP = 'op' const PROTOCOL_GATEWAY = 'gateway' /** * 🔴 **协议级 ping 的形态 = 一个空对象**。 * 外部层按 `client.ping_interval` 发它;客户端**必须按连接回一个空对象**: * 不回 ⇒ 被判 `3012 no pong`;回错(例如把别的命令塞进去)⇒ 被判 `3501 bad request`。 * (判据 = 47 就地部署实测:off 80/80 断 · conn 掉 0 · global 80/80 断。) */ const GATEWAY_PONG = '{}' /** 网关命令的 id 从 1 起(0 = 服务端的**推送**标记,⛔ 客户端不许用 0)。 */ const GATEWAY_FIRST_ID = 1 /** * 是不是"空对象帧"(= 协议级 ping)。 * 判据严格:**对象、非数组、零个自有键**。⛔ 不把 `null` / `[]` / `{id:0}` 误判成 ping。 */ function isGatewayPing(msg) { if (msg === null || typeof msg !== 'object' || Array.isArray(msg)) return false for (const k in msg) { if (Object.prototype.hasOwnProperty.call(msg, k)) return false } return true } /** 造一条网关命令(`{id, : payload}`)。⛔ id 从 1 起、⛔ 不用 0。 */ function gatewayCommand(id, method, payload) { const cmd = { id: id } cmd[method] = payload === undefined ? {} : payload return cmd } /** * 从推送帧里取出**应用层原帧字符串**(网关契约:`pub.data.frame` 是平台发出来的那一串)。 * ⛔ 不做任何加工 —— 取到就 `JSON.parse` 后照原路径分发,这是"换连接层不动协议"的落点。 * 不是 pub 推送(join/leave 之类)或形态不对 ⇒ 回 `null`(由调用方忽略)。 */ function appFrameOfPush(msg) { const push = msg === null || typeof msg !== 'object' ? null : msg.push if (push === null || typeof push !== 'object') return null const pub = push.pub if (pub === null || typeof pub !== 'object') return null const data = pub.data if (data === null || typeof data !== 'object') return null return typeof data.frame === 'string' ? data.frame : null } /** 服务端**建议断开**(带 code / reason)—— 读出来给用户看,⛔ 不当成普通错误吞掉。 */ function disconnectInfoOf(msg) { const d = msg === null || typeof msg !== 'object' ? null : msg.disconnect if (d === null || typeof d !== 'object') return null const code = numOf(d.code) const reason = typeof d.reason === 'string' ? d.reason : '' return { code: code, reason: reason } } /** 选择器清单 —— **顺序即优先级**(新名优先,旧名兜底)。🔴 单一选择器会静默失效。 */ const CONVERSATION_SLOT_SELECTORS = [ '[data-slot="main.conversation"]', '[data-slot="conversation"]', ] /** 词条(zh / en 双侧齐,E 单 §六-6;⛔ 无硬编码中文残留)。 */ const DICT = { zh: { 'tab.dsh': 'dsh 会话', 'tab.group': '群组会话 {n}', 'tab.close': '关闭群组会话 {n}', 'tab.add': '新建群组会话', 'tab.limit': '群组会话最多 5 个:请先关闭一个再新建', 'slot.missing': '群组会话不可用:未找到 dsh 会话区(官方槽位可能已改名)', 'slot.missingDetail': '期望选择器:{sel}。请把该提示反馈给平台管理员。', 'disc.title': '新建群组会话', 'disc.intro': '下面是可以加入的群。点「加入」即可进入并自动开一个 tab。', 'disc.loading': '正在加载…', 'disc.empty': '当前没有可加入的群。', 'disc.emptyHint': '若确认已有公开群,请让群主把群设为「可发现 + 允许自助加入」。', 'disc.join': '加入', 'disc.joined': '已在群里', 'disc.full': '已满员', 'disc.retry': '重试', 'disc.close': '关闭', 'disc.members': '{n} 人', 'disc.newRoom': '新建群', 'disc.newRoomName': '群名称', 'disc.newRoomCreate': '创建并进入', 'disc.errNotDiscoverable': '该群不存在或不可加入', 'disc.errClosed': '该群需要邀请才能加入', 'disc.errFull': '该群已满员', 'disc.errNetwork': '网络异常,请稍后重试', 'room.empty': '这个群还没有消息,发送第一条吧。', 'room.loading': '正在加载消息…', 'room.placeholder': '说点什么…(Enter 发送,Shift+Enter 换行)', 'room.send': '发送', 'room.sending': '发送中…', 'room.cursor': '已同步至 #{seq}', 'room.members': '成员 {n}', 'room.online': '在线 {n}', 'presence.online': '在线', 'presence.offline': '离线', 'kind.human': '成员', 'kind.bot': '机器人', 'agent.hooked': '已挂助手', 'agent.on': '待应答:开', 'agent.off': '待应答:关', 'agent.toggle': '切换「待应答」(打开后助手会在被 @ 时代答)', 'agent.badge': '由 {name} 的助手代答', 'agent.self': '由我的助手代答', 'conn.online': '已连接', 'conn.reconnecting': '重连中(第 {n} 次)', 'conn.offline': '已断开', 'conn.retryNow': '立即重试', 'msg.system': '系统', 'msg.private': '仅自己可见', 'err.send': '发送失败:{err}', 'err.load': '加载失败:{err}', 'err.ws': '链路报错:{err}', }, en: { 'tab.dsh': 'dsh chat', 'tab.group': 'Group {n}', 'tab.close': 'Close group chat {n}', 'tab.add': 'New group chat', 'tab.limit': 'At most 5 group chats: close one before opening another', 'slot.missing': 'Group chat unavailable: the dsh conversation slot was not found (the official slot may have been renamed)', 'slot.missingDetail': 'Expected selectors: {sel}. Please report this to the platform admin.', 'disc.title': 'New group chat', 'disc.intro': 'These groups are open to join. Click "Join" to enter — a tab opens automatically.', 'disc.loading': 'Loading…', 'disc.empty': 'No joinable groups right now.', 'disc.emptyHint': 'If a public group exists, ask its owner to enable "discoverable + self-join".', 'disc.join': 'Join', 'disc.joined': 'Already joined', 'disc.full': 'Full', 'disc.retry': 'Retry', 'disc.close': 'Close', 'disc.members': '{n} members', 'disc.newRoom': 'New group', 'disc.newRoomName': 'Group name', 'disc.newRoomCreate': 'Create and enter', 'disc.errNotDiscoverable': 'That group does not exist or is not joinable', 'disc.errClosed': 'That group requires an invitation', 'disc.errFull': 'That group is full', 'disc.errNetwork': 'Network error, please retry later', 'room.empty': 'No messages yet. Send the first one.', 'room.loading': 'Loading messages…', 'room.placeholder': 'Say something… (Enter to send, Shift+Enter for newline)', 'room.send': 'Send', 'room.sending': 'Sending…', 'room.cursor': 'Synced to #{seq}', 'room.members': '{n} members', 'room.online': '{n} online', 'presence.online': 'Online', 'presence.offline': 'Offline', 'kind.human': 'Member', 'kind.bot': 'Bot', 'agent.hooked': 'Assistant attached', 'agent.on': 'Auto-reply: on', 'agent.off': 'Auto-reply: off', 'agent.toggle': 'Toggle auto-reply (your assistant answers when you are @-mentioned)', 'agent.badge': 'Answered by {name}\'s assistant', 'agent.self': 'Answered by my assistant', 'conn.online': 'Connected', 'conn.reconnecting': 'Reconnecting (attempt {n})', 'conn.offline': 'Disconnected', 'conn.retryNow': 'Retry now', 'msg.system': 'System', 'msg.private': 'Visible only to you', 'err.send': 'Send failed: {err}', 'err.load': 'Load failed: {err}', 'err.ws': 'Link error: {err}', }, } /** 当前语言(官方 locale 快照可能是对象,见 portal-entry v0.5.6 的教训)。 */ function activeLang(ctx) { try { const snap = ctx?.locale?.getSnapshot?.() ?? ctx?.locale?.getLocale?.() const id = typeof snap === 'string' ? snap : snap?.active ?? snap?.locale return typeof id === 'string' && id.startsWith('en') ? 'en' : 'zh' } catch { return 'zh' } } /** 取词条(缺键 ⇒ 回 zh ⇒ 回键名本身,⛔ 不抛错、不空串)。 */ function tOf(lang, key, vars) { const table = DICT[lang] ?? DICT.zh let text = table[key] ?? DICT.zh[key] ?? key if (vars !== undefined) { for (const name of Object.keys(vars)) { text = text.split('{' + name + '}').join(String(vars[name])) } } return text } // ── HTTP(同源相对路径,⛔ 不带主机名)──────────────────────────────── /** 带 cookie 的同源请求;非 2xx ⇒ 抛出**带具名 code** 的错误(供 UI 显示后端原文)。 */ async function apiFetch(path, init) { const res = await fetch(API + path, { credentials: 'include', headers: { 'content-type': 'application/json' }, ...(init ?? {}), }) let body = null try { body = await res.json() } catch { body = null } if (!res.ok) { const err = new Error((body && (body.error || body.detail)) || 'http-' + res.status) err.status = res.status err.code = body && body.error err.detail = body && body.detail throw err } return body } // ── WS 传输层(🔴 帧协议 = op 制,⛔ 不是 type;见模块头)────────────────── /** 数值归一 —— 服务端的 `seq` / `cursor` 一律按安全整数对待。 */ function numOf(v) { return typeof v === 'number' && Number.isFinite(v) ? Math.trunc(v) : 0 } /** * 出站帧的**唯一入口**。🔴 白名单 = `WS_OPS_OUT`。 * ⛔ 出 `type` 字段、⛔ 出未认 op(典型:把心跳写成 `pong`)都会被服务端 * `reject('bad-op')` + `destroy()` 掉整条连接 —— 表现就是「连上就断」。 * @returns 是否真的发出去了(`false` 由调用方按"关闭竞态"处理) */ function sendOp(ws, op, body) { if (WS_OPS_OUT.indexOf(op) < 0) { if (window.console && window.console.warn) { window.console.warn('[im-conversation-tabs] 拒绝发送未认的 op:' + String(op)) } return false } try { ws.send(JSON.stringify(Object.assign({ op: op }, body || {}))) return true } catch { return false // 关闭竞态 ⇒ 交给 onclose 走重连 } } /** * **网关模式**的发帧入口(⛔ 不走 `WS_OPS_OUT` 白名单)。 * * 🔴 为什么要单独一个入口:白名单是 **op 制(native)** 的约束 —— 网关协议用的是 * `{id, connect|subscribe}` / 空对象,跟 op 制**是两套信封**。把网关帧塞进 `sendOp` * 会被白名单挡下(正确的行为)⇒ 两套信封必须各有各的入口,⛔ 不合并成一个"宽松版"。 * @returns 是否真的发出去了(`false` 由调用方按"关闭竞态"处理) */ function sendRaw(ws, frame) { try { ws.send(typeof frame === 'string' ? frame : JSON.stringify(frame)) return true } catch { return false // 关闭竞态 ⇒ 交给 onclose 走重连 } } // ── 会话区接管(硬约束 ①②③ 全在这里)──────────────────────────────── /** * 找到会话区槽(**多选择器兜底**)。返回 `{el, selector}` 或 `null`。 * ⛔ 返回命中的**那个选择器**,失配提示里要写清期望值(可诊断)。 */ function findConversationSlot() { for (const sel of CONVERSATION_SLOT_SELECTORS) { const el = document.querySelector(sel) if (el !== null) return { el, selector: sel } } return null } /** 注入一次插件样式(`id` 去重,⛔ 不重复插)。 */ function injectStyleOnce(id, css) { if (document.getElementById(id) !== null) return const style = document.createElement('style') style.id = id style.textContent = css document.head.appendChild(style) } /** 插件 CSS(🔴 布局全在这里,用 `!important` 压 React 的 inline style)。 */ const CSS = ` .imtabs-root{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0;width:100%;height:100%;overflow:hidden;background:var(--dsw-alias-bg-base)} .imtabs-bar{flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:6px 10px;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));background:var(--dsw-alias-bg-base);overflow-x:auto;scrollbar-width:thin} .imtabs-tab{display:inline-flex;align-items:center;gap:6px;font:var(--dsw-font-s-14);color:var(--dsw-alias-label-secondary,#8a8f98);cursor:pointer;background:0 0;border:0;border-radius:8px;padding:6px 14px;white-space:nowrap;transition:color var(--ds-transition-duration-fast) var(--ds-ease-in-out),background var(--ds-transition-duration-fast) var(--ds-ease-in-out);font-family:inherit} .imtabs-tab:hover{color:var(--imtabs-brand,#4176e6);background:var(--dsw-alias-interactive-bg-hover)} .imtabs-tabActive{color:var(--imtabs-brand,#4176e6);font-weight:600;background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover))} .imtabs-tabDot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#22c55e;flex:0 0 auto} .imtabs-tabDotOff{background:#9ca3af} .imtabs-tabClose{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:4px;border:0;background:0 0;color:inherit;cursor:pointer;font-size:13px;line-height:1;opacity:.65} .imtabs-tabClose:hover{background:var(--dsw-alias-interactive-bg-hover);opacity:1} .imtabs-add{margin-left:auto;flex:0 0 auto;display:inline-flex;align-items:center;gap:4px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:4px 10px;font:var(--dsw-font-s-14);cursor:pointer;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary);white-space:nowrap;font-family:inherit} .imtabs-add:hover{background:var(--dsw-alias-interactive-bg-hover)} .imtabs-add:disabled{opacity:.45;cursor:not-allowed} .imtabs-pane{flex:1 1 auto;min-height:0;min-width:0;display:flex;flex-direction:column;overflow:hidden} .imtabs-paneHidden{display:none!important} .imtabs-convWrap{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0;width:100%;height:100%;overflow:hidden} .imtabs-convWrap>[data-slot='conversation'],.imtabs-convWrap>[data-slot='main.conversation']{display:contents} .imtabs-convWrap>[data-slot='conversation']>div,.imtabs-convWrap>[data-slot='main.conversation']>div{flex:1 1 0%!important;min-width:0!important;min-height:0!important;width:auto!important;height:100%!important;--dsh-chat-content-width:100%!important} .imtabs-alert{flex:0 0 auto;margin:8px 10px;padding:8px 12px;border:1px solid #f97316;border-left-width:3px;border-radius:8px;background:rgba(249,115,22,.10);color:var(--dsw-alias-label-primary);font:var(--dsw-font-s-13);line-height:1.6} .imtabs-alertTitle{font-weight:600;margin-bottom:2px} .imtabs-alertDim{color:var(--dsw-alias-label-secondary)} .imtabs-conn{flex:0 0 auto;display:inline-flex;align-items:center;gap:6px;font:var(--dsw-font-s-13);color:var(--dsw-alias-label-secondary);cursor:default} .imtabs-connWarn{color:#f59e0b} .imtabs-connBad{color:#ef4444} .imtabs-connBtn{border:0;background:0 0;color:var(--imtabs-brand,#4176e6);cursor:pointer;font:var(--dsw-font-s-13);text-decoration:underline;padding:0;font-family:inherit} .imrt{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;width:100%} .imrt-head{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));flex-wrap:wrap} .imrt-name{font-weight:600;font:var(--dsw-font-s-14);color:var(--dsw-alias-label-primary)} .imrt-fact{color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-13);font-variant-numeric:tabular-nums} .imrt-spacer{flex:1} .imrt-msgs{flex:1 1 auto;min-height:0;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:10px} .imrt-empty,.imrt-loading{color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-14);padding:16px;text-align:center} .imrt-msg{display:flex;flex-direction:column;gap:3px;max-width:min(720px,86%)} .imrt-msgMine{align-self:flex-end;align-items:flex-end} .imrt-msgHead{display:flex;align-items:center;gap:6px;font:var(--dsw-font-s-13);color:var(--dsw-alias-label-secondary)} .imrt-author{font-weight:600;color:var(--dsw-alias-label-primary)} .imrt-agent{display:inline-flex;align-items:center;gap:4px;border:1px solid #8b5cf6;color:#8b5cf6;border-radius:999px;padding:0 7px;font-size:11px;line-height:1.7} .imrt-kindBot{display:inline-flex;align-items:center;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:999px;padding:0 7px;font-size:11px;line-height:1.7} .imrt-private{display:inline-flex;align-items:center;gap:3px;color:#f59e0b;font-size:11px} .imrt-bubble{padding:8px 12px;border-radius:10px;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));color:var(--dsw-alias-label-primary);font:var(--dsw-font-s-14);line-height:1.65;white-space:pre-wrap;word-break:break-word} .imrt-msgMine .imrt-bubble{background:var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover));border-color:transparent} .imrt-bubbleSystem{background:0 0;border-style:dashed;color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-13)} .imrt-card{display:flex;flex-direction:column;gap:5px;padding:8px 12px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated))} .imrt-cardRow{display:flex;align-items:center;gap:8px;font:var(--dsw-font-s-13)} .imrt-cardKey{color:var(--dsw-alias-label-secondary);min-width:56px} .imrt-input{flex:0 0 auto;display:flex;flex-direction:column;gap:6px;padding:8px 12px 12px;border-top:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border))} .imrt-inputRow{display:flex;align-items:flex-end;gap:8px} .imrt-ta{flex:1;min-height:38px;max-height:160px;resize:vertical;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:8px 10px;font:var(--dsw-font-s-14);background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary);font-family:inherit} .imrt-ta:focus{outline:none;border-color:var(--imtabs-brand,#4176e6);box-shadow:0 0 0 2px rgba(65,118,230,.22)} .imrt-send{border:1px solid transparent;border-radius:8px;padding:8px 18px;font:var(--dsw-font-s-14);cursor:pointer;background:var(--imtabs-brand,#4176e6);color:#fff;white-space:nowrap;font-family:inherit} .imrt-send:disabled{opacity:.45;cursor:not-allowed} .imrt-err{color:#ef4444;font:var(--dsw-font-s-13)} .imrt-atWrap{position:relative} .imrt-atList{position:absolute;bottom:calc(100% + 4px);left:0;min-width:220px;max-height:220px;overflow:auto;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,.2);z-index:40;padding:4px} .imrt-atItem{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:6px;cursor:pointer;font:var(--dsw-font-s-14);color:var(--dsw-alias-label-primary)} .imrt-atItem:hover{background:var(--dsw-alias-interactive-bg-hover)} .imrt-atDot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#22c55e} .imrt-atDotOff{background:#9ca3af} .imrt-atEmpty{padding:8px 10px;color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-13)} .imrd-mask{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:flex-start;justify-content:center;padding-top:14vh;overflow:auto;z-index:200} .imrd{width:min(680px,94vw);max-height:74vh;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.28);overflow:hidden} .imrd-head{display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border))} .imrd-title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)} .imrd-close{margin-left:auto;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary);cursor:pointer} .imrd-close:hover{background:var(--dsw-alias-interactive-bg-hover)} .imrd-intro{padding:10px 18px 0;color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-13);line-height:1.6} .imrd-create{display:flex;align-items:center;gap:8px;padding:12px 18px;border-bottom:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border))} .imrd-in{flex:1;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:6px 10px;font:var(--dsw-font-s-14);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-family:inherit} .imrd-in:focus{outline:none;border-color:var(--imtabs-brand,#4176e6)} .imrd-btn{border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:8px;padding:6px 14px;font:var(--dsw-font-s-14);cursor:pointer;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));color:var(--dsw-alias-label-primary);white-space:nowrap;font-family:inherit} .imrd-btn:hover{background:var(--dsw-alias-interactive-bg-hover)} .imrd-btn:disabled{opacity:.45;cursor:not-allowed} .imrd-btnPrimary{background:var(--imtabs-brand,#4176e6);color:#fff;border-color:transparent} .imrd-list{flex:1;min-height:0;overflow:auto;padding:8px 12px 14px;display:flex;flex-direction:column;gap:8px} .imrd-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border));border-radius:10px;background:var(--dsw-alias-bg-base)} .imrd-rowMain{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px} .imrd-rowName{font-weight:600;font:var(--dsw-font-s-14);color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .imrd-rowMeta{color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-13)} .imrd-empty{padding:22px 16px;text-align:center;color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-14);line-height:1.7} .imrd-err{color:#ef4444;padding:10px 18px;font:var(--dsw-font-s-13)} .imrd-toast{position:absolute;top:10px;left:50%;transform:translateX(-50%);z-index:260;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-elevated));border:1px solid #f59e0b;border-radius:10px;padding:8px 16px;font:var(--dsw-font-s-14);box-shadow:0 8px 30px rgba(0,0,0,.25);max-width:74%;color:var(--dsw-alias-label-primary)} ` // ── 房间消息区(群组 tab 的内容)─────────────────────────────────────── /** * 一个群组 tab 的完整内容:头部(名称 / 成员 / 在线)+ 消息流 + 输入框 + @ 选择器。 * * 🔴 **保活 = 候选一(`display:none`)**(E 单 §四-4):本组件**不被卸载**, * 切走只是父级加 `.imtabs-paneHidden` ⇒ 滚动位置与草稿天然保留(React state 还在)。 */ function RoomPane(props) { const { ctx, lang, roomId, meId, registerReveal } = props const T = (key, vars) => tOf(lang, key, vars) const [room, setRoom] = React.useState(null) const [messages, setMessages] = React.useState([]) const [cursor, setCursor] = React.useState(0) const [draft, setDraft] = React.useState('') const [sending, setSending] = React.useState(false) const [err, setErr] = React.useState('') const [members, setMembers] = React.useState([]) const [presence, setPresence] = React.useState({}) const [atOpen, setAtOpen] = React.useState(false) const [connected, setConnected] = React.useState(false) const [retries, setRetries] = React.useState(0) const [toggleErr, setToggleErr] = React.useState('') const listRef = React.useRef(null) const cursorRef = React.useRef(0) const wsRef = React.useRef(null) const timerRef = React.useRef(null) const pingRef = React.useRef(null) const closedRef = React.useRef(false) const stickRef = React.useRef(true) /** 滚动到底(仅当用户本来就在底部 —— ⛔ 不打断向上翻阅)。 */ const scrollBottom = React.useCallback(() => { const el = listRef.current if (el === null || !stickRef.current) return el.scrollTop = el.scrollHeight }, []) /** 合并一批消息(按 `(roomId, id)` 去重 —— 与内核的内容哈希去重同一判据)。 */ const mergeMessages = React.useCallback((incoming) => { if (!Array.isArray(incoming) || incoming.length === 0) return setMessages((prev) => { const seen = new Set(prev.map((m) => m.id)) const added = incoming.filter((m) => m !== null && m !== undefined && !seen.has(m.id)) if (added.length === 0) return prev const next = prev.concat(added) next.sort((a, b) => (Number(a.seq) === Number(b.seq) ? String(a.id).localeCompare(String(b.id)) : Number(a.seq) - Number(b.seq))) return next }) const maxSeq = incoming.reduce((acc, m) => Math.max(acc, Number(m?.seq ?? 0)), 0) if (maxSeq > cursorRef.current) { cursorRef.current = maxSeq setCursor(maxSeq) } setTimeout(scrollBottom, 0) }, [scrollBottom]) /** 按游标补齐(`since` 递增 ⇒ **不重复**;E 单 §五-4 验证点)。 */ const pull = React.useCallback(async (since) => { const q = since === undefined ? `?limit=200` : `?since=${since}&limit=200` const body = await apiFetch('/rooms/' + encodeURIComponent(roomId) + '/messages' + q) mergeMessages(body && body.messages) return body }, [roomId, mergeMessages]) /** 初次取数:房间元信息 + 成员表 + 消息 + 在线态。 */ React.useEffect(() => { let alive = true ;(async () => { try { const rooms = await apiFetch('/rooms') const list = (rooms && rooms.rooms) || [] const found = list.find((r) => r.id === roomId) || null if (!alive) return setRoom(found) /* 房间名由 room state 直接渲染(⛔ 不回调给父级 —— tab 标题用序号,不是房间名)*/ try { const st = await apiFetch('/rooms/' + encodeURIComponent(roomId) + '/members') if (alive && st) { setMembers(Array.isArray(st.members) ? st.members : []) setPresence(st.presence && typeof st.presence === 'object' ? st.presence : {}) } } catch { /* 成员面失败不阻塞消息面 */ } await pull(undefined) } catch (e) { if (alive) setErr(T('err.load', { err: e && (e.detail || e.code || e.message) })) } })() return () => { alive = false } }, [roomId]) /** * WebSocket:按平台下发的**传输口径**建连 + 订阅本房 + **自建保活** + * 指数退避重连(≤ 8 次 / 60 s,带抖动)。 * * 🔴 两套信封(第 16 棒):`op`(平台进程内,原路径)|`gateway`(外部连接层)。 * ⛔ 信封错用 = 满屏非法帧 ⇒ "连上就断";应用层帧两种模式**逐字相同**。 * 🔴 保活死线**绑本连接自己的 `open` 时刻**(与 `src/im/keepalive.ts` 同口径)。 */ React.useEffect(() => { closedRef.current = false cursorRef.current = 0 const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const url = proto + '//' + window.location.host + API + '/ws' let attempt = 0 /** * 取**传输口径**(第 16 棒)⇒ 再开 socket。 * * 🔴 模式由平台下发(`GET /api/im/transport`),⛔ 不硬编码;取不到 ⇒ 按"连不上"处理 * (走同一条重连退避),⛔ **不默认退回 native** —— 猜错就会用错信封,表现是"连上就断"。 */ const connect = () => { if (closedRef.current) return apiFetch('/transport') .then((cfg) => { if (closedRef.current) return openSocket(cfg !== null && typeof cfg === 'object' ? cfg : {}) }) .catch(() => { scheduleReconnect() }) } const openSocket = (cfg) => { const gatewayMode = cfg.protocol === PROTOCOL_GATEWAY let ws try { ws = new WebSocket(url) } catch { scheduleReconnect() return } wsRef.current = ws // ── 本连接自己的保活状态(🔴 全在闭包里 ⇒ 结构上不可能串到别的连接)────── const openedAt = Date.now() let pingAnchorAt = openedAt // 🔴 计时起点 = **本次 open**(⛔ 模块加载 / 组件挂载时刻) let periodMs = WS_PING_MS let lastInboundAt = openedAt let nextPingAt = pingAnchorAt + periodMs let resumed = false /** 网关命令 id(🔴 **从 1 起**:0 在协议里是"服务端推送"的标记)。 */ let cmdId = GATEWAY_FIRST_ID - 1 const nextId = () => { cmdId += 1 return cmdId } ws.onopen = () => { attempt = 0 setRetries(0) setConnected(true) if (gatewayMode) { // 网关协议:先取**连接票据** ⇒ 再 `connect`(⛔ 没有合法票据的连接会被直接拒)。 // 🔴 票据只回答"你是谁";"能不能读这个房"由 `subscribe` 时的成员表判(见下)。 apiFetch('/gateway/token', { method: 'POST', body: '{}' }) .then((t) => { if (closedRef.current) return sendRaw(ws, gatewayCommand(nextId(), 'connect', { token: t.token })) }) .catch(() => { try { ws.close() } catch { /* 交给 onclose */ } }) } else { // 订阅本房 —— ⚠️ 平台的 `subscribe` **不读 `since`** ⇒ 游标补齐走独立的 `resume`。 sendOp(ws, 'subscribe', { roomId }) } if (pingRef.current !== null) clearInterval(pingRef.current) pingRef.current = setInterval(() => { if (ws.readyState !== 1) return const now = Date.now() // ③ 半开链路检测(对应垫片的 `onExpire`):整窗无任何入向流量 ⇒ 主动重连。 if (now - lastInboundAt > WS_IDLE_TIMEOUT_MS) { try { ws.close() } catch { /* 交给 onclose */ } return } // 🔴 **网关模式不发应用层 ping**:外部层按 `client.ping_interval` 发协议级 ping // (空对象),客户端只需**按连接回空对象**(见 `onGatewayFrame`)。 // ⚠️ 死线检测(③)两种模式**都保留** —— 半开链路在两种模式下都会发生。 if (gatewayMode) return // ② 死线由 **anchor** 推算(⛔ 不按"上次发送时刻"漂移)。 if (now < nextPingAt) return nextPingAt = pingAnchorAt + (Math.floor((now - pingAnchorAt) / periodMs) + 1) * periodMs // 🔴 出站心跳**是 `ping`**(`ws.ts:264` 的 `case 'ping'` ⇒ 服务端回 `pong`)。 // ⛔ 不是 `pong`:实现里没有 `case 'pong'`,发出去就是 `bad-op` + 断开。 sendOp(ws, 'ping', { ts: now }) }, WS_PING_TICK_MS) } ws.onmessage = (ev) => { let msg = null try { msg = JSON.parse(ev.data) } catch { return } if (msg === null || typeof msg !== 'object') return lastInboundAt = Date.now() // noteSeen:任何入向帧都刷新本连接的空窗计数 // 🔴 两种模式**只差信封**:网关模式先剥一层(`push.pub.data.frame`), // 剥出来的仍是同一个应用层帧 ⇒ 交给**同一段** `onAppFrame` 分发。 if (gatewayMode) { onGatewayFrame(msg) return } onAppFrame(msg) } /** * **网关信封处理**(第 16 棒)。四类,全部按协议收敛,⛔ 不猜字段。 * ① 空对象 = 协议级 ping ⇒ **按连接回空对象**(漏回 ⇒ `3012 no pong` 断开); * ② `connect` 应答 ⇒ 取**订阅票据**并订阅(票据里带的就是"你能读哪个频道"); * ③ `subscribe` 应答 / `error` ⇒ 成功即按本端游标补齐(走 REST,与 native 同口径); * ④ 推送:`pub` 取回应用层原帧 ⇒ `onAppFrame`;`disconnect` ⇒ 让用户看见原因。 * ⚠️ `join` / `leave` 推送**不需要客户端处理** —— "谁在线"由平台产 `{op:'presence'}` * 帧经同一信道下发(在线态取数已接线,见 `presence-ingest.ts`)。 */ const onGatewayFrame = (msg) => { if (isGatewayPing(msg)) { // 🔴 回的是**空对象**(空命令);把别的命令塞进去会被判 `3501 bad request`。 sendRaw(ws, GATEWAY_PONG) return } if (msg.connect !== undefined) { // 连接已建立 ⇒ 拿本房的**订阅票据**(服务端在此刻查成员表;非成员 ⇒ 403)。 apiFetch('/gateway/token', { method: 'POST', body: JSON.stringify({ roomId: roomId }) }) .then((t) => { if (closedRef.current) return sendRaw(ws, gatewayCommand(nextId(), 'subscribe', { channel: t.channel, token: t.token })) }) .catch((e) => { setErr(T('err.ws', { err: (e && (e.detail || e.code || e.message)) || 'token' })) try { ws.close() } catch { /* 交给 onclose */ } }) return } if (msg.subscribe !== undefined) { // 订阅成功 ⇒ 按**本端进度**补齐(与 native 的 `resume` 同一口径,走 REST)。 if (cursorRef.current > 0 && !resumed) { resumed = true pull(cursorRef.current).catch(() => { /* 补齐失败不覆盖已有消息 */ }) } return } const frame = appFrameOfPush(msg) if (frame !== null) { let app = null try { app = JSON.parse(frame) } catch { return } if (app !== null && typeof app === 'object') onAppFrame(app) return } const disconnected = disconnectInfoOf(msg) if (disconnected !== null) { setErr(T('err.ws', { err: 'disconnect ' + disconnected.code + ' · ' + disconnected.reason })) try { ws.close() } catch { /* 交给 onclose */ } return } const errObj = msg.error if (errObj !== null && typeof errObj === 'object') { const code = errObj.code === undefined ? '' : String(errObj.code) const message = typeof errObj.message === 'string' ? errObj.message : '' setErr(T('err.ws', { err: message === '' ? code : code + ' · ' + message })) } } /** **应用层帧分发**(🔴 两种模式共用这一段 ⇒ 应用层协议逐字不变)。 */ const onAppFrame = (msg) => { switch (msg.op) { case 'hello': { // 握手首帧(`connectionId` / `heartbeatMs`):把保活周期校到**服务端权威值**。 // ⚠️ 只用它改周期,⛔ 不改计时起点(起点恒为本连接 `open`)。 const hb = numOf(msg.heartbeatMs) if (hb >= 1000 && hb < periodMs) { periodMs = hb nextPingAt = pingAnchorAt + periodMs } return } case 'subscribed': { // 🔴 重连补齐走平台设计的独立路径 `resume`,`since` = **本端自己的进度**。 // ⚠️ 不能用本帧回带的 `cursor` —— 那是**房间最新 seq**,拿它补拉取出 0 条。 if (cursorRef.current > 0 && !resumed) { resumed = true sendOp(ws, 'resume', { roomId, since: cursorRef.current }) } return } case 'unsubscribed': { // 同一连接退订后可再订阅 ⇒ 允许下一次 `subscribed` 再次补齐。 resumed = false return } case 'message': { // 形状 = `{op:'message', roomId, messages:[…]}` —— **数组**,⛔ 不是单条。 mergeMessages(msg.messages) return } case 'messages': { mergeMessages(msg.messages) // 补拉结果(与 `message` 同形) return } case 'presence': { // 形状 = `{op:'presence', roomId, at, members:[{memberId,state,at}]}` —— **数组**。 const deltas = Array.isArray(msg.members) ? msg.members : [] if (deltas.length === 0) return setPresence((prev) => { const next = Object.assign({}, prev) for (const d of deltas) { if (d !== null && d !== undefined && typeof d.memberId === 'string') next[d.memberId] = d.state } return next }) return } case 'sent': { // 本端写入已被受理 ⇒ `seq` 是已知落库位点,直接推进游标。 // (防御性:本 bundle 的发送走 REST `/rooms/:id/messages`,正常不会收到本帧。) const seq = numOf(msg.seq) if (seq > cursorRef.current) { cursorRef.current = seq setCursor(seq) } return } case 'pong': { return // 心跳应答:`lastInboundAt` 已在上方刷新(这就是它的用途) } case 'error': { // 🔴 ⛔ 不得静默丢弃:`bad-op` / `not-a-member` / 预算拦下都要让用户看得见。 const code = msg.error === undefined || msg.error === null ? '' : String(msg.error) const detail = msg.detail === undefined || msg.detail === null ? '' : String(msg.detail) setErr(T('err.ws', { err: detail === '' ? code : code + ' · ' + detail })) return } default: { // 前向兼容:不认的 op ⛔ 不抛、不断连,只留痕(并列出已知集合便于对账)。 if (window.console && window.console.warn) { window.console.warn( '[im-conversation-tabs] 未认的入站 op:' + String(msg.op) + '(已知:' + WS_OPS_IN.join('/') + ')', ) } } } } ws.onclose = () => { if (pingRef.current !== null) clearInterval(pingRef.current) pingRef.current = null setConnected(false) if (closedRef.current) return scheduleReconnect() } ws.onerror = () => { /* 统一由 onclose 走重连(⛔ 不在这里重复排程) */ } } const scheduleReconnect = () => { if (closedRef.current) return if (attempt >= 8) return // 60 s 内 ≤ 8 次(E 单 §六.1-8),超出 ⇒ 停在「已断开」并给「立即重试」 attempt += 1 setRetries(attempt) const backoff = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt - 1), RECONNECT_MAX_MS) const jitter = Math.floor(Math.random() * RECONNECT_JITTER_MS) timerRef.current = setTimeout(connect, backoff + jitter) } connect() return () => { closedRef.current = true if (timerRef.current !== null) clearTimeout(timerRef.current) if (pingRef.current !== null) clearInterval(pingRef.current) const ws = wsRef.current if (ws !== null) { ws.onclose = null ws.onerror = null try { ws.close() } catch { /* 忽略 */ } } } }, [roomId]) /** 重连成功后按游标补齐(断线期间可能漏消息)—— `connected` 由 false→true 时触发。 */ React.useEffect(() => { if (!connected) return pull(cursorRef.current).catch(() => { /* 补齐失败不覆盖已有消息 */ }) }, [connected]) /** 暴露"切回本 tab 时滚到底 + 聚焦输入框"(`display:none` 保活下 scrollHeight 有效)。 */ React.useEffect(() => { if (registerReveal !== undefined) { registerReveal(() => { setTimeout(() => { scrollBottom() }, 0) }) } }, [registerReveal, scrollBottom]) const onScroll = () => { const el = listRef.current if (el === null) return stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40 } const send = async () => { const text = draft.trim() if (text === '' || sending) return setSending(true) setErr('') try { const body = await apiFetch('/rooms/' + encodeURIComponent(roomId) + '/messages', { method: 'POST', body: JSON.stringify({ payload: { text } }), }) if (body && body.message) mergeMessages([body.message]) setDraft('') // 清草稿(本地已渲染 ⇒ 不等 WS 回环) stickRef.current = true setTimeout(scrollBottom, 0) } catch (e) { setErr(T('err.send', { err: e && (e.detail || e.code || e.message) })) } finally { setSending(false) } } /** 「待应答」开关(形态 B,§五-7):写自己的成员行 `autoReply`。 */ const toggleAutoReply = async () => { const mine = members.find((m) => m.memberId === meId) if (mine === undefined) return setToggleErr('') try { const body = await apiFetch('/rooms/' + encodeURIComponent(roomId) + '/members/' + encodeURIComponent(meId), { method: 'PATCH', body: JSON.stringify({ autoReply: !mine.autoReply }), }) if (body && body.member) { setMembers((prev) => prev.map((m) => (m.memberId === meId ? body.member : m))) } } catch (e) { // 后端未接线 ⇒ 如实显示(⛔ 不伪造成功) setToggleErr(String(e && (e.detail || e.code || e.message))) } } const mine = members.find((m) => m.memberId === meId) const onlineCount = members.filter((m) => presence[m.memberId] === 'online').length const atCandidates = members.filter((m) => m.memberId !== meId) const insertAt = (member) => { const label = member.displayName !== undefined && member.displayName !== null && member.displayName !== '' ? member.displayName : member.memberId setDraft((prev) => { const sep = prev === '' || prev.endsWith(' ') ? '' : ' ' return prev + sep + '@' + label + ' ' }) setAtOpen(false) } const renderBubble = (m) => { const payload = (m && m.payload) || {} const isMine = m.authorId === meId const isSystem = m.authorKind === 'system' const byAgent = payload.byAgentId !== undefined && payload.byAgentId !== null const onBehalf = byAgent && payload.onBehalfOf !== undefined && payload.onBehalfOf !== null const agentName = byAgent ? (payload.agentName !== undefined && payload.agentName !== null ? payload.agentName : String(payload.byAgentId)) : '' const cls = 'imrt-msg' + (isMine ? ' imrt-msgMine' : '') const bubbleCls = 'imrt-bubble' + (isSystem ? ' imrt-bubbleSystem' : '') return jsx.jsxs( 'div', { className: cls, key: m.id, children: [ jsx.jsxs( 'div', { className: 'imrt-msgHead', children: [ jsx.jsx('span', { className: 'imrt-author', children: isSystem ? T('msg.system') : (m.authorId === meId ? T('kind.human') + ' · ' + m.authorId : m.authorId) }), m.authorKind === 'bot' ? jsx.jsx('span', { className: 'imrt-kindBot', children: T('kind.bot') }) : null, onBehalf ? jsx.jsx('span', { className: 'imrt-agent', title: T('agent.badge', { name: agentName }), children: m.authorId === meId ? T('agent.self') : T('agent.badge', { name: agentName }), }) : null, m.visibility === 'private' ? jsx.jsx('span', { className: 'imrt-private', children: '· ' + T('msg.private') }) : null, jsx.jsx('span', { children: '#' + m.seq }), ], }, 'h-' + m.id, ), payload.kind === 'task' && payload.card !== undefined ? jsx.jsx( 'div', { className: 'imrt-card', children: Object.entries(payload.card).map(([k, v]) => jsx.jsxs( 'div', { className: 'imrt-cardRow', key: k, children: [jsx.jsx('span', { className: 'imrt-cardKey', children: k }), jsx.jsx('span', { children: String(v) })] }, 'c-' + k, ), ), }, 'card-' + m.id, ) : jsx.jsx('div', { className: bubbleCls, children: payload.text !== undefined && payload.text !== null ? String(payload.text) : JSON.stringify(payload) }), ], }, m.id, ) } return jsx.jsxs('div', { className: 'imrt', children: [ jsx.jsxs('div', { className: 'imrt-head', children: [ jsx.jsx('span', { className: 'imrt-name', children: room !== null ? room.name : roomId }), jsx.jsx('span', { className: 'imrt-fact', children: T('room.members', { n: members.length }) }), jsx.jsx('span', { className: 'imrt-fact', children: T('room.online', { n: onlineCount }) }), mine !== undefined ? jsx.jsx('button', { type: 'button', className: 'imrd-btn', title: T('agent.toggle'), onClick: toggleAutoReply, children: mine.autoReply ? T('agent.on') : T('agent.off'), }) : null, jsx.jsx('span', { className: 'imrt-spacer' }), jsx.jsxs('span', { className: 'imtabs-conn' + (connected ? '' : retries > 0 ? ' imtabs-connWarn' : ' imtabs-connBad'), children: [ jsx.jsx('span', { className: 'imtabs-tabDot' + (connected ? '' : ' imtabs-tabDotOff') }), connected ? T('conn.online') : retries > 0 ? T('conn.reconnecting', { n: retries }) : T('conn.offline'), !connected && retries >= 8 ? jsx.jsx('button', { type: 'button', className: 'imtabs-connBtn', onClick: () => setRetries(0), children: T('conn.retryNow') }) : null, ], }), jsx.jsx('span', { className: 'imrt-fact', children: T('room.cursor', { seq: cursor }) }), ], }), toggleErr !== '' ? jsx.jsx('div', { className: 'imrt-err', children: toggleErr }) : null, jsx.jsxs('div', { className: 'imrt-msgs', ref: listRef, onScroll, children: messages.length === 0 ? [jsx.jsx('div', { className: 'imrt-empty', children: T('room.empty') }, 'empty')] : messages.map(renderBubble), }), jsx.jsxs('div', { className: 'imrt-input', children: [ err !== '' ? jsx.jsx('div', { className: 'imrt-err', children: err }) : null, jsx.jsxs('div', { className: 'imrt-inputRow', children: [ jsx.jsxs('div', { className: 'imrt-atWrap', style: { flex: '1', display: 'flex' }, children: [ atOpen ? jsx.jsxs('div', { className: 'imrt-atList', children: atCandidates.length === 0 ? [jsx.jsx('div', { className: 'imrt-atEmpty', children: '—' }, 'no')] : atCandidates.map((m) => jsx.jsxs( 'div', { className: 'imrt-atItem', onClick: () => insertAt(m), key: m.memberId, children: [ jsx.jsx('span', { className: 'imrt-atDot' + (presence[m.memberId] === 'online' ? '' : ' imrt-atDotOff') }), jsx.jsx('span', { children: (m.displayName !== undefined && m.displayName !== null && m.displayName !== '') ? m.displayName : m.memberId }), m.kind === 'bot' ? jsx.jsx('span', { className: 'imrt-kindBot', children: T('kind.bot') }) : null, m.agentRef !== null && m.agentRef !== undefined ? jsx.jsx('span', { className: 'imrt-kindBot', children: T('agent.hooked') }) : null, ] }, m.memberId, ), ), }) : null, jsx.jsx('textarea', { className: 'imrt-ta', rows: 1, placeholder: T('room.placeholder'), value: draft, onChange: (e) => { const v = e.target.value setDraft(v) const at = v.lastIndexOf('@') setAtOpen(at >= 0 && at === v.length - 1) }, onKeyDown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() if (atOpen) { setAtOpen(false) return } send() } else if (e.key === 'Escape') { setAtOpen(false) } }, }), ], }), jsx.jsx('button', { type: 'button', className: 'imrt-send', disabled: sending || draft.trim() === '', onClick: send, children: sending ? T('room.sending') : T('room.send'), }), ], }), ], }), ], }) } // ── 发现 / 加入 / 新建群(E 单 §五-8;入口在本插件内 —— §四-4 拍板)──────── function DiscoveryModal(props) { const { lang, onClose, onJoined, onCreated } = props const T = (key, vars) => tOf(lang, key, vars) const [list, setList] = React.useState(null) const [err, setErr] = React.useState('') const [busy, setBusy] = React.useState('') const [limitHit, setLimitHit] = React.useState('') const [newName, setNewName] = React.useState('') const [creating, setCreating] = React.useState(false) const load = React.useCallback(async () => { setErr('') try { const body = await apiFetch('/rooms?discoverable=1') setList((body && body.rooms) || []) } catch (e) { setErr(e && e.code === 'unauthorized' ? T('disc.errNetwork') : T('disc.errNetwork')) } }, []) React.useEffect(() => { load() }, [load]) const join = async (room) => { setBusy(room.id) setErr('') try { const body = await apiFetch('/rooms/' + encodeURIComponent(room.id) + '/join', { method: 'POST' }) if (body && body.ok !== false) { const verdict = onJoined(room) if (verdict !== undefined && verdict !== null && verdict.limit === true) setLimitHit(T('tab.limit')) else onClose() } } catch (e) { const code = e && e.code if (code === 'room_full') setErr(T('disc.errFull')) else if (code === 'join_closed') setErr(T('disc.errClosed')) else if (code === 'no-such-room') setErr(T('disc.errNotDiscoverable')) else setErr(T('disc.errNetwork')) } finally { setBusy('') } } const create = async () => { const name = newName.trim() if (name === '' || creating) return setCreating(true) setErr('') try { const room = await apiFetch('/rooms', { method: 'POST', body: JSON.stringify({ roomType: 'office', name, discoverable: true, joinPolicy: 'open' }), }) if (room && room.room) { const verdict = onCreated(room.room) if (verdict !== undefined && verdict !== null && verdict.limit === true) setLimitHit(T('tab.limit')) else onClose() } } catch (e) { setErr(String(e && (e.detail || e.code || e.message))) } finally { setCreating(false) } } return jsx.jsxs('div', { className: 'imrd-mask', onClick: (e) => { if (e.target === e.currentTarget) onClose() }, children: [ jsx.jsxs('div', { className: 'imrd', children: [ jsx.jsxs('div', { className: 'imrd-head', children: [ jsx.jsx('span', { className: 'imrd-title', children: T('disc.title') }), jsx.jsx('button', { type: 'button', className: 'imrd-close', onClick: onClose, children: '✕' }), ], }), jsx.jsx('div', { className: 'imrd-intro', children: T('disc.intro') }), jsx.jsxs('div', { className: 'imrd-create', children: [ jsx.jsx('input', { className: 'imrd-in', placeholder: T('disc.newRoomName'), value: newName, onChange: (e) => setNewName(e.target.value), }), jsx.jsx('button', { type: 'button', className: 'imrd-btn imrd-btnPrimary', disabled: creating || newName.trim() === '', onClick: create, children: T('disc.newRoomCreate'), }), ], }), limitHit !== '' ? jsx.jsx('div', { className: 'imrd-err', children: limitHit }) : null, err !== '' ? jsx.jsx('div', { className: 'imrd-err', children: err }) : null, jsx.jsxs('div', { className: 'imrd-list', children: list === null ? [jsx.jsx('div', { className: 'imrd-empty', children: T('disc.loading') }, 'loading')] : list.length === 0 ? [jsx.jsxs('div', { className: 'imrd-empty', children: [jsx.jsx('div', { children: T('disc.empty') }), jsx.jsx('div', { children: T('disc.emptyHint') })] }, 'empty')] : list.map((room) => jsx.jsxs( 'div', { className: 'imrd-row', children: [ jsx.jsxs('div', { className: 'imrd-rowMain', children: [ jsx.jsx('div', { className: 'imrd-rowName', children: room.name }), jsx.jsx('div', { className: 'imrd-rowMeta', children: room.roomType + ' · ' + (room.memberCount !== undefined ? T('disc.members', { n: room.memberCount }) : '') }), ], }), jsx.jsx('button', { type: 'button', className: 'imrd-btn imrd-btnPrimary', disabled: busy === room.id, onClick: () => join(room), children: busy === room.id ? '…' : T('disc.join'), }), ], }, room.id, ), ), }), ], }), ], }) } /** * **接管组件**(唯一对 DOM 动手的地方)。 * * 结构(接管后): * ``` * host (= conversation 的 parentElement) * └─ div[data-imtabs-root="1"].imtabs-root ← wrapper(新建,flex column) * ├─ div[data-imtabs-bar="1"].imtabs-bar ← tab 行(React 渲染到此) * ├─ div[data-imtabs-pane="dsh"].imtabs-pane.imtabs-convWrap * │ └─ <原 conversation 节点> ← 移入,CSS `display:contents` 保持其内部布局 * └─ div[data-imtabs-pane="g:"].imtabs-pane * └─ RoomPane 子树(React 渲染到此) * ``` * * 🔴 卸载(`cleanup`)必须**反向还原**:会话区插回 wrapper 之前的位置 + * 恢复 `previousCss` + 移除 wrapper(MCN 既有做法;⛔ 不做会让切走再回来结构坏掉)。 */ function ConversationTabs(props) { const { ctx, lang } = props const T = (key, vars) => tOf(lang, key, vars) const [groups, setGroups] = React.useState([]) const [activeKey, setActiveKey] = React.useState('dsh') const [slot, setSlot] = React.useState(null) // {el, selector} | null const [ready, setReady] = React.useState(false) const [slotMissing, setSlotMissing] = React.useState(false) const [discOpen, setDiscOpen] = React.useState(false) const [toast, setToast] = React.useState('') const [meId, setMeId] = React.useState('') const [barEl, setBarEl] = React.useState(null) const [paneEls, setPaneEls] = React.useState({}) const revealRef = React.useRef({}) const domRef = React.useRef(null) /** 取当前用户 id(气泡左右 / 我的成员行判据)。 */ React.useEffect(() => { ;(async () => { try { const body = await apiFetch('/rooms') if (body && typeof body.meId === 'string') setMeId(body.meId) } catch { /* 忽略:meId 缺省为空时气泡一律按"别人"渲染(⛔ 不阻塞消息流) */ } })() }, []) // ── ① 找槽(多选择器兜底);② 建 wrapper;③ 会话区移入;④ 建 pane 容器 ── React.useEffect(() => { const hit = findConversationSlot() if (hit === null) { // 🔴 硬约束 ①:失配**可见提示**(⛔ 不静默)—— MCN 被咬过「点了没反应」。 setSlotMissing(true) setReady(true) return } setSlot(hit) const conversation = hit.el const host = conversation.parentElement if (host === null) { setSlotMissing(true) setReady(true) return } // 幂等:宿主里已有 wrapper ⇒ 复用(⛔ 不建第二个 —— 重复挂载会套娃)。 let wrap = host.querySelector(':scope > [data-imtabs-root="1"]') let prevCss = null if (wrap === null) { prevCss = conversation.style.cssText wrap = document.createElement('div') wrap.setAttribute('data-imtabs-root', '1') wrap.className = 'imtabs-root' host.insertBefore(wrap, conversation) const bar = document.createElement('div') bar.setAttribute('data-imtabs-bar', '1') bar.className = 'imtabs-bar' const convPane = document.createElement('div') convPane.setAttribute('data-imtabs-pane', 'dsh') convPane.className = 'imtabs-pane imtabs-convWrap' convPane.appendChild(conversation) // ← 会话区移入 pane(CSS `display:contents`) wrap.appendChild(bar) wrap.appendChild(convPane) } domRef.current = { host, wrap, conversation, prevCss } setBarEl(wrap.querySelector('[data-imtabs-bar="1"]')) setPaneEls({ dsh: wrap.querySelector('[data-imtabs-pane="dsh"]') }) setReady(true) return () => { // 🔴 硬约束 ③:**反向还原**(会话区插回原位 + 恢复 previousCss + 移除 wrapper)。 const cur = domRef.current if (cur !== null && cur.wrap.isConnected) { cur.host.insertBefore(cur.conversation, cur.wrap) if (cur.prevCss !== null) cur.conversation.style.cssText = cur.prevCss cur.wrap.remove() } domRef.current = null } }, []) // ── 群组 pane 容器:按 groups 增删(保活 = 只切 `display:none`,⛔ 不卸载)── React.useEffect(() => { const cur = domRef.current if (cur === null) return const wrap = cur.wrap const next = { dsh: wrap.querySelector('[data-imtabs-pane="dsh"]') } for (const g of groups) { const key = 'g:' + g.id let pane = wrap.querySelector('[data-imtabs-pane="' + key + '"]') if (pane === null) { pane = document.createElement('div') pane.className = 'imtabs-pane' pane.setAttribute('data-imtabs-pane', key) wrap.appendChild(pane) } next[key] = pane } // 已删除的群 ⇒ 移除其 DOM(关闭 tab)。 for (const el of Array.from(wrap.querySelectorAll('[data-imtabs-pane]'))) { const key = el.getAttribute('data-imtabs-pane') if (key === 'dsh') continue if (next[key] === undefined) el.remove() } setPaneEls(next) }, [groups]) // ── 显隐:`display:none` 保活(候选一)⇒ 滚动位置与草稿都留 ── React.useEffect(() => { for (const key of Object.keys(paneEls)) { const el = paneEls[key] if (el === null || el === undefined) continue el.classList.toggle('imtabs-paneHidden', key !== activeKey) } }, [paneEls, activeKey]) React.useEffect(() => { if (toast === '') return const timer = setTimeout(() => setToast(''), 3200) return () => clearTimeout(timer) }, [toast]) const registerReveal = React.useCallback((id, fn) => { revealRef.current[id] = fn }, []) const onTabClick = (key) => { setActiveKey(key) if (key.startsWith('g:')) { const fn = revealRef.current[key.slice(2)] if (typeof fn === 'function') setTimeout(fn, 0) } } /** 开一个群组 tab(去重 + 上限 5)。返回 `{ok}` / `{limit:true}`。 */ const openGroup = (room) => { if (groups.some((g) => g.id === room.id)) { setActiveKey('g:' + room.id) return { ok: true } } if (groups.length >= MAX_GROUP_TABS) { setToast(T('tab.limit')) return { limit: true } } setGroups((prev) => prev.concat([{ id: room.id, name: room.name || room.id }])) setActiveKey('g:' + room.id) return { ok: true } } const closeGroup = (id) => { setGroups((prev) => prev.filter((g) => g.id !== id)) setActiveKey((cur) => (cur === 'g:' + id ? 'dsh' : cur)) delete revealRef.current[id] } const tabs = [{ key: 'dsh', label: T('tab.dsh'), closable: false }].concat( groups.map((g, i) => ({ key: 'g:' + g.id, label: T('tab.group', { n: i + 1 }), closable: true, id: g.id })), ) // 失配 ⇒ 只渲染可见提示(⛔ 不接管、不静默)。 if (slotMissing) { return jsx.jsxs('div', { className: 'imtabs-alert', children: [ jsx.jsx('div', { className: 'imtabs-alertTitle', children: T('slot.missing') }), jsx.jsx('div', { className: 'imtabs-alertDim', children: T('slot.missingDetail', { sel: CONVERSATION_SLOT_SELECTORS.join(' / ') }), }), ], }) } if (!ready) return null return jsx.jsxs(React.Fragment, { children: [ barEl === null ? null : createPortal( jsx.jsxs(React.Fragment, { children: [ tabs.map((tb) => jsx.jsxs( 'button', { type: 'button', className: 'imtabs-tab' + (activeKey === tb.key ? ' imtabs-tabActive' : ''), onClick: () => onTabClick(tb.key), children: [ jsx.jsx('span', { className: 'imtabs-tabDot' + (activeKey === tb.key ? '' : ' imtabs-tabDotOff'), }), jsx.jsx('span', { children: tb.label }), tb.closable === true ? jsx.jsx('span', { className: 'imtabs-tabClose', title: T('tab.close', { n: tb.label }), onClick: (e) => { e.stopPropagation() closeGroup(tb.id) }, children: '✕', }) : null, ], }, tb.key, ), ), jsx.jsx('button', { type: 'button', className: 'imtabs-add', onClick: () => { if (groups.length >= MAX_GROUP_TABS) { setToast(T('tab.limit')) return } setDiscOpen(true) }, children: '+ ' + T('disc.newRoom'), }), ], }), barEl, ), // 各群组 pane 的 React 子树(保活 ⇒ 常驻渲染,靠 CSS 显隐)。 groups.map((g) => paneEls['g:' + g.id] === undefined || paneEls['g:' + g.id] === null ? null : createPortal( jsx.jsx(RoomPane, { ctx, lang, roomId: g.id, meId, registerReveal: (fn) => registerReveal(g.id, fn), }), paneEls['g:' + g.id], ), ), discOpen ? jsx.jsx(DiscoveryModal, { lang, onClose: () => setDiscOpen(false), onJoined: (room) => openGroup(room), onCreated: (room) => openGroup(room), }) : null, toast !== '' ? jsx.jsx('div', { className: 'imrd-toast', children: toast }) : null, ], }) } // ── 插件入口 ──────────────────────────────────────────────────────────── /** 只依赖官方扩展点:`slots`(注册面)+ `locale`(词条)。⛔ 不改官方任何东西。 */ const inject = ['slots', 'locale'] function apply(ctx) { injectStyleOnce('imtabs-style', CSS) const lang = activeLang(ctx) // 词条注册(zh / en 双侧 —— E 单 §六-6)。 try { if (ctx.locale !== undefined && typeof ctx.locale.register === 'function') { ctx.locale.register(NS, DICT) } } catch { /* 官方 locale 面不可用时降级到内置词典(本文件自带 DICT) */ } // 🔴 挂载点:会话区接管**不是** `slots.register`(官方 slots 面很窄,实测只有 // `sidebar.footer.action` / `settings.section`)⇒ 靠 DOM 接管。这里只借 // `slots.inject` 拿一个**已挂载的宿主节点位置**,渲染仍由本插件自己放进 // 会话区的父级(见 TabBarInto)。 const mount = () => { const root = document.createElement('div') root.setAttribute('data-imtabs-mount', '1') document.body.appendChild(root) const { createRoot } = require('react-dom/client') const r = createRoot(root) r.render(jsx.jsx(ConversationTabs, { ctx, lang })) return () => { r.unmount() root.remove() } } let dispose = null try { // 借 `slots.inject` 拿生命周期(官方唯一稳定可用的挂接面)—— // ⚠️ 若该名字在官方改版后不可用,退化为直接在 `apply` 里挂(见 catch)。 ctx.slots.inject('sidebar.footer.action', () => { dispose = mount() return () => { if (dispose !== null) dispose() } }) } catch { dispose = mount() } return () => { if (dispose !== null) dispose() } } exports.apply = apply exports.inject = inject exports.DICT = DICT exports.MAX_GROUP_TABS = MAX_GROUP_TABS exports.CONVERSATION_SLOT_SELECTORS = CONVERSATION_SLOT_SELECTORS /** * 🔴 **网关传输层的纯函数出口**(第 16 棒)—— 单测直接驱动**真客户端件**的这几条规则。 * * 为什么要暴露:判据 ③ 要求「`gateway` 路径下"空对象即回"必须有**独立断言**, * ⛔ 不许只靠端到端间接证明」。端到端只能证明"整体通了",证明不了"回的是空对象、 * 且只在**空对象**时才回" —— 这两条只有直接调函数才断得住。 * ⛔ 它们**不含状态**(纯函数)⇒ 暴露出去不会引入第二条可控路径。 */ exports.imTransport = { PROTOCOL_OP: PROTOCOL_OP, PROTOCOL_GATEWAY: PROTOCOL_GATEWAY, GATEWAY_PONG: GATEWAY_PONG, GATEWAY_FIRST_ID: GATEWAY_FIRST_ID, isGatewayPing: isGatewayPing, gatewayCommand: gatewayCommand, appFrameOfPush: appFrameOfPush, disconnectInfoOf: disconnectInfoOf, WS_OPS_OUT: WS_OPS_OUT, WS_OPS_IN: WS_OPS_IN, } return module.exports }, })