build / build-and-scan (push) Canceled after 0s
文档库:目录改为编号制(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/;交接单不入库(政策)。
1627 lines
81 KiB
JavaScript
1627 lines
81 KiB
JavaScript
/**
|
||
* @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
|
||
},
|
||
})
|