mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat(onetalk): add conversation ID copy control
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
| [质量规范](./quality-guidelines.md) | 可执行命令与质量底线 | 已建立基线 |
|
||||
| [类型安全](./type-safety.md) | TypeScript 与运行时校验 | 已建立基线 |
|
||||
| [OneTalk 当前会话历史同步](./onetalk/history-sync.md) | MAIN-world 只读分页、节流、退出与 SDK 契约 | 已验证 |
|
||||
| [OneTalk 页面增强控件](./onetalk/page-controls.md) | MAIN 页面内受控辅助控件、会话 ID 复制与失败反馈 | 已实现并有 focused tests |
|
||||
| [OneTalk 双向运行时与耐久同步总览](./onetalk/runtime-sync.md) | OneTalk 跨层数据流、所有权和子规范索引 | 目标契约 |
|
||||
| [OneTalk 页面桥、Port 与命令路由](./onetalk/page-bridge.md) | MAIN/ISOLATED/SW 页面桥、Port 注册、页面身份和命令路由 | 目标契约 |
|
||||
| [OneTalk 耐久同步与连接生命周期](./onetalk/durable-sync.md) | IndexedDB、full/incremental/live、ACK、checkpoint 和重启恢复 | 目标契约 |
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# OneTalk 页面增强控件
|
||||
|
||||
## 1. Scope / Trigger
|
||||
|
||||
当 MAIN world 需要在 OneTalk SPA 页面内添加只读辅助控件(当前为复制会话 ID)时,遵循本文。控件只改善当前页面交互;不得新增 Page Bridge 消息、Service Worker 状态、IndexedDB 写入或 Bright 请求。
|
||||
|
||||
## 2. Signatures
|
||||
|
||||
```ts
|
||||
const installOneTalkConversationIdCopyControl = (
|
||||
pageWindow: OneTalkConversationIdCopyWindow,
|
||||
): void => {};
|
||||
```
|
||||
|
||||
入口由 `src/onetalk/main-page/page-script-entry.ts` 安装;控件实现在 `src/onetalk/main-page/conversation-id-copy.ts`,ID 读取复用 `readCurrentConversationId(pageWindow)`。
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
- 会话 ID 只接受 `readCurrentConversationId` 返回值;它要求恰好一个 `.contact-item-container.selected[data-cid]`。不得从 URL、会话标题、列表顺序、参与者或账号字段推断 ID。
|
||||
- 仅当会话 ID 唯一且标题节点可用时创建一个 `button[data-tmc-conversation-id-copy]`。SPA 切换或无唯一选中会话时,更新控件的 `data-conversation-id` 或移除控件,不能保留旧 ID。
|
||||
- 按钮直接追加到标题节点,使用 `inline-flex` 和 `justify-content: center` 保持同行及文字水平居中;文字为“复制会话 Id”,字体显式为 `12px`,不能依赖会话标题的继承字体。
|
||||
- 点击处理在用户手势中调用 `navigator.clipboard.writeText(conversationId)`;成功显示“已复制”,不可用或 reject 显示“复制失败”,随后恢复按钮文案。不会以 `execCommand`、隐形文本框或其它方式降级复制。
|
||||
- 页面 DOM 尚未就绪或 SPA 重绘时可由 `MutationObserver` 重新尝试挂载;同一页面只能存在一个该属性的按钮。
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
|
||||
| 条件 | 行为 |
|
||||
| --- | --- |
|
||||
| 恰好一个 selected `data-cid`,标题已渲染 | 在标题内安装按钮,复制该 ID |
|
||||
| 零个或多个 selected 会话 | 移除已有按钮,不复制、不猜测 ID |
|
||||
| 标题节点暂未渲染 | 不创建按钮,等待后续 DOM 变化重新检查 |
|
||||
| Clipboard API 不可用或 `writeText` reject | 显示“复制失败”,不抛出到 OneTalk 页面 |
|
||||
| `writeText` resolve | 显示“已复制”,定时恢复“复制会话 Id” |
|
||||
| SPA 重绘后旧按钮被移除 | 仅按新的唯一 selected `data-cid` 重建一个按钮 |
|
||||
|
||||
## 5. Good / Base / Bad Cases
|
||||
|
||||
- Good:用户选中单一会话,按钮和标题同行;点击后仅把该 DOM `data-cid` 写入剪贴板。
|
||||
- Base:页面首次加载时标题未出现;控件暂不显示,DOM 稳定后再安装。
|
||||
- Bad:读取地址栏 `conversationId`、复制标题文字或多个 selected 节点中的第一个值。
|
||||
- Bad:因为复制失败而创建 bridge frame、写 IndexedDB、上报 Bright 或显示成功状态。
|
||||
|
||||
## 6. Tests Required
|
||||
|
||||
- `test/onetalk-conversation-id-copy.test.js` 断言按钮嵌入标题、文案为“复制会话 Id”、字体为 `12px`、内容水平居中,并把唯一 selected `data-cid` 传给 Clipboard API。
|
||||
- 覆盖无唯一 selected 会话不安装按钮,以及 Clipboard reject 显示“复制失败”。
|
||||
- 修改控件后执行该定向测试、`pnpm --filter @trade-message-center/chrome-extension typecheck`,以及经 `scripts/with-build-hash.mjs` 注入构建标识的扩展构建。
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
|
||||
```ts
|
||||
// Wrong: 标题和 URL 都不是会话 ID 的可信来源。
|
||||
button.addEventListener("click", () => navigator.clipboard.writeText(title.textContent ?? ""));
|
||||
|
||||
// Correct: 只复制当前唯一 selected DOM 会话 ID。
|
||||
const conversationId = readCurrentConversationId(pageWindow);
|
||||
if (!conversationId) return;
|
||||
void navigator.clipboard.writeText(conversationId);
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
// 在当前会话标题旁提供复制会话 ID 控件
|
||||
|
||||
import { readCurrentConversationId } from "./page-context.ts";
|
||||
import type { OneTalkPageWindow } from "./model.ts";
|
||||
|
||||
type OneTalkConversationIdCopyWindow = OneTalkPageWindow &
|
||||
Pick<Window, "clearTimeout" | "document" | "navigator" | "setTimeout"> & {
|
||||
MutationObserver?: typeof MutationObserver;
|
||||
};
|
||||
|
||||
const COPY_BUTTON_ATTRIBUTE = "data-tmc-conversation-id-copy";
|
||||
const HEADER_MAX_BOTTOM_PX = 72;
|
||||
const BUTTON_LABEL = "复制会话 Id";
|
||||
const COPIED_LABEL = "已复制";
|
||||
const COPY_FAILED_LABEL = "复制失败";
|
||||
const BUTTON_RESTORE_DELAY_MS = 1_500;
|
||||
|
||||
const selectedConversationElement = (document: Document): Element | null => {
|
||||
const selected = document.querySelectorAll(".contact-item-container.selected[data-cid]");
|
||||
return selected.length === 1 ? selected[0] : null;
|
||||
};
|
||||
|
||||
const headerTitleElement = (document: Document): Element | null => {
|
||||
const selected = selectedConversationElement(document);
|
||||
if (!selected) return null;
|
||||
const selectedRect = selected.getBoundingClientRect();
|
||||
const maxLeft = document.defaultView?.innerWidth ?? Number.POSITIVE_INFINITY;
|
||||
const candidates = Array.from(document.querySelectorAll("body *")).filter((element) => {
|
||||
if (element.children.length > 0 || !element.textContent?.trim()) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.top >= 0 &&
|
||||
rect.bottom <= HEADER_MAX_BOTTOM_PX &&
|
||||
rect.left >= selectedRect.right &&
|
||||
rect.left < maxLeft * 0.75
|
||||
);
|
||||
});
|
||||
return (
|
||||
candidates.sort(
|
||||
(left, right) =>
|
||||
left.getBoundingClientRect().left - right.getBoundingClientRect().left ||
|
||||
left.getBoundingClientRect().top - right.getBoundingClientRect().top,
|
||||
)[0] ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const updateButtonLabel = (
|
||||
pageWindow: OneTalkConversationIdCopyWindow,
|
||||
button: HTMLButtonElement,
|
||||
label: string,
|
||||
): void => {
|
||||
button.textContent = label;
|
||||
button.setAttribute("aria-label", `${label}会话 Id`);
|
||||
pageWindow.clearTimeout(Number(button.dataset.restoreTimer));
|
||||
button.dataset.restoreTimer = String(
|
||||
pageWindow.setTimeout(() => {
|
||||
button.textContent = BUTTON_LABEL;
|
||||
button.setAttribute("aria-label", BUTTON_LABEL);
|
||||
}, BUTTON_RESTORE_DELAY_MS),
|
||||
);
|
||||
};
|
||||
|
||||
const createCopyButton = (
|
||||
pageWindow: OneTalkConversationIdCopyWindow,
|
||||
conversationId: string,
|
||||
): HTMLButtonElement => {
|
||||
const button = pageWindow.document.createElement("button");
|
||||
button.type = "button";
|
||||
button.setAttribute(COPY_BUTTON_ATTRIBUTE, "");
|
||||
button.setAttribute("aria-label", BUTTON_LABEL);
|
||||
button.title = BUTTON_LABEL;
|
||||
button.textContent = BUTTON_LABEL;
|
||||
button.dataset.conversationId = conversationId;
|
||||
button.style.cssText = [
|
||||
"margin-left: 8px",
|
||||
"padding: 2px 7px",
|
||||
"display: inline-flex",
|
||||
"align-items: center",
|
||||
"justify-content: center",
|
||||
"border: 1px solid #d8dce5",
|
||||
"border-radius: 4px",
|
||||
"background: #fff",
|
||||
"color: #315efb",
|
||||
"font-size: 12px",
|
||||
"line-height: 18px",
|
||||
"text-align: center",
|
||||
"cursor: pointer",
|
||||
"vertical-align: middle",
|
||||
].join(";");
|
||||
button.addEventListener("click", () => {
|
||||
const currentId = button.dataset.conversationId;
|
||||
if (!currentId) return;
|
||||
const clipboard = pageWindow.navigator.clipboard;
|
||||
if (!clipboard) {
|
||||
updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL);
|
||||
return;
|
||||
}
|
||||
void clipboard
|
||||
.writeText(currentId)
|
||||
.then(() => updateButtonLabel(pageWindow, button, COPIED_LABEL))
|
||||
.catch(() => updateButtonLabel(pageWindow, button, COPY_FAILED_LABEL));
|
||||
});
|
||||
return button;
|
||||
};
|
||||
|
||||
const updateCopyControl = (pageWindow: OneTalkConversationIdCopyWindow): void => {
|
||||
const existing = pageWindow.document.querySelector<HTMLButtonElement>(
|
||||
`button[${COPY_BUTTON_ATTRIBUTE}]`,
|
||||
);
|
||||
const conversationId = readCurrentConversationId(pageWindow);
|
||||
if (!conversationId) {
|
||||
existing?.remove();
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
existing.dataset.conversationId = conversationId;
|
||||
return;
|
||||
}
|
||||
const title = headerTitleElement(pageWindow.document);
|
||||
if (!title) return;
|
||||
title.append(createCopyButton(pageWindow, conversationId));
|
||||
};
|
||||
|
||||
/** 将当前会话 ID 的复制控件安装到 OneTalk 页面标题旁。 */
|
||||
export const installOneTalkConversationIdCopyControl = (
|
||||
pageWindow: OneTalkConversationIdCopyWindow,
|
||||
): void => {
|
||||
let refreshScheduled = false;
|
||||
const refresh = (): void => {
|
||||
refreshScheduled = false;
|
||||
updateCopyControl(pageWindow);
|
||||
};
|
||||
const scheduleRefresh = (): void => {
|
||||
if (refreshScheduled) return;
|
||||
refreshScheduled = true;
|
||||
pageWindow.setTimeout(refresh, 0);
|
||||
};
|
||||
const observer = pageWindow.MutationObserver
|
||||
? new pageWindow.MutationObserver(scheduleRefresh)
|
||||
: null;
|
||||
if (observer && pageWindow.document.documentElement) {
|
||||
observer.observe(pageWindow.document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
refresh();
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// 启动 OneTalk 页面监听与当前会话同步能力
|
||||
|
||||
import { installCurrentConversationHistorySync } from "./current-conversation-history/entry.ts";
|
||||
import { installOneTalkConversationIdCopyControl } from "./conversation-id-copy.ts";
|
||||
import { handleOneTalkHistoryCommand } from "./current-conversation-history/page-command.ts";
|
||||
import { installOneTalkMessageObserver } from "./message-observer/entry.ts";
|
||||
import { createHistoryMessageBatch } from "./message-observer/history.ts";
|
||||
@@ -38,6 +39,7 @@ const publishObservedBatch = (
|
||||
|
||||
/** 启动 main-page 目录下的全部 OneTalk 页面能力。 */
|
||||
const installOneTalkPageFeatures = (): void => {
|
||||
installOneTalkConversationIdCopyControl(window);
|
||||
const sendObservation = createSendObservationCorrelator();
|
||||
let profileObserver: OneTalkContactProfileObserver;
|
||||
profileObserver = installOneTalkContactProfileObserver(
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { installOneTalkConversationIdCopyControl } from "../src/onetalk/main-page/conversation-id-copy.ts";
|
||||
|
||||
const rect = (left, top, width, height) => ({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
});
|
||||
|
||||
class FakeElement {
|
||||
constructor({ conversationId, elementRect, textContent = "" } = {}) {
|
||||
this.conversationId = conversationId;
|
||||
this.elementRect = elementRect ?? rect(0, 0, 0, 0);
|
||||
this.textContent = textContent;
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.listeners = new Map();
|
||||
this.style = { cssText: "" };
|
||||
this.attributes = new Map();
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return name === "data-cid"
|
||||
? (this.conversationId ?? null)
|
||||
: (this.attributes.get(name) ?? null);
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return this.elementRect;
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
this.listeners.set(type, listener);
|
||||
}
|
||||
|
||||
append(element) {
|
||||
this.inserted = element;
|
||||
element.isConnected = true;
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
const createPage = () => {
|
||||
const selected = new FakeElement({
|
||||
conversationId: "conversation-1",
|
||||
elementRect: rect(80, 80, 340, 60),
|
||||
});
|
||||
const headerTitle = new FakeElement({
|
||||
elementRect: rect(436, 16, 112, 24),
|
||||
textContent: "Elliot Izzard",
|
||||
});
|
||||
const navigationTitle = new FakeElement({
|
||||
elementRect: rect(1_500, 16, 160, 24),
|
||||
textContent: "OKKI销售助手",
|
||||
});
|
||||
const document = {
|
||||
documentElement: {},
|
||||
defaultView: { innerWidth: 2_048 },
|
||||
button: null,
|
||||
querySelectorAll(selector) {
|
||||
if (selector === ".contact-item-container.selected[data-cid]") return [selected];
|
||||
if (selector === "body *") return [headerTitle, navigationTitle];
|
||||
return [];
|
||||
},
|
||||
querySelector(selector) {
|
||||
return selector === "button[data-tmc-conversation-id-copy]" ? this.button : null;
|
||||
},
|
||||
createElement() {
|
||||
const button = new FakeElement();
|
||||
button.type = "";
|
||||
Object.defineProperty(button, "isConnected", { value: false, writable: true });
|
||||
return button;
|
||||
},
|
||||
};
|
||||
const clipboardWrites = [];
|
||||
const pageWindow = {
|
||||
document,
|
||||
location: { href: "https://onetalk.alibaba.com/message/default.htm" },
|
||||
navigator: { clipboard: { writeText: async (value) => clipboardWrites.push(value) } },
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
};
|
||||
return { clipboardWrites, document, headerTitle, pageWindow, selected };
|
||||
};
|
||||
|
||||
test("installs a copy control beside the current conversation title", async () => {
|
||||
const { clipboardWrites, document, headerTitle, pageWindow } = createPage();
|
||||
|
||||
installOneTalkConversationIdCopyControl(pageWindow);
|
||||
|
||||
const button = headerTitle.inserted;
|
||||
assert.ok(button);
|
||||
assert.equal(button.textContent, "复制会话 Id");
|
||||
assert.equal(button.dataset.conversationId, "conversation-1");
|
||||
assert.equal(button.getAttribute("aria-label"), "复制会话 Id");
|
||||
assert.match(button.style.cssText, /font-size: 12px/u);
|
||||
assert.match(button.style.cssText, /justify-content: center/u);
|
||||
button.listeners.get("click")();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(clipboardWrites, ["conversation-1"]);
|
||||
assert.equal(button.textContent, "已复制");
|
||||
});
|
||||
|
||||
test("does not install a control without one unambiguous selected conversation", () => {
|
||||
const { document, pageWindow } = createPage();
|
||||
document.querySelectorAll = () => [];
|
||||
|
||||
installOneTalkConversationIdCopyControl(pageWindow);
|
||||
|
||||
assert.equal(document.button, null);
|
||||
});
|
||||
|
||||
test("shows a failure state when the page cannot write to the clipboard", async () => {
|
||||
const { headerTitle, pageWindow } = createPage();
|
||||
pageWindow.navigator.clipboard.writeText = async () => {
|
||||
throw new Error("clipboard_denied");
|
||||
};
|
||||
|
||||
installOneTalkConversationIdCopyControl(pageWindow);
|
||||
|
||||
headerTitle.inserted.listeners.get("click")();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(headerTitle.inserted.textContent, "复制失败");
|
||||
});
|
||||
Reference in New Issue
Block a user