diff --git a/README.md b/README.md
index c7d15b5..9e35562 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@
- **频道**:唯一的顶层概念,一条只追加的帧流,有自己的 id、密钥、设置和账号成员人数上限。
- **条目**:频道里的一项内容(文本、JSON、文件),由若干帧组成,可复制、下载、删除。
-- **终端**:加入频道的设备或会话,每个设备有独立终端席位;频道容量按账号成员人数计算,同一账号的多设备只算一人。
+- **成员**:频道里的人是用户(账号);设备只是账号的属性,同一账号的多设备只算一人,请求凭账号会话鉴权。未登录的**访客**以终端密钥 `S` 登记,每个访客终端各算一人。
- **所有者 / 成员**:所有者持有管理密钥,成员通过频道链接加入。
- **模式**:`enc`(默认,浏览器端 AES-256-GCM)和 `raw`(负载原样,服务器可见,用于公开或用户自行加密的内容)。
diff --git a/docs/frontend.md b/docs/frontend.md
index ccbf383..8b83ac9 100644
--- a/docs/frontend.md
+++ b/docs/frontend.md
@@ -101,7 +101,7 @@ type ItemState =
| 降级防御 | 链接含 `k` 就只接受 enc 且认证通过的帧 |
| 不把密钥、令牌、明文写入 `console`、错误上报、URL、`localStorage` | 「我的频道」只存 IndexedDB |
| 便捷分享草稿预览 | 原始文件只缓存在创建者本机 IndexedDB,不会随文件帧上传;移除条目或封存时清理,用于刷新后继续预览草稿 |
-| 账号资源保险库 | Worker 派生登录凭据与 AES-GCM 密钥;同步资源访问凭据、邀请私钥和移除墓碑;不含个人资料、草稿内容、终端 S、设备名或 Blob。按账号/访客分库,会话通过本机 IndexedDB 恢复 |
+| 账号资源保险库 | Worker 派生登录凭据与 AES-GCM 密钥;同步资源访问凭据、邀请私钥和移除墓碑;不含个人资料、草稿内容、访客终端 S 或 Blob。按账号/访客分库,会话通过本机 IndexedDB 恢复 |
| 频道与文件夹 | 后端持久化结构和资源归属,Worker 读取结构快照;加密文件元数据中的原始位置用于内容校验,UI 使用后端当前归属 |
| 条目预览在沙箱 iframe 渲染;raw 内容只当纯文本显示或下载 | 无内联渲染,不使用 `dangerouslySetInnerHTML` |
| 严格 CSP,所有脚本样式带 SRI,不使用内联脚本 | 与 architecture §5 一致 |
@@ -171,7 +171,7 @@ type ItemState =
## 12. 实际边界与会话
-core-bridge/instance.ts 使用真实 Worker 实现。账号、设备和个人资料由 worker/account.ts 与 accountProfile.ts 编排;账号密钥包通过 accountChannels.ts 与 channelGrant.ts 领取和核对;空间成员/设置/设备席位通过 channelResources.ts 调用 API;频道、分组和目录由 channelThreads.ts 调用后端。workspaceRecords.ts 只读取加密文件元数据,不处理账户或结构操作。
+core-bridge/instance.ts 使用真实 Worker 实现。账号、设备和个人资料由 worker/account.ts 与 accountProfile.ts 编排;账号密钥包通过 accountChannels.ts 与 channelGrant.ts 领取和核对;空间成员/设置/加入通过 channelResources.ts 调用 API;频道、分组和目录由 channelThreads.ts 调用后端。workspaceRecords.ts 只读取加密文件元数据,不处理账户或结构操作。
@webdrop/core 仅实现纯密码学与帧协议,不访问数据库或网络。sessions.ts 管本机已解锁会话。transfer.ts 管持久化密文发送队列,队列按资源/创建顺序索引,确保文件密文帧先于目录记录上传。downloadStorage.ts 优先使用 OPFS 逐块写入临时文件,认证结束后交付;不支持时最多 64 MiB 的 Blob 回退。
@@ -197,7 +197,7 @@ ContextMenu 是无业务 UI,负责 portal、视口限制、键盘焦点和关
ItemBase.author 由服务端资源作者和账号资料投影得到,晚到的解密内容不能覆盖服务器提供的作者与位置。客户端提供的作者声明不能覆盖服务端资料。这是服务端身份归属,不是端对端逐账号签名。
-ChatPane 订阅当前频道的条目 ID,ChatMessage 单独订阅条目状态和上传进度;文件目录变成文件消息,不重复创建独立文本通知。预览/下载继续经已有 store action,移动文件不会重新上传。GATED 内容只有明确操作后才读取。
+ChatPane 订阅当前频道的条目 ID(乐观发送:store 的 sendText/sendFiles 先写入 `local:` 占位项,worker 经 `localId` 带回后由 reduce 替换,失败可 `retrySend`),ChatMessage 单独订阅条目状态和上传进度;文件目录变成文件消息,不重复创建独立文本通知。预览/下载继续经已有 store action,移动文件不会重新上传。GATED 内容只有明确操作后才读取。
## 17. 本机凭据保存
diff --git a/docs/ui-design.md b/docs/ui-design.md
index 88fcf55..a092ded 100644
--- a/docs/ui-design.md
+++ b/docs/ui-design.md
@@ -148,7 +148,7 @@ WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thr
### 6.4.1 便捷分享
-Share 是独立的只读发布资源,不创建频道成员、threads 或席位。创建者在草稿中添加内容和设置,明确确认后封存;服务端清除 owner_hash,前端丢弃管理密钥,此后只有读取入口。访客打开分享链接直接读取加密清单,下载受限文件时计次数,不经过频道加入流程。详见 protocol.md §15。
+Share 是独立的只读发布资源,不创建频道成员或 threads。创建者在草稿中添加内容和设置,明确确认后封存;服务端清除 owner_hash,前端丢弃管理密钥,此后只有读取入口。访客打开分享链接直接读取加密清单,下载受限文件时计次数,不经过频道加入流程。详见 protocol.md §15。
### 6.5 频道 thread、文件管理与聊天
@@ -258,7 +258,7 @@ Share 是独立的只读发布资源,不创建频道成员、threads 或席位
| 码 | 页面 |
|---|---|
-| `seats_full` | 加入预览页:「成员人数已满」,告知联系所有者 |
+| `members_full` | 加入预览页:「成员人数已满」,告知联系所有者 |
| `not_joined`(被释放) | 频道页整体替换为「你已被移出此频道」 |
| `not_found` / `gone` | 「频道不存在或已过期」 |
| `rate_limited` | Toast,带重试倒计时 |
@@ -331,12 +331,20 @@ Share 是独立的只读发布资源,不创建频道成员、threads 或席位
## 13. 空间、频道与频道分组
-界面层级统一为「空间 → 频道分组(可选)→ 频道 → 聊天与文件夹」。原顶层频道对用户称为空间,原 thread 对用户称为频道。新建空间自动创建“常规”频道。空间负责成员、邀请、成员人数上限和加密边界;账号内设备不重复计入人数,设备席位只用于单设备认证与撤销。频道分组只组织导航,不改变权限或加密密钥。
+界面层级统一为「空间 → 频道分组(可选)→ 频道 → 聊天与文件夹」。原顶层频道对用户称为空间,原 thread 对用户称为频道。新建空间自动创建“常规”频道。空间负责成员、邀请、成员人数上限和加密边界;账号内设备不重复计入人数,设备只是账号的属性,只在账号设置里管理。频道分组只组织导航,不改变权限或加密密钥。
-左侧上段显示账号与空间列表,下段按分组展示频道。支持新建/重命名分组、折叠分组、在分组内新建频道、将已有频道移入其他分组或移回未分组。旧 thread 自动作为未分组频道展示。中间显示当前频道聊天,右侧显示当前频道文件夹和文件。
+桌面最左是 64px 的空间窄栏(每个空间一个首字方块,当前空间反色;下方是新建空间与加入空间),其右 240px 的频道列:顶部是空间名与加密状态(点击打开空间设置),下面按分组展示频道,分组标题小号灰字,悬停才显示 + 与 ⋯,频道行 32px、选中为整行淡底;便签并入频道列底部。账号卡片贴在左下角横跨两栏。手机上空间栏变成横向一排方块,空间名栏隐藏。支持新建/重命名分组、折叠分组、在分组内新建频道、将已有频道移入其他分组或移回未分组。旧 thread 自动作为未分组频道展示。中间显示当前频道聊天,右侧显示当前频道文件夹和文件。
空间设置集中管理成员、邀请和空间权限;频道管理提供名称与所属分组。分组名及归属由后端持久化并通过通知刷新,刷新与其他设备保持一致。协议/API 内部保留 channels/thread_id 等标识,避免因界面术语变更重建已有数据。
+### 13.1 成员栏、邀请与通知
+
+右侧栏顶部是「文件 / 成员 n」页签,默认文件;顶栏的人数按钮直接打开成员页,空间主还有一个 + 直接打开邀请弹窗。手机上「聊天 / 文件」切换里多一项「成员」。
+
+成员页以用户为单位,按 Discord 的分组排列:在线 — n、离线 — n、待接受 — n(仅空间主可见,有「撤销」)。每行头像、昵称(有昵称时下方小字显示用户名),空间主带「空间主」标签;一个用户有多台设备时小字显示「在线设备/设备数 台设备在线」。**设备不再单独列出,只是用户的属性。**在线状态与设备数由后端按账号统计(`GET /channels/{id}/members` 的 `online`、`devices`、`online_devices`,在线 = 该账号在本频道有活动订阅),前端在成员页可见期间每 8 秒读取一次,不改动订阅流。空间主在页头有醒目的「+ 邀请成员」,成员行悬停出现「移除」(需确认)。邀请与成员管理不再放在空间设置里,设置里只留一句指引。
+
+邀请弹窗只收用户名;成功、撤销、移除、失败都弹 toast。toast 在右下角,4 秒消失(带操作按钮的 10 秒),最多 4 条,成功 / 失败 / 提示用左侧色条区分,`aria-live`。被邀请方登录后每 8 秒轮询账号邀请,出现新邀请就弹「xxx 邀请你加入 yyy [接受]」,点「接受」直接加入并进入空间;账号设置里的邀请收件箱同样显示空间名与邀请人。空间名与邀请人放在邀请 payload 的加密内容里(`ChannelGrant.name / invitedBy`,均为可选字段),服务端不可见;旧邀请没有这两个字段时回退为「空间 + ID 前 8 位」。
+
## 14. 文件管理器交互
文件区支持单击选中、Ctrl/Command 多选、Shift 连选、双击预览、右键菜单和键盘 Enter/Delete/Ctrl+A/Escape。文件夹单击选中、双击或菜单“打开文件夹”进入;面包屑用于快速返回。文件行提供更多菜单按钮,触屏也能访问操作。
@@ -349,9 +357,13 @@ Share 是独立的只读发布资源,不创建频道成员、threads 或席位
## 15. 频道中的文件消息与发送者
-聊天时间线同时显示文本和已发布的文件,文件消息使用头像、发送者名称、时间和文件卡片,卡片提供预览和下载。当前账号发出的消息靠右,其他人的消息靠左;没有头像时显示名称首字。上传中显示进度和状态,受限文件不自动加载内容或媒体缩略图。
+聊天时间线采用 Discord 式布局:所有消息靠左,同一条左边线,左侧头像、右侧「名称 + 时间」一行与占满宽度的正文,不再区分自己和他人。同一发送者 5 分钟内连发的消息合并,只缩进对齐,悬停时在头像位置显示时间;悬停整行有淡底,右上角出现删除。文件消息是 360px 以内的文件卡片(图标、名称、大小与类型,右侧预览与下载);没有头像时显示名称首字。受限文件不自动加载内容或媒体缩略图。
-发送者名称与头像从后端账号资料读取,回退固定用户名;设备名保留在提示中。资源作者由后端记录,昵称修改后其他设备同步更新展示。没有账号归属时回退设备来源。
+输入区是整体圆角框(左 + 附件、中间自增高输入、右发送),占位文案「给 #频道名 发消息」,Enter 发送、Shift+Enter 换行(输入法组合中不触发)。
+
+**乐观发送**:点发送的同一帧就清空输入框并在时间线底部画出消息(半透明,aria-busy),后台再加密上传,不等后端;可以连续快速发送。worker 发布真实条目时带回 localId,在同一次状态更新里替换占位项,不闪烁、不重复。失败时占位项保留原文本并显示「发送失败 · 重试」,可删除;离线沿用本机发送队列。文件同理:确认上传选项后卡片立刻出现并显示进度。
+
+发送者名称与头像从后端账号资料读取,回退固定用户名。资源作者由后端记录,昵称修改后其他设备同步更新展示。没有账号归属(未登录访客)时统一显示「访客」;界面里不再展示设备名,设备只在账号设置的「我的设备」里管理。
头像缩略图在 Worker 内生成,最大 64×64、编码后不超过 8 KB,仅使用 PNG/JPEG/WebP data URL;不使用第三方头像链接。圆形头像使用专门的 --radius-avatar token,消息气泡仍遵守现有圆角规范。
diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx
index 0653937..caa9908 100644
--- a/web/src/app/App.tsx
+++ b/web/src/app/App.tsx
@@ -12,6 +12,8 @@ import { Splash } from './Splash';
import { AccountScreen } from '@/features/home/AccountScreen';
import { AccountDock } from '@/features/home/AccountDock';
import { useAccount } from '@/store/account';
+import { ToastHost } from './ToastHost';
+import { useInvitationNotices } from './useInvitationNotices';
const SEEN_KEY = 'wd.splash';
@@ -27,6 +29,7 @@ export function App() {
const username = useAccount((s) => s.username);
const accountHydrated = useAccount((s) => s.hydrated);
const refreshAccount = useAccount((s) => s.refresh);
+ useInvitationNotices(username);
useEffect(() => {
const timer = window.setInterval(() => { void refreshAccount(); }, 5000);
return () => window.clearInterval(timer);
@@ -82,6 +85,7 @@ export function App() {
<>
{screen}
{username ? : null}
+
{splash ? : null}
>
);
diff --git a/web/src/app/ToastHost.module.css b/web/src/app/ToastHost.module.css
new file mode 100644
index 0000000..3564c8f
--- /dev/null
+++ b/web/src/app/ToastHost.module.css
@@ -0,0 +1,76 @@
+.host {
+ position: fixed;
+ right: max(var(--space-4), env(safe-area-inset-right));
+ bottom: max(var(--space-4), env(safe-area-inset-bottom));
+ z-index: 60;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ width: min(360px, calc(100vw - var(--space-6)));
+ pointer-events: none;
+}
+
+.toast {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
+ border-inline-start: 3px solid var(--busy);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ box-shadow: var(--shadow-pop);
+ font-size: 13px;
+ pointer-events: auto;
+ animation: toast-in var(--duration-enter) ease-out;
+}
+
+.toast svg {
+ flex-shrink: 0;
+}
+
+.ok {
+ border-inline-start-color: var(--ok);
+}
+
+.ok svg {
+ color: var(--ok);
+}
+
+.error {
+ border-inline-start-color: var(--danger);
+}
+
+.error svg {
+ color: var(--danger);
+}
+
+.info svg {
+ color: var(--busy);
+}
+
+.text {
+ flex: 1;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.close {
+ width: 28px;
+ min-width: 28px;
+ height: 28px;
+ min-height: 28px;
+ padding: 0;
+}
+
+@keyframes toast-in {
+ from {
+ opacity: 0;
+ translate: 0 8px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .toast {
+ animation: none;
+ }
+}
diff --git a/web/src/app/ToastHost.tsx b/web/src/app/ToastHost.tsx
new file mode 100644
index 0000000..a13b794
--- /dev/null
+++ b/web/src/app/ToastHost.tsx
@@ -0,0 +1,19 @@
+import { useToast } from '@/store/toast';
+import { Button } from '@/ui/Button';
+import { Icon } from '@/ui/Icon';
+import styles from './ToastHost.module.css';
+
+const ICON = { ok: 'check', error: 'warn', info: 'bell' } as const;
+
+export function ToastHost() {
+ const toasts = useToast((s) => s.toasts);
+ const dismiss = useToast((s) => s.dismiss);
+ return
+ {toasts.map((toast) =>
+
+ {toast.text}
+ {toast.action ? : null}
+
)}
+
;
+}
diff --git a/web/src/app/useInvitationNotices.ts b/web/src/app/useInvitationNotices.ts
new file mode 100644
index 0000000..b674ad1
--- /dev/null
+++ b/web/src/app/useInvitationNotices.ts
@@ -0,0 +1,48 @@
+import { useEffect, useRef } from 'react';
+import { accountApi, type AccountInvitation } from '@/core-bridge/account';
+import { errorMessage } from '@/core-bridge/errors';
+import { useChannels } from '@/store/channels';
+import { useToast } from '@/store/toast';
+import { channelPath, navigate } from './navigation';
+
+const POLL_MS = 8000;
+
+export function invitationLabel(row: AccountInvitation): string {
+ return row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`;
+}
+
+async function accept(row: AccountInvitation): Promise {
+ try {
+ await accountApi.acceptInvitation(row.channel_id);
+ await useChannels.getState().refresh();
+ useToast.getState().push({ kind: 'ok', text: `已加入 ${invitationLabel(row)}` });
+ navigate(channelPath(row.channel_id));
+ } catch (cause) {
+ useToast.getState().push({ kind: 'error', text: `加入失败:${errorMessage(cause)}` });
+ }
+}
+
+/** 登录后轮询账号邀请,出现新邀请就弹通知,可直接接受 */
+export function useInvitationNotices(username: string | null): void {
+ const seen = useRef | null>(null);
+ useEffect(() => {
+ if (!username) { seen.current = null; return; }
+ let stopped = false;
+ async function poll() {
+ try {
+ const rows = await accountApi.invitations();
+ if (stopped) return;
+ const known = seen.current;
+ seen.current = new Set(rows.map((row) => row.channel_id));
+ const fresh = known ? rows.filter((row) => !known.has(row.channel_id)) : rows;
+ for (const row of fresh) {
+ useToast.getState().push({ kind: 'info', text: row.invitedBy ? `${row.invitedBy} 邀请你加入 ${invitationLabel(row)}` : `你收到了加入 ${invitationLabel(row)} 的邀请`,
+ action: { label: '接受', run: () => { void accept(row); } } });
+ }
+ } catch { /* 下一轮再试,不打扰用户 */ }
+ }
+ void poll();
+ const timer = window.setInterval(() => { void poll(); }, POLL_MS);
+ return () => { stopped = true; window.clearInterval(timer); };
+ }, [username]);
+}
diff --git a/web/src/core-bridge/account.ts b/web/src/core-bridge/account.ts
index c0b618b..93d0bc9 100644
--- a/web/src/core-bridge/account.ts
+++ b/web/src/core-bridge/account.ts
@@ -6,7 +6,7 @@ const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }
export type AccountDevice = AccountDeviceBody;
-export type AccountInvitation = InvitationBody;
+export type AccountInvitation = InvitationBody & { channelName?: string; invitedBy?: string };
export const accountApi = {
register: (username: string, password: string, remember = true) => callWorker('registerAccount', { username, password, remember, apiBase: API_BASE }),
diff --git a/web/src/core-bridge/api.ts b/web/src/core-bridge/api.ts
index 1237899..56097ed 100644
--- a/web/src/core-bridge/api.ts
+++ b/web/src/core-bridge/api.ts
@@ -11,9 +11,7 @@ export interface WorkspaceApi {
createChannel(opts: CreateOptions, captchaToken?: string): Promise;
/** 使用现有空间 settings 接口更新空间设置。 */
updateChannelSettings(patch: ChannelSettingsPatch): Promise;
- renameDevice(endpointHash: string, name: string): Promise;
- releaseDevice(endpointHash: string): Promise;
- /** 通过链接加入:先 GET 空间信息预览,再 POST seats */
+ /** 通过链接加入:先 GET 空间信息预览,再 POST join */
joinChannel(link: ParsedChannelLink, password?: string): Promise;
inviteUser(username: string): Promise;
listInvitations(): Promise;
@@ -24,8 +22,8 @@ export interface WorkspaceApi {
/** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
- sendText(text: string, threadId?: string): Promise;
- sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string): Promise;
+ sendText(text: string, threadId?: string, localId?: string): Promise;
+ sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise;
createThread(name: string, groupId?: string): Promise;
createGroup(name: string): Promise;
renameGroup(id: string, name: string): Promise;
diff --git a/web/src/core-bridge/contract.test.ts b/web/src/core-bridge/contract.test.ts
index 8a77765..98bf1b4 100644
--- a/web/src/core-bridge/contract.test.ts
+++ b/web/src/core-bridge/contract.test.ts
@@ -3,15 +3,16 @@ import { supportsApiContract } from './contract';
describe('API contract compatibility', () => {
it('accepts compatible SemVer releases in the supported major', () => {
- expect(supportsApiContract('6.0.0')).toBe(true);
- expect(supportsApiContract('6.8.2')).toBe(true);
- expect(supportsApiContract('6.2.0-rc.1')).toBe(true);
+ expect(supportsApiContract('7.0.0')).toBe(true);
+ expect(supportsApiContract('7.8.2')).toBe(true);
+ expect(supportsApiContract('7.2.0-rc.1')).toBe(true);
});
it('rejects a breaking major and malformed versions', () => {
expect(supportsApiContract('3.0.0')).toBe(false);
expect(supportsApiContract('4.0.0')).toBe(false);
expect(supportsApiContract('5.0.0')).toBe(false);
+ expect(supportsApiContract('6.1.0')).toBe(false);
expect(supportsApiContract('01.0.0')).toBe(false);
expect(supportsApiContract(undefined)).toBe(false);
});
diff --git a/web/src/core-bridge/contract.ts b/web/src/core-bridge/contract.ts
index d0ce036..124bf53 100644
--- a/web/src/core-bridge/contract.ts
+++ b/web/src/core-bridge/contract.ts
@@ -1,4 +1,4 @@
-export const SUPPORTED_API_CONTRACT_MAJOR = 6;
+export const SUPPORTED_API_CONTRACT_MAJOR = 7;
const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
diff --git a/web/src/core-bridge/database.ts b/web/src/core-bridge/database.ts
index 3f168ab..81c1998 100644
--- a/web/src/core-bridge/database.ts
+++ b/web/src/core-bridge/database.ts
@@ -19,8 +19,6 @@ export interface SavedResource {
kind: 'channel' | 'share';
/** 服务端空间设置名称的本地缓存,打开空间时会用最新设置刷新。 */
name?: string;
- /** 本终端在席位列表中的自定义显示名称。 */
- deviceLabel?: string;
mode: 'enc' | 'raw';
/** Keys stay in IndexedDB. The page URL and localStorage never retain them. */
link: string;
@@ -30,7 +28,7 @@ export interface SavedResource {
mine?: boolean;
createdAt: number;
expiresAt: number;
- seats: number;
+ maxMembers: number;
state?: 'draft' | 'sealed';
maxVisitors?: number;
maxDownloads?: number | null;
@@ -137,7 +135,7 @@ export async function importResources(resources: SavedResource[]): Promise
const existing = await tx.store.get(resource.id);
await tx.store.put({ ...resource, localScope: resourceScope,
endpointSecret: existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32)),
- deviceLabel: existing?.deviceLabel, localItems: existing?.localItems,
+ localItems: existing?.localItems,
pendingManifestId: existing?.pendingManifestId,
needsJoin: existing?.needsJoin ?? (!existing && resource.kind === 'channel'),
});
diff --git a/web/src/core-bridge/errors.ts b/web/src/core-bridge/errors.ts
index 55653ac..05e05c9 100644
--- a/web/src/core-bridge/errors.ts
+++ b/web/src/core-bridge/errors.ts
@@ -1,6 +1,6 @@
const messages: Record = {
not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。',
- seats_full: '空间成员人数已满,请联系所有者。', not_joined: '你已被移出此空间。',
+ members_full: '空间成员人数已满,请联系所有者。', not_joined: '你已被移出此空间。',
visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。',
forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。',
too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。',
diff --git a/web/src/core-bridge/protocol/account.ts b/web/src/core-bridge/protocol/account.ts
index 726f7d9..84238d0 100644
--- a/web/src/core-bridge/protocol/account.ts
+++ b/web/src/core-bridge/protocol/account.ts
@@ -17,7 +17,7 @@ export interface VaultResource {
expiresAt: number;
creatorAccount?: string;
name?: string;
- seats: number;
+ maxMembers: number;
ttlSeconds?: number;
maxDownloads?: number | null;
maxVisitors?: number;
diff --git a/web/src/core-bridge/protocol/workspace.ts b/web/src/core-bridge/protocol/workspace.ts
index f8818da..716e783 100644
--- a/web/src/core-bridge/protocol/workspace.ts
+++ b/web/src/core-bridge/protocol/workspace.ts
@@ -8,7 +8,6 @@ export interface PublishedFile {
mime: string;
size: number;
ts: number;
- deviceId: string;
gated: boolean;
}
export type ResourceControl = { op: 'file.published'; item: PublishedFile };
diff --git a/web/src/core-bridge/real.ts b/web/src/core-bridge/real.ts
index ac294fc..f129b2e 100644
--- a/web/src/core-bridge/real.ts
+++ b/web/src/core-bridge/real.ts
@@ -17,8 +17,6 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) =
export const realApi: WorkspaceApi = {
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker('createChannel', { options, captchaToken, apiBase: API_BASE }),
updateChannelSettings: (patch: ChannelSettingsPatch) => callWorker('updateChannelSettings', { id: currentChannelId(), patch, apiBase: API_BASE }),
- renameDevice: (endpointHash, name) => callWorker('renameDevice', { id: currentChannelId(), endpointHash, name, apiBase: API_BASE }),
- releaseDevice: (endpointHash) => callWorker('releaseDevice', { id: currentChannelId(), endpointHash, apiBase: API_BASE }),
joinChannel: (link: ParsedChannelLink, password?: string) => callWorker('joinChannel', { link, password, apiBase: API_BASE }),
inviteUser: (username) => callWorker('inviteChannelUser', { id: currentChannelId(), username, apiBase: API_BASE }),
listInvitations: () => callWorker('listChannelInvitations', { id: currentChannelId(), apiBase: API_BASE }),
@@ -37,8 +35,8 @@ export const realApi: WorkspaceApi = {
};
},
- sendText: (text, threadId) => callWorker('sendText', { id: currentChannelId(), text, threadId, apiBase: API_BASE }),
- sendFiles: (files, options, threadId, folderId) => callWorker('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, apiBase: API_BASE }),
+ sendText: (text, threadId, localId) => callWorker('sendText', { id: currentChannelId(), text, threadId, localId, apiBase: API_BASE }),
+ sendFiles: (files, options, threadId, folderId, localIds) => callWorker('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, localIds, apiBase: API_BASE }),
createThread: (name, groupId) => callWorker('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }),
createGroup: (name) => callWorker('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }),
renameGroup: (groupId, name) => callWorker('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }),
diff --git a/web/src/core-bridge/settings.ts b/web/src/core-bridge/settings.ts
index ed7391a..c803da6 100644
--- a/web/src/core-bridge/settings.ts
+++ b/web/src/core-bridge/settings.ts
@@ -3,6 +3,6 @@ import type { CreateOptions } from './types';
export function creationOptions(defaults: DefaultsBody): CreateOptions {
const settings = defaults.channel;
- return { mode: defaults.channel_mode, seats: settings.seats, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
+ return { mode: defaults.channel_mode, maxMembers: settings.max_members, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy, allowGuests: settings.allow_guests };
}
diff --git a/web/src/core-bridge/types.ts b/web/src/core-bridge/types.ts
index 0efddf7..4b339b1 100644
--- a/web/src/core-bridge/types.ts
+++ b/web/src/core-bridge/types.ts
@@ -1,19 +1,8 @@
import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceFolder, WorkspaceState, AccountProfile } from './generated/api';
-export type DeviceKind = 'desktop' | 'phone' | 'tablet';
export type ConnectionState = 'online' | 'reconnecting' | 'offline';
export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none';
-export interface Device {
- id: string;
- name: string;
- kind: DeviceKind;
- self?: boolean;
- /** 正在编辑便签 */
- editing?: boolean;
- joinedAt?: number;
-}
-
/** 常驻空间:自己多台设备之间长期同步 */
export interface ChannelInfo {
dataSource?: 'server' | 'cache';
@@ -26,7 +15,7 @@ export interface ChannelInfo {
name?: string;
mode: ChannelMode;
role: 'owner' | 'member';
- seats: number;
+ maxMembers: number;
membersUsed?: number;
membersCanUpload?: boolean;
ttlSeconds?: number;
@@ -48,7 +37,7 @@ export interface CreateOptions {
joinPassword?: string;
name?: string;
mode: ChannelMode;
- seats: number;
+ maxMembers: number;
ttlSeconds: number;
membersCanUpload: boolean;
fileMaxDownloads?: number;
@@ -65,7 +54,7 @@ export interface ChannelSettingsPatch {
allowGuests?: boolean;
joinPassword?: string;
name?: string;
- seats?: number;
+ maxMembers?: number;
membersCanUpload?: boolean;
ttlSeconds?: number;
fileMaxDownloads?: number;
@@ -111,10 +100,11 @@ interface ItemBase {
author?: SenderInfo;
id: string;
ts: number;
- deviceId: string;
state: ItemState;
threadId?: string;
folderId?: string;
+ /** 乐观渲染:本地占位项的 id,worker 发布真实条目时带回,reduce 据此替换占位项 */
+ localId?: string;
}
export type SenderInfo = Pick & { avatar?: string };
@@ -123,8 +113,8 @@ export interface TextItem extends ItemBase {
kind: 'text';
text: string;
editedAt?: number;
- /** 另一台设备同时改过(后写者胜,见 ui-design §8) */
- conflict?: { deviceId: string; text: string };
+ /** 另一位用户同时改过(后写者胜,见 ui-design §8) */
+ conflict?: { author?: SenderInfo; text: string };
}
export interface FileItem extends ItemBase {
@@ -144,14 +134,14 @@ export interface FileItem extends ItemBase {
* 客户端据此把它们折叠成同一个文件的历史。后端不解析 meta。
*/
versions?: FileVersion[];
- /** 当前版本是别的设备改的,而你还没看过 */
+ /** 当前版本是别人改的,而你还没看过 */
unseen?: boolean;
}
export interface FileVersion {
id: string;
ts: number;
- deviceId: string;
+ author?: SenderInfo;
size: number;
}
@@ -181,7 +171,6 @@ export type WorkspaceEvent =
| { t: 'ready' }
| { t: 'connection'; state: ConnectionState }
| { t: 'channel'; info: ChannelInfo }
- | { t: 'devices'; devices: Device[] }
| { t: 'item'; item: Item }
| { t: 'file-catalog'; item: FileItem }
| { t: 'removed'; id: string }
diff --git a/web/src/core-bridge/worker.ts b/web/src/core-bridge/worker.ts
index bc7e58a..8879757 100644
--- a/web/src/core-bridge/worker.ts
+++ b/web/src/core-bridge/worker.ts
@@ -1,7 +1,7 @@
import { setApiBase, getServerInfo } from './worker/http';
import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys';
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
-import { createChannel, forgetChannel, joinChannel, listChannels, releaseDevice, renameDevice, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
+import { createChannel, forgetChannel, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
import { stopChannel } from './worker/channelTransport';
import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft';
@@ -68,8 +68,6 @@ async function dispatch(operation: string, input: unknown): Promise {
case 'createFolder': return createFolder(id, String(payload.threadId ?? ''), String(payload.name ?? ''), typeof payload.parentId === 'string' ? payload.parentId : undefined);
case 'renameFolder': return renameFolder(id, String(payload.folderId ?? ''), String(payload.name ?? ''), String(payload.threadId ?? ''));
case 'moveItems': return moveItems(id, payload.itemIds as string[], String(payload.threadId ?? ''), typeof payload.folderId === 'string' ? payload.folderId : undefined);
- case 'renameDevice': return renameDevice(id, String(payload.endpointHash ?? ''), String(payload.name ?? ''));
- case 'releaseDevice': return releaseDevice(id, String(payload.endpointHash ?? ''));
case 'joinChannel': return joinChannel(payload as Parameters[0]);
case 'previewChannel': return previewChannel(payload.link as Parameters[0]);
case 'channelMembers': return channelMembers(id);
@@ -77,8 +75,8 @@ async function dispatch(operation: string, input: unknown): Promise {
case 'forgetChannel': return forgetChannel(id);
case 'connectChannel': void connectChannelResource(id); return true;
case 'stopChannel': stopChannel(id); return true;
- case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined);
- case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined);
+ case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined, undefined, typeof payload.localId === 'string' ? payload.localId : undefined);
+ case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined, Array.isArray(payload.localIds) ? payload.localIds as string[] : undefined);
case 'removeChannelItem': return removeChannelItem(id, String(payload.itemId));
case 'getChannelBlob': return getChannelBlob(id, String(payload.itemId), typeof payload.versionId === 'string' ? payload.versionId : undefined);
case 'updateSnapshot': return updateSnapshot(id, String(payload.text ?? ''));
diff --git a/web/src/core-bridge/worker/account.ts b/web/src/core-bridge/worker/account.ts
index c7d7e31..227aa28 100644
--- a/web/src/core-bridge/worker/account.ts
+++ b/web/src/core-bridge/worker/account.ts
@@ -5,7 +5,7 @@ import { setAccountIdentity, requireAccount } from './accountSession';
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto';
import { mergeVault, validateVault, type VaultProfile } from './accountVault';
import { joinChannel } from './channelResources';
-import { openChannelGrant, sealChannelGrant } from './channelGrant';
+import { openChannelGrant, peekChannelGrant, sealChannelGrant } from './channelGrant';
import { recoverAccountChannels } from './accountChannels';
import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName, saveLoginFields, forgetLoginFields, savedLoginFields } from './accountPersistence';
import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api';
@@ -26,7 +26,7 @@ interface AccountSession {
controller: AbortController; dirty: boolean; syncing?: Promise; error?: string;
timer?: ReturnType; polling?: ReturnType;
}
-export type Invitation = InvitationBody;
+export type Invitation = InvitationBody & { channelName?: string; invitedBy?: string };
let account: AccountSession | undefined;
export function unlockedAccount() {
if (!account) throw new Error('请先登录账号');
@@ -246,7 +246,13 @@ export async function revokeAccountDevice(id: string): Promise {
if ((await localAccountDevice()).id === id) await logoutAccount();
}
export async function listAccountInvitations(): Promise {
- requireAccount(); return apiFetch('/accounts/invitations').then((r) => r.json() as Promise);
+ requireAccount();
+ const rows = await apiFetch('/accounts/invitations').then((r) => r.json() as Promise);
+ const session = account;
+ return Promise.all(rows.map(async (row): Promise => {
+ const meta = session ? await peekChannelGrant(row.payload, session.username, row.channel_id, session.profile.keys.privateKey) : {};
+ return { ...row, ...(meta.name ? { channelName: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) };
+ }));
}
export async function acceptInvitation(channelId: string): Promise {
if (!account) throw new Error('请先登录账号');
@@ -262,7 +268,7 @@ export async function inviteChannelUser(channelId: string, input: string): Promi
const plan = await apiFetch(`/channels/${channelId}/invitations/${username}/crypto-plan`, { headers: session.authHeaders('owner') })
.then(r => r.json() as Promise);
const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise);
- const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey);
+ const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey, { name: resource.name, invitedBy: account?.username });
const domainEnvelopes = await packageInvitationDomains(resource, session, plan);
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload, domain_envelopes: domainEnvelopes }) });
}
diff --git a/web/src/core-bridge/worker/accountChannels.ts b/web/src/core-bridge/worker/accountChannels.ts
index 8be6e2d..0ee7952 100644
--- a/web/src/core-bridge/worker/accountChannels.ts
+++ b/web/src/core-bridge/worker/accountChannels.ts
@@ -25,7 +25,7 @@ export async function recoverAccountChannels(username: string, keys: InvitationK
creatorAccount: info.creator_account ?? undefined, name: info.settings.name ?? undefined,
link: session.link(self.location.origin), ...(grant.role === 'owner' ? { ownerLink: session.link(self.location.origin, 'owner') } : {}),
endpointSecret: new Uint8Array(32), createdAt: info.created_at, modifiedAt: grant.created_at, expiresAt: info.expires_at,
- seats: info.settings.seats, maxDownloads: info.settings.file_max_downloads };
+ maxMembers: info.settings.max_members, maxDownloads: info.settings.file_max_downloads };
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
await importResources([resource]);
} finally { session.dispose(); }
diff --git a/web/src/core-bridge/worker/accountVault.test.ts b/web/src/core-bridge/worker/accountVault.test.ts
index 7129282..647fe12 100644
--- a/web/src/core-bridge/worker/accountVault.test.ts
+++ b/web/src/core-bridge/worker/accountVault.test.ts
@@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest';
import { exportResource, mergeVault, type VaultProfile } from './accountVault';
import type { SavedResource } from '../database';
const resource = (modifiedAt = 10): SavedResource => ({ id: 'AAAAAAAAAAAAAAAAAAAAAA', kind: 'channel', mode: 'enc', role: 'owner',
- link: 'https://drop.test/c/a#k=secret', endpointSecret: new Uint8Array(32).fill(7), deviceLabel: 'device-a', localScope: 'account:alice',
- createdAt: 1, modifiedAt, expiresAt: 100, seats: 3, name: `version-${modifiedAt}` });
+ link: 'https://drop.test/c/a#k=secret', endpointSecret: new Uint8Array(32).fill(7), localScope: 'account:alice',
+ createdAt: 1, modifiedAt, expiresAt: 100, maxMembers: 3, name: `version-${modifiedAt}` });
const profile = (): VaultProfile => ({ v: 3, keys: { publicKey: {}, privateKey: {}, signing: { publicKey: {}, privateKey: {} } }, resources: [exportResource(resource(20))], deletes: [] });
describe('account vault portability and merge', () => {
it('never synchronizes device identity or local scope', () => {
const exported = exportResource(resource());
- expect(exported).not.toHaveProperty('endpointSecret'); expect(exported).not.toHaveProperty('deviceLabel');
+ expect(exported).not.toHaveProperty('endpointSecret');
expect(exported).not.toHaveProperty('localScope'); expect(exported.link).toContain('secret');
});
it('a stale device does not overwrite a newer remote resource', () => {
diff --git a/web/src/core-bridge/worker/accountVault.ts b/web/src/core-bridge/worker/accountVault.ts
index 6a20b53..74cd928 100644
--- a/web/src/core-bridge/worker/accountVault.ts
+++ b/web/src/core-bridge/worker/accountVault.ts
@@ -7,7 +7,7 @@ export function exportResource(resource: SavedResource): VaultResource {
domainKeys: resource.domainKeys,
link: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
modifiedAt: resource.modifiedAt, creatorAccount: resource.creatorAccount, name: resource.name,
- seats: resource.seats, ttlSeconds: resource.ttlSeconds,
+ maxMembers: resource.maxMembers, ttlSeconds: resource.ttlSeconds,
maxDownloads: resource.maxDownloads, maxVisitors: resource.maxVisitors, mine: resource.mine, state: resource.state };
}
export function validateVault(value: unknown): VaultProfile {
diff --git a/web/src/core-bridge/worker/channelEvents.ts b/web/src/core-bridge/worker/channelEvents.ts
index c55af7d..5505a60 100644
--- a/web/src/core-bridge/worker/channelEvents.ts
+++ b/web/src/core-bridge/worker/channelEvents.ts
@@ -3,11 +3,10 @@ import { canDecryptRecord, objectEventProjection, readDataKeys, verifyObjectDele
import { getResource, cacheResource, type SavedResource } from '../database';
import { channelCache } from './resourceMetadata';
import { loadWorkspace } from './workspaceStructure';
-import type { SeatInfo } from '../generated/api';
import type { FileItem, Item, Snapshot } from '../types';
import { previewKindOf } from '@/lib/fileKind';
import { blobs, readers } from './sessions';
-import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation';
+import { showRejected, publishWorkspace } from './presentation';
import { type ChannelWire, toChannelInfo } from './wire';
import { apiFetch, readJson } from './http';
import { readResourceRecord } from './workspaceRecords';
@@ -15,12 +14,11 @@ import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog';
export function mapReceived(item: ReceivedItem): Item {
const timestamp = item.meta?.ts ?? Date.now();
- const deviceId = item.meta?.dev ?? '未知设备';
const state = item.complete ? { kind: 'complete' as const } : { kind: 'incomplete' as const };
if (item.kind === 'file') {
const name = item.meta?.name ?? '加密文件';
return {
- id: item.itemId, kind: 'file', ts: timestamp, deviceId, state, name,
+ id: item.itemId, kind: 'file', ts: timestamp, state, name,
...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}),
...(item.meta?.folderId ? { folderId: item.meta.folderId } : {}),
mime: item.meta?.mime ?? 'application/octet-stream', size: item.meta?.size ?? item.content.byteLength,
@@ -28,7 +26,7 @@ export function mapReceived(item: ReceivedItem): Item {
gated: item.gated,
};
}
- return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state,
+ return { id: item.itemId, kind: 'text', ts: timestamp, state,
...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}),
text: new TextDecoder().decode(item.content) };
}
@@ -70,11 +68,11 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
const item = mapReceived(event.item);
if (item.kind === 'file') visibleFiles.add(`${resource.id}:${item.id}`);
if (event.item.snapshot) {
- let snapshot: Snapshot = { text: '', updatedBy: item.deviceId, updatedAt: item.ts };
+ let snapshot: Snapshot = { text: '', updatedBy: '', updatedAt: item.ts };
if (item.kind === 'text') {
try {
const value = JSON.parse(item.text) as Partial;
- if (typeof value.text === 'string') snapshot = { text: value.text, updatedBy: value.updatedBy ?? item.deviceId, updatedAt: value.updatedAt ?? item.ts };
+ if (typeof value.text === 'string') snapshot = { text: value.text, updatedBy: value.updatedBy ?? '', updatedAt: value.updatedAt ?? item.ts };
else snapshot.text = item.text;
} catch { snapshot.text = item.text; }
}
@@ -93,7 +91,7 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
if (catalog) { publishWorkspace(resource.id, { t: 'file-catalog', item: catalog }); break; }
const local = (await getResource(resource.id))?.localItems?.find((item) => item.id === event.itemId);
const placeholder: FileItem = local?.kind === 'file' ? local : {
- id: event.itemId, kind: 'file', ts: event.createdAt, deviceId: '未知设备', state: { kind: 'incomplete' },
+ id: event.itemId, kind: 'file', ts: event.createdAt, state: { kind: 'incomplete' },
name: '受限下载内容', mime: 'application/octet-stream', size: 0, remaining: null, preview: 'none',
gated: true, metadataHidden: true,
};
@@ -128,22 +126,6 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
Object.assign(resource, await cacheResource(resource, channelCache(info)));
publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
await loadWorkspace(resource.id);
- const ownId = toBase64Url(session.endpointHash);
- const ownName = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
- let devices = [deviceInfo(ownId, Date.now(), ownName, ownId)];
- if (session.can('owner')) {
- const seats = await readJson(
- `/channels/${resource.id}/seats`, session.authHeaders('owner'));
- devices = await Promise.all(seats.map(async (seat) => {
- let name: string | undefined;
- if (seat.label) {
- try { name = await session.openLabel(seat.label, seat.endpoint_hash); }
- catch { name = '设备名校验失败'; }
- }
- return deviceInfo(seat.endpoint_hash, seat.joined_at, name ?? (seat.endpoint_hash === ownId ? ownName : undefined), ownId);
- }));
- }
- publishWorkspace(resource.id, { t: 'devices', devices });
}
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise {
diff --git a/web/src/core-bridge/worker/channelGrant.ts b/web/src/core-bridge/worker/channelGrant.ts
index e05b372..f82014a 100644
--- a/web/src/core-bridge/worker/channelGrant.ts
+++ b/web/src/core-bridge/worker/channelGrant.ts
@@ -8,16 +8,30 @@ interface ChannelGrant {
mode: 'enc' | 'raw';
role: 'owner' | 'member';
keys: ParsedChannelLink['keys'];
+ /** 仅供被邀请方在收件箱显示,在加密 payload 内,服务端不可见 */
+ name?: string;
+ invitedBy?: string;
}
-export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey): Promise {
+export interface InvitationMeta { name?: string; invitedBy?: string }
+
+export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey, meta: InvitationMeta = {}): Promise {
const parsed = parseChannelLink(link);
if (!parsed.ok) throw new Error('空间凭据格式无效');
const grant: ChannelGrant = { v: 1, channelId: parsed.link.id, mode: parsed.link.mode,
- role: parsed.link.isOwner ? 'owner' : 'member', keys: parsed.link.keys };
+ role: parsed.link.isOwner ? 'owner' : 'member', keys: parsed.link.keys,
+ ...(meta.name ? { name: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) };
return encryptInvitation(JSON.stringify(grant), recipient, grant.channelId, publicKey);
}
+/** 只读取显示用的元信息;解不开或没有这些字段时返回空,调用方回退到空间 ID */
+export async function peekChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey): Promise {
+ try {
+ const grant = JSON.parse(await decryptInvitation(payload, recipient, channelId, privateKey)) as Partial;
+ return { ...(typeof grant.name === 'string' ? { name: grant.name } : {}), ...(typeof grant.invitedBy === 'string' ? { invitedBy: grant.invitedBy } : {}) };
+ } catch { return {}; }
+}
+
export async function openChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey,
role: 'owner' | 'member', origin: string): Promise {
const plain = await decryptInvitation(payload, recipient, channelId, privateKey);
diff --git a/web/src/core-bridge/worker/channelItems.ts b/web/src/core-bridge/worker/channelItems.ts
index ac5b29c..dfd344c 100644
--- a/web/src/core-bridge/worker/channelItems.ts
+++ b/web/src/core-bridge/worker/channelItems.ts
@@ -46,7 +46,7 @@ export async function getChannelBlob(resourceId: string, itemId: string, version
verifyFileCatalog(fileCatalogs.get(`${resourceId}:${itemId}`), result.meta, result.blob.size);
}
catch (error) {
- if (error instanceof IntegrityError) publishWorkspace(resourceId, { t: 'item', item: { id: itemId, kind: 'text', ts: Date.now(), deviceId: '', text: '', state: { kind: 'tampered' } } });
+ if (error instanceof IntegrityError) publishWorkspace(resourceId, { t: 'item', item: { id: itemId, kind: 'text', ts: Date.now(), text: '', state: { kind: 'tampered' } } });
throw error;
}
const fileBlob = result.meta?.kind === 'file' && typeof File !== 'undefined'
@@ -58,7 +58,7 @@ export async function getChannelBlob(resourceId: string, itemId: string, version
blobs.set(`${resourceId}:${itemId}`, fileBlob);
if (result.meta?.kind === 'file') {
const item: FileItem = {
- id: itemId, kind: 'file', ts: result.meta.ts, deviceId: result.meta.dev ?? '未知设备', state: { kind: 'complete' },
+ id: itemId, kind: 'file', ts: result.meta.ts, state: { kind: 'complete' },
...(result.meta.threadId ? { threadId: result.meta.threadId } : {}),
...(result.meta.folderId ? { folderId: result.meta.folderId } : {}),
name: result.meta.name ?? '加密文件', mime: result.meta.mime ?? result.blob.type, size: fileBlob.size,
diff --git a/web/src/core-bridge/worker/channelResources.ts b/web/src/core-bridge/worker/channelResources.ts
index 6e6fdb7..eb3b11a 100644
--- a/web/src/core-bridge/worker/channelResources.ts
+++ b/web/src/core-bridge/worker/channelResources.ts
@@ -1,4 +1,4 @@
-import { ChannelSession, toBase64Url } from '@webdrop/core';
+import { ChannelSession } from '@webdrop/core';
import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
import type { ParsedChannelLink } from '@/lib/channelLink';
@@ -7,14 +7,12 @@ import { apiFetch, readJson, getServerInfo } from './http';
import { channelCache, isOfflineFailure } from './resourceMetadata';
import { accountToken } from './accountSession';
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
-import type { ChannelSettingsPatch as SettingsRequest, SeatJoined, MemberBody } from '../generated/api';
+import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody } from '../generated/api';
import { getSession, sessions } from './sessions';
-import { deviceIdentity } from './presentation';
import { makeLink } from './links';
import { toChannelInfo, type ChannelWire } from './wire';
import { stopChannel } from './channelTransport';
import { publishWorkspace } from './presentation';
-import { channelMetadata } from './channelEvents';
import { requireAccount, accountPublicKey } from './accountSession';
import { sealChannelGrant } from './channelGrant';
@@ -32,7 +30,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
settings: {
...defaults,
name: options.name?.trim() || undefined,
- seats: options.seats,
+ max_members: options.maxMembers,
ttl_seconds: options.ttlSeconds,
members_can_upload: options.membersCanUpload,
file_max_downloads: options.fileMaxDownloads,
@@ -53,13 +51,9 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
name: info.settings.name ?? undefined,
link: session.link(self.location.origin), ownerLink: session.link(self.location.origin, 'owner'),
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
- seats: info.settings.seats,
+ maxMembers: info.settings.max_members,
maxDownloads: info.settings.file_max_downloads, localItems: [],
};
- const ownerLabel = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
- await apiFetch(`/channels/${session.id}/seats/me`, {
- method: 'PUT', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, body: ownerLabel,
- });
await saveResource(resource);
sessions.set(session.id, session);
attachDataKeys(session, resource);
@@ -80,10 +74,10 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
try {
const info = await readJson(`/channels/${id}`, session.authHeaders('read'));
session.verifyChannelInfo(info);
- const joined = await apiFetch(`/channels/${id}/seats`, {
+ const joined = await apiFetch(`/channels/${id}/join`, {
method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.password }),
- }).then((response) => response.json() as Promise);
- info.seats_used = joined.seats_used;
+ }).then((response) => response.json() as Promise);
+ info.members_used = joined.members_used;
const resource: SavedResource = {
...channelCache(info),
id, kind: 'channel', mode: session.mode, role: session.role,
@@ -91,13 +85,9 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
name: info.settings.name ?? undefined,
link: session.link(self.location.origin), ownerLink: session.can('owner') ? session.link(self.location.origin, 'owner') : undefined,
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
- seats: info.settings.seats,
+ maxMembers: info.settings.max_members,
maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [],
};
- const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
- await apiFetch(`/channels/${id}/seats/me`, {
- method: 'PUT', headers: { ...session.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, body: label,
- });
await saveResource(resource);
sessions.set(id, session);
return toChannelInfo(info, resource);
@@ -114,7 +104,7 @@ export async function previewChannel(link: ParsedChannelLink): Promise {
if (!isOfflineFailure(error)) throw error;
if (resource.channelMetadata) return { ...toChannelInfo(resource.channelMetadata, resource), dataSource: 'cache' };
return {
- id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, seats: resource.seats, maxDownloads: resource.maxDownloads ?? undefined,
+ id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, maxMembers: resource.maxMembers, maxDownloads: resource.maxDownloads ?? undefined,
creatorAccount: resource.creatorAccount,
memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
dataSource: 'cache',
@@ -161,7 +151,7 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
const body: SettingsRequest = {};
if (patch.name !== undefined) body.name = patch.name.trim();
- if (patch.seats !== undefined) body.seats = patch.seats;
+ if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
@@ -177,36 +167,6 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, updated) });
}
-export async function renameDevice(resourceId: string, endpointHash: string, name: string): Promise {
- const { session, resource } = await getSession(resourceId);
- const ownEndpointHash = toBase64Url(session.endpointHash);
- const isSelf = endpointHash === ownEndpointHash;
- if (!isSelf && !session.can('owner')) throw new Error('只有所有者可以重命名其他设备');
- const normalized = name.trim();
- if (!normalized) throw new Error('设备名称不能为空');
- const label = await session.sealLabel(normalized, { endpointHash });
- const path = isSelf
- ? `/channels/${resourceId}/seats/me`
- : `/channels/${resourceId}/seats/${endpointHash}/label`;
- const role = !isSelf || session.can('owner') ? 'owner' : 'read';
- await apiFetch(path, {
- method: 'PUT', headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream' }, body: label,
- });
- if (isSelf) {
- resource.deviceLabel = normalized;
- await saveResource(resource);
- }
- await channelMetadata(resource, session);
-}
-
-export async function releaseDevice(resourceId: string, endpointHash: string): Promise {
- const { session, resource } = await getSession(resourceId);
- if (!session.can('owner')) throw new Error('只有所有者可以移除设备');
- if (endpointHash === toBase64Url(session.endpointHash)) throw new Error('不能移除当前设备');
- await apiFetch(`/channels/${resourceId}/seats/${endpointHash}`, { method: 'DELETE', headers: session.authHeaders('owner') });
- await channelMetadata(resource, session);
-}
-
export async function forgetChannel(id: string): Promise {
stopChannel(id);
sessions.get(id)?.dispose();
diff --git a/web/src/core-bridge/worker/channelSend.ts b/web/src/core-bridge/worker/channelSend.ts
index dcdd4a2..d0d3b24 100644
--- a/web/src/core-bridge/worker/channelSend.ts
+++ b/web/src/core-bridge/worker/channelSend.ts
@@ -11,6 +11,7 @@ import type { FileItem, FileUploadOptions, Item } from '../types';
import { blobs, getSession } from './sessions';
import { deviceIdentity } from './presentation';
+import { accountDisplay } from './accountSession';
@@ -18,18 +19,15 @@ import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPla
import { publishWorkspace } from './presentation';
export async function sendText(resourceId: string, text: string, threadId?: string,
- event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }): Promise<{ itemId: string; uploaded: boolean }> {
+ event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }, localId?: string): Promise<{ itemId: string; uploaded: boolean }> {
if (!threadId) throw new Error('请先选择频道');
const { session, resource } = await getSession(resourceId);
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
- const identity = await deviceIdentity(session.endpointSecret);
- const device = { ...identity, name: resource.deviceLabel ?? identity.name };
- session.deviceName = device.name;
const ts = Date.now();
const author = await senderSnapshot();
const prepared = await prepareObject(resource, session, threadId, 'chat');
- const plan = session.encryptText(text, { dev: device.name, ts, threadId, itemId: prepared.itemIdBytes });
- const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}) };
+ const plan = session.encryptText(text, { ts, threadId, itemId: prepared.itemIdBytes });
+ const item: Item = { id: plan.itemId, kind: 'text', ts, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}), ...(localId ? { localId } : {}) };
blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' }));
publishWorkspace(resourceId, { t: 'item', item });
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch,
@@ -41,17 +39,16 @@ export async function sendText(resourceId: string, text: string, threadId?: stri
return { itemId: plan.itemId, uploaded };
}
-export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string): Promise {
+export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise {
if (!threadId) throw new Error('请先选择频道');
const { session, resource } = await getSession(resourceId);
const options = suppliedOptions ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 };
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
- const identity = await deviceIdentity(session.endpointSecret);
- const device = { ...identity, name: resource.deviceLabel ?? identity.name };
- session.deviceName = device.name;
+ const device = await deviceIdentity(session.endpointSecret);
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
const author = await senderSnapshot();
- for (const file of files) {
+ for (const [index, file] of files.entries()) {
+ const localId = localIds?.[index];
const ts = Date.now();
let item: FileItem | undefined;
try {
@@ -59,12 +56,12 @@ export async function sendFiles(resourceId: string, files: File[], suppliedOptio
const plan = session.encryptFile(file, {}, {
itemId: prepared.itemIdBytes,
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
- dev: device.name, ts, maxFrameData, threadId, folderId,
+ ts, maxFrameData, threadId, folderId,
});
item = {
- id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
+ id: plan.itemId, kind: 'file', ts, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
...(author ? { author } : {}),
- ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
+ ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}),
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
remaining: plan.gated
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
@@ -127,11 +124,11 @@ export async function sendFiles(resourceId: string, files: File[], suppliedOptio
export async function updateSnapshot(resourceId: string, text: string): Promise {
const { session, resource } = await getSession(resourceId);
- const name = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
+ const name = accountDisplay()?.username ?? '';
const { structure } = await import('./workspaceStructure').then(module => module.loadWorkspace(resourceId));
if (!structure.defaultChannelId) throw new Error('请先创建默认频道');
const prepared = await prepareObject(resource, session, structure.defaultChannelId, 'chat');
- const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name, itemId: prepared.itemIdBytes });
+ const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, itemId: prepared.itemIdBytes });
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'chat.snapshot' });
await flushOutbox(resource, session);
}
diff --git a/web/src/core-bridge/worker/channelThreads.ts b/web/src/core-bridge/worker/channelThreads.ts
index 17f6904..7bdf484 100644
--- a/web/src/core-bridge/worker/channelThreads.ts
+++ b/web/src/core-bridge/worker/channelThreads.ts
@@ -50,6 +50,6 @@ export async function queueFileCatalog(resource: SavedResource, session: Channel
const prepared = await prepareObject(resource, session, item.threadId, 'files');
const control: ResourceControl = { op: 'file.published', item: toPublishedFile(item) };
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: WORKSPACE_APP, v: 1, ...control })),
- { kind: 'other', threadId: item.threadId, dev: item.deviceId }, { ts: item.ts, control: true, itemId: prepared.itemIdBytes });
+ { kind: 'other', threadId: item.threadId }, { ts: item.ts, control: true, itemId: prepared.itemIdBytes });
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.catalog' });
}
diff --git a/web/src/core-bridge/worker/channelTransport.ts b/web/src/core-bridge/worker/channelTransport.ts
index 241164b..1d1c2bb 100644
--- a/web/src/core-bridge/worker/channelTransport.ts
+++ b/web/src/core-bridge/worker/channelTransport.ts
@@ -8,7 +8,6 @@ import { flushOutbox } from './transfer';
import { socketStream, sseStream, delay, type StreamConsumer } from './transports';
import { errorMessage } from '../errors';
import { saveResource } from '../database';
-import { deviceIdentity } from './presentation';
export async function connectChannel(id: string): Promise {
active.get(id)?.abort();
@@ -21,11 +20,9 @@ export async function connectChannel(id: string): Promise {
if (signal.aborted) return;
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
// Device sessions can be revoked independently of account membership; rejoin idempotently.
- await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
+ await apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
if (resource.needsJoin) {
if (session.can('owner')) await channelMetadata(resource, session);
- const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
- await apiFetch(`/channels/${id}/seats/me`, { method: 'PUT', headers: session.authHeaders('read'), body: label, signal });
resource.needsJoin = false;
await saveResource(resource);
}
@@ -35,7 +32,7 @@ export async function connectChannel(id: string): Promise {
async control(value) {
if (value.t === 'settings') {
await Promise.all([channelMetadata(resource, session), loadWorkspace(id)]);
- } else if (value.t === 'seats') await channelMetadata(resource, session);
+ } else if (value.t === 'members') await channelMetadata(resource, session);
},
online() { if (!signal.aborted) publishWorkspace(id, { t: 'connection', state: 'online' }); },
};
diff --git a/web/src/core-bridge/worker/fileCatalog.ts b/web/src/core-bridge/worker/fileCatalog.ts
index cec15d7..f1489ed 100644
--- a/web/src/core-bridge/worker/fileCatalog.ts
+++ b/web/src/core-bridge/worker/fileCatalog.ts
@@ -5,7 +5,7 @@ import type { PublishedFile } from '../protocol/workspace';
export function toPublishedFile(item: FileItem): PublishedFile {
if (!item.threadId) throw new Error('请先选择频道');
return { id: item.id, kind: 'file', threadId: item.threadId, folderId: item.folderId,
- name: item.name, mime: item.mime, size: item.size, ts: item.ts, deviceId: item.deviceId,
+ name: item.name, mime: item.mime, size: item.size, ts: item.ts,
gated: item.gated ?? false };
}
diff --git a/web/src/core-bridge/worker/http.test.ts b/web/src/core-bridge/worker/http.test.ts
index 36060b8..a38ff24 100644
--- a/web/src/core-bridge/worker/http.test.ts
+++ b/web/src/core-bridge/worker/http.test.ts
@@ -8,7 +8,7 @@ describe('API contract preflight', () => {
});
it('blocks API requests when the server has a newer breaking major', async () => {
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 }));
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '8.0.0' }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
setApiBase('http://api.test/api/v1');
@@ -19,7 +19,7 @@ describe('API contract preflight', () => {
it('checks the contract once and then sends the API request', async () => {
const fetchMock = vi.fn()
- .mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '6.0.0' }), { status: 200 }))
+ .mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 }))
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
diff --git a/web/src/core-bridge/worker/presentation.ts b/web/src/core-bridge/worker/presentation.ts
index 299f6dd..be840f8 100644
--- a/web/src/core-bridge/worker/presentation.ts
+++ b/web/src/core-bridge/worker/presentation.ts
@@ -1,7 +1,9 @@
import type { ItemEvent } from '@webdrop/core';
-import type { Device, FileItem, ShareEvent, WorkspaceEvent } from '../types';
+import type { FileItem, ShareEvent, WorkspaceEvent } from '../types';
import { previewKindOf } from '@/lib/fileKind';
import { publish } from './port';
+export type TerminalKind = 'desktop' | 'phone' | 'tablet';
+
interface ClientHints {
mobile?: boolean;
model?: string;
@@ -28,7 +30,7 @@ function version(value: string | undefined): string | undefined {
return value?.split('.').slice(0, 2).join('.') || undefined;
}
-export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: Device['kind'] }> {
+export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: TerminalKind }> {
const ua = navigator.userAgent;
const hints = await readClientHints();
const android = /Android/i.test(ua) || hints.platform === 'Android';
@@ -36,7 +38,7 @@ export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name
const ipad = /iPad/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const tablet = ipad || (android && (hints.mobile === false || (!hints.mobile && !/Mobile/i.test(ua))));
const mobile = !tablet && (android || iphone || /Mobile/i.test(ua));
- const kind: Device['kind'] = tablet ? 'tablet' : mobile ? 'phone' : 'desktop';
+ const kind: TerminalKind = tablet ? 'tablet' : mobile ? 'phone' : 'desktop';
const iosVersion = ua.match(/(?:iPhone )?OS ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
const windowsVersion = version(hints.platformVersion);
const macVersion = ua.match(/Mac OS X ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
@@ -66,24 +68,16 @@ export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name
return { name: `${identity} · ${suffix}`, kind };
}
-export function deviceInfo(id: string, joinedAt: number, label: string | undefined, selfId: string): Device {
- const name = label ?? '未知设备';
- const kind: Device['kind'] = /iPhone|iPod/i.test(name) ? 'phone'
- : /iPad|Galaxy Tab|Pixel Tablet|Android.*Tablet/i.test(name) ? 'tablet'
- : /Android|Pixel|Galaxy|OnePlus|Xiaomi|Redmi|OPPO|vivo|Huawei/i.test(name) ? 'phone'
- : 'desktop';
- return { id, name, kind, self: id === selfId, joinedAt };
-}
export function showRejected(resourceId: string, event: Extract, share = false): void {
if (share) publish(resourceId, { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' });
else if (event.itemId) publish(resourceId, {
- t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), deviceId: '未知设备', state: { kind: 'tampered' }, text: '' },
+ t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), state: { kind: 'tampered' }, text: '' },
});
else publish(resourceId, { t: 'error', message: '内容校验失败,已拒绝该记录。' });
}
export function makeFileItem(id: string, name: string, mime: string, size: number, ts: number, state: FileItem['state'], remaining: number | null = null): FileItem {
- return { id, kind: 'file', ts, deviceId: '创建者', state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
+ return { id, kind: 'file', ts, state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
}
export function publishWorkspace(resourceId: string, event: WorkspaceEvent): void {
publish(resourceId, event);
diff --git a/web/src/core-bridge/worker/resourceMetadata.test.ts b/web/src/core-bridge/worker/resourceMetadata.test.ts
index 681e0f6..3334930 100644
--- a/web/src/core-bridge/worker/resourceMetadata.test.ts
+++ b/web/src/core-bridge/worker/resourceMetadata.test.ts
@@ -13,7 +13,7 @@ describe('resource and rendering boundaries', () => {
expect(isOfflineFailure(new Error('bad_meta'))).toBe(false);
});
it('publishes only resource metadata, excluding UI state and download counters', () => {
- const item: FileItem = { id: 'a', threadId: 'channel', kind: 'file', name: 'secret.txt', mime: 'text/plain', size: 10, ts: 1, deviceId: 'device',
+ const item: FileItem = { id: 'a', threadId: 'channel', kind: 'file', name: 'secret.txt', mime: 'text/plain', size: 10, ts: 1,
gated: true, remaining: 5, preview: 'text', state: { kind: 'receiving', progress: 0.2 }, unseen: true, versions: [] };
const wire = toPublishedFile(item);
expect(wire.name).toBe('secret.txt');
@@ -21,7 +21,7 @@ describe('resource and rendering boundaries', () => {
});
it('never synchronizes draft content or server structure snapshots inside the key vault', () => {
const resource: SavedResource = { id: 'a', kind: 'share', mode: 'enc', role: 'owner', link: 'key', createdAt: 1, expiresAt: 2,
- endpointSecret: new Uint8Array(32), seats: 0, draftItems: [], manifestEntries: [],
+ endpointSecret: new Uint8Array(32), maxMembers: 0, draftItems: [], manifestEntries: [],
workspaceMetadata: { structure: { revision: 1, channels: [], groups: [], folders: [], locations: [] }, people: [] } };
const exported = exportResource(resource);
for (const field of ['draftItems', 'manifestEntries', 'workspaceMetadata', 'endpointSecret']) expect(exported).not.toHaveProperty(field);
diff --git a/web/src/core-bridge/worker/resourceMetadata.ts b/web/src/core-bridge/worker/resourceMetadata.ts
index f502e81..b327fc0 100644
--- a/web/src/core-bridge/worker/resourceMetadata.ts
+++ b/web/src/core-bridge/worker/resourceMetadata.ts
@@ -4,7 +4,7 @@ import type { SavedResource } from '../database';
/** Server-owned fields only: preserve local credentials, drafts, and user edit timestamps. */
export function channelCache(raw: ChannelInfo): Partial {
return { name: raw.settings.name ?? undefined, creatorAccount: raw.creator_account ?? undefined,
- createdAt: raw.created_at, expiresAt: raw.expires_at, seats: raw.settings.seats,
+ createdAt: raw.created_at, expiresAt: raw.expires_at, maxMembers: raw.settings.max_members,
maxDownloads: raw.settings.file_max_downloads,
ttlSeconds: raw.settings.ttl_seconds, channelMetadata: raw };
}
diff --git a/web/src/core-bridge/worker/shareDraft.ts b/web/src/core-bridge/worker/shareDraft.ts
index 9f5379b..cacfbf9 100644
--- a/web/src/core-bridge/worker/shareDraft.ts
+++ b/web/src/core-bridge/worker/shareDraft.ts
@@ -16,7 +16,7 @@ function newTextEntry(text: string): { item: TextItem; entry: Extract s.channel?.id);
- const invite = useWorkspace((s) => s.inviteUser);
- const list = useWorkspace((s) => s.listInvitations);
- const revoke = useWorkspace((s) => s.revokeInvitation);
- const [members, setMembers] = useState([]);
- const [invitations, setInvitations] = useState([]);
- const [username, setUsername] = useState('');
- const [error, setError] = useState('');
- const [busy, setBusy] = useState(false);
- async function refresh() {
- if (channelId) setMembers(await channelAccessApi.members(channelId));
- if (owner) setInvitations(await list());
- }
- useEffect(() => { void refresh().catch(() => setError('成员清单读取失败')); }, [channelId, owner, list]);
- async function submit(event: FormEvent) {
- event.preventDefault(); setBusy(true); setError('');
- try { await invite(username); setUsername(''); await refresh(); }
- catch (cause) { setError(cause instanceof Error ? cause.message : '邀请失败'); }
- finally { setBusy(false); }
- }
- async function remove(username: string) {
- setBusy(true); setError('');
- try { await revoke(username); await refresh(); }
- catch (cause) { setError(cause instanceof Error ? cause.message : '移除失败'); }
- finally { setBusy(false); }
- }
- return 空间成员
- {members.map((member) =>
- {member.username} · {member.role === 'owner' ? '空间主' : '成员'}
- {owner && member.role !== 'owner' ? : null}
-
)}
- 未登录访客以独立设备身份加入,可在设备面板移除。
- {owner ? <>邀请用户
- {invitations.map((row) => {row.username} · 已邀请
)}
- 移除会取消加入授权并断开该账号的设备。已读取的内容无法收回。
> : null}
- {error ? {error}
: null}
- ;
-}
diff --git a/web/src/features/channel/ChannelSettingsSheet.tsx b/web/src/features/channel/ChannelSettingsSheet.tsx
index 11e1c13..1915eb4 100644
--- a/web/src/features/channel/ChannelSettingsSheet.tsx
+++ b/web/src/features/channel/ChannelSettingsSheet.tsx
@@ -3,7 +3,6 @@ import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import styles from './ChannelSettingsSheet.module.css';
-import { ChannelInvitations } from './ChannelInvitations';
import { useServerSettings } from '@/store/server';
import type { ServerInfoBody } from '@/core-bridge/generated/api';
@@ -25,7 +24,7 @@ export function ChannelSettingsSheet(props: Props) {
function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) {
const owner = channel.role === 'owner';
const [name, setName] = useState(channel.name ?? '');
- const [seats, setSeats] = useState(channel.seats);
+ const [maxMembers, setMaxMembers] = useState(channel.maxMembers);
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!);
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!);
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!);
@@ -43,7 +42,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
setSaving(true);
setFailed(false);
try {
- await onSave({ name: name.trim(), seats, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
+ await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
onClose();
} catch {
@@ -58,13 +57,13 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
diff --git a/web/src/features/channel/ChannelSwitcher.tsx b/web/src/features/channel/ChannelSwitcher.tsx
deleted file mode 100644
index 6c6e09e..0000000
--- a/web/src/features/channel/ChannelSwitcher.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useChannels } from '@/store/channels';
-import { useAccount } from '@/store/account';
-import { channelPath, navigate } from '@/app/navigation';
-import { Button } from '@/ui/Button';
-import { Icon } from '@/ui/Icon';
-import styles from './ThreadSidebar.module.css';
-
-export function ChannelSwitcher({ id, onCreate }: { id?: string; onCreate: () => void }) {
- const channels = useChannels((s) => s.list);
- const username = useAccount((s) => s.username);
- return
- 空间
-
- ;
-}
diff --git a/web/src/features/channel/ChatMessage.tsx b/web/src/features/channel/ChatMessage.tsx
index 78ac3df..6d4d390 100644
--- a/web/src/features/channel/ChatMessage.tsx
+++ b/web/src/features/channel/ChatMessage.tsx
@@ -1,50 +1,56 @@
import { useState } from 'react';
-import { useWorkspace } from '@/store/workspace';
+import { isLocalId, useWorkspace } from '@/store/workspace';
import { useAccount } from '@/store/account';
import { useDownload } from '@/features/item/download';
import { iconOf } from '@/features/item/summary';
+import { senderLabel } from '@/lib/sender';
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import styles from './ChatPane.module.css';
-export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) {
+interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
+
+export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props) {
const item = useWorkspace((s) => s.byId[id]);
const owner = useWorkspace((s) => s.channel?.role === 'owner');
- const ownDevice = useWorkspace((s) => s.devices.find((device) => device.self)?.name);
+ const retry = useWorkspace((s) => s.retrySend);
const username = useAccount((s) => s.username);
const download = useDownload();
const [downloading, setDownloading] = useState(false);
if (!item) return null;
const sender = item.author;
- const name = sender?.displayName || sender?.username || item.deviceId || '未知用户';
- const self = sender?.username !== undefined ? sender.username === username : Boolean(ownDevice && item.deviceId === ownDevice);
+ const name = senderLabel(item);
+ const self = sender?.username !== undefined && sender.username === username;
+ const local = isLocalId(id);
+ const sending = local && item.state.kind === 'receiving';
+ const failed = local && item.state.kind === 'incomplete';
const ready = item.state.kind === 'complete';
+ const deleteLabel = item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息';
async function save() {
if (!item || downloading) return;
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
}
- return
- {compact ? : sender?.avatar ?
- : {Array.from(name)[0] ?? '?'}}
-
- {!compact ?
{name}
- {owner || self ? : null}
- {compact && (owner || self) ?