Files
dsh_ai1net_server/poc/im-conversation-tabs/lib/client.js
T
admin e6207aa691
build / build-and-scan (push) Canceled after 0s
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

1627 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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, <method>: 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:<roomId>"].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
},
})