From 4b8d0b5a5fa2b20ce4f83e9f20c008d2a1070291 Mon Sep 17 00:00:00 2001 From: JustMe Date: Wed, 14 Oct 2026 22:19:25 +0800 Subject: [PATCH] feat(web): discord-style chat, members pane, invitations and optimistic send [CI] - chat: left-aligned discord-style messages with grouped senders, hover actions, an auto-growing composer and file cards - send: render messages and files locally first and replace the placeholder when the worker publishes the real item; failures keep the text with a retry action - navigation: space rail plus a flatter channel list with the space name header and collapsible groups - members: right pane tabs (files / members) grouped by online, offline and pending users, with a prominent invite action and a top bar shortcut - notifications: toast host for invite results; invitees get a toast with an accept action, and the invitation payload now carries the space name and inviter inside the encrypted grant - users over devices: drop device names, the device list and device rename/release; senders show the account (guests show as guest) and the share panel only keeps links - follow contract 7.0.0 (max_members, members_used, members_full, /join) - docs: ui-design and frontend updated for the new layout and concepts --- README.md | 2 +- docs/frontend.md | 6 +- docs/ui-design.md | 24 +- web/src/app/App.tsx | 4 + web/src/app/ToastHost.module.css | 76 ++++++ web/src/app/ToastHost.tsx | 19 ++ web/src/app/useInvitationNotices.ts | 48 ++++ web/src/core-bridge/account.ts | 2 +- web/src/core-bridge/api.ts | 8 +- web/src/core-bridge/contract.test.ts | 7 +- web/src/core-bridge/contract.ts | 2 +- web/src/core-bridge/database.ts | 6 +- web/src/core-bridge/errors.ts | 2 +- web/src/core-bridge/protocol/account.ts | 2 +- web/src/core-bridge/protocol/workspace.ts | 1 - web/src/core-bridge/real.ts | 6 +- web/src/core-bridge/settings.ts | 2 +- web/src/core-bridge/types.ts | 29 +-- web/src/core-bridge/worker.ts | 8 +- web/src/core-bridge/worker/account.ts | 14 +- web/src/core-bridge/worker/accountChannels.ts | 2 +- .../core-bridge/worker/accountVault.test.ts | 6 +- web/src/core-bridge/worker/accountVault.ts | 2 +- web/src/core-bridge/worker/channelEvents.ts | 30 +-- web/src/core-bridge/worker/channelGrant.ts | 18 +- web/src/core-bridge/worker/channelItems.ts | 4 +- .../core-bridge/worker/channelResources.ts | 62 +---- web/src/core-bridge/worker/channelSend.ts | 29 +-- web/src/core-bridge/worker/channelThreads.ts | 2 +- .../core-bridge/worker/channelTransport.ts | 7 +- web/src/core-bridge/worker/fileCatalog.ts | 2 +- web/src/core-bridge/worker/http.test.ts | 4 +- web/src/core-bridge/worker/presentation.ts | 20 +- .../worker/resourceMetadata.test.ts | 4 +- .../core-bridge/worker/resourceMetadata.ts | 2 +- web/src/core-bridge/worker/shareDraft.ts | 2 +- web/src/core-bridge/worker/shareRead.ts | 2 +- web/src/core-bridge/worker/shareResources.ts | 4 +- web/src/core-bridge/worker/wire.ts | 4 +- .../core-bridge/worker/workspaceRecords.ts | 2 +- .../features/channel/ChannelInvitations.tsx | 49 ---- .../features/channel/ChannelSettingsSheet.tsx | 13 +- web/src/features/channel/ChannelSwitcher.tsx | 17 -- web/src/features/channel/ChatMessage.tsx | 48 ++-- web/src/features/channel/ChatPane.module.css | 232 +++++++++++------- web/src/features/channel/ChatPane.tsx | 43 ++-- web/src/features/channel/DevicePanel.tsx | 88 ------- web/src/features/channel/ExplorerHover.tsx | 3 +- web/src/features/channel/InviteSheet.tsx | 44 ++++ web/src/features/channel/LatestCard.tsx | 6 +- .../features/channel/MembersPane.module.css | 177 +++++++++++++ web/src/features/channel/MembersPane.tsx | 92 +++++++ web/src/features/channel/Notepad.module.css | 5 +- web/src/features/channel/Notepad.tsx | 2 - web/src/features/channel/PaneTabs.module.css | 43 ++++ web/src/features/channel/PaneTabs.tsx | 11 + ...Panel.module.css => SharePanel.module.css} | 0 web/src/features/channel/SharePanel.tsx | 54 ++++ web/src/features/channel/SideRail.module.css | 48 ---- web/src/features/channel/SideRail.tsx | 38 --- web/src/features/channel/SpaceRail.module.css | 108 ++++++++ web/src/features/channel/SpaceRail.tsx | 36 +++ .../features/channel/ThreadSidebar.module.css | 54 ++-- web/src/features/channel/TopBar.tsx | 15 +- web/src/features/channel/Workspace.module.css | 13 +- web/src/features/channel/Workspace.tsx | 29 ++- web/src/features/home/AccountDevices.tsx | 2 +- web/src/features/home/AccountDock.module.css | 4 +- web/src/features/home/ChannelList.tsx | 2 +- web/src/features/home/CreateSheet.tsx | 6 +- web/src/features/home/InvitationInbox.tsx | 2 +- web/src/features/item/CompactRow.tsx | 6 +- web/src/features/item/HistoryList.tsx | 10 +- web/src/features/item/ItemDetail.tsx | 10 +- web/src/features/item/source.ts | 14 +- web/src/features/item/summary.ts | 4 - web/src/features/scan/JoinPreview.tsx | 2 +- web/src/lib/sender.ts | 8 + web/src/store/reduce.test.ts | 9 +- web/src/store/reduce.ts | 19 +- web/src/store/shareReduce.test.ts | 2 +- web/src/store/toast.ts | 29 +++ web/src/store/workspace.ts | 79 +++++- 83 files changed, 1291 insertions(+), 661 deletions(-) create mode 100644 web/src/app/ToastHost.module.css create mode 100644 web/src/app/ToastHost.tsx create mode 100644 web/src/app/useInvitationNotices.ts delete mode 100644 web/src/features/channel/ChannelInvitations.tsx delete mode 100644 web/src/features/channel/ChannelSwitcher.tsx delete mode 100644 web/src/features/channel/DevicePanel.tsx create mode 100644 web/src/features/channel/InviteSheet.tsx create mode 100644 web/src/features/channel/MembersPane.module.css create mode 100644 web/src/features/channel/MembersPane.tsx create mode 100644 web/src/features/channel/PaneTabs.module.css create mode 100644 web/src/features/channel/PaneTabs.tsx rename web/src/features/channel/{DevicePanel.module.css => SharePanel.module.css} (100%) create mode 100644 web/src/features/channel/SharePanel.tsx delete mode 100644 web/src/features/channel/SideRail.module.css delete mode 100644 web/src/features/channel/SideRail.tsx create mode 100644 web/src/features/channel/SpaceRail.module.css create mode 100644 web/src/features/channel/SpaceRail.tsx create mode 100644 web/src/lib/sender.ts create mode 100644 web/src/store/toast.ts 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 ? <>邀请用户
{ void submit(event); }}> - setUsername(event.target.value)} placeholder="已注册用户的用户名" required /> - -
- {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
{owner && channel.passwordRequired ? : null} - +

成员与邀请请在右侧栏的「成员」页管理。

{owner ? ( <> {failed ?

保存失败,请检查名称和设置后重试。

: null} - + ) :

只有所有者可以修改空间设置。

}
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 ?
; } diff --git a/web/src/features/channel/ChatPane.module.css b/web/src/features/channel/ChatPane.module.css index 91c85ac..1731281 100644 --- a/web/src/features/channel/ChatPane.module.css +++ b/web/src/features/channel/ChatPane.module.css @@ -3,23 +3,28 @@ flex: 1; flex-direction: column; min-height: 0; - border-top: 1px solid var(--line); background: var(--surface); } .head { display: flex; align-items: center; - justify-content: space-between; + gap: var(--space-2); min-height: var(--bar-h); - padding: 0 var(--space-3); + padding: 0 var(--space-4); border-bottom: 1px solid var(--line); - font-size: 12px; + font-size: 15px; +} + +.hash { + color: var(--text-dim); + font-size: 20px; + font-weight: 400; } -.head span, .empty, -.message time { +.message time, +.delivery { color: var(--text-dim); font-size: 11px; } @@ -27,59 +32,72 @@ .messages { flex: 1; min-height: 0; - padding: var(--space-3) var(--space-4); + padding: var(--space-4) 0 var(--space-2); overflow-y: auto; overscroll-behavior: contain; } .empty { - margin: var(--space-2) 0; + margin: 0; + padding: 0 var(--space-4); + font-size: 13px; } +/* Discord 式:所有消息同一条左边线,头像 + 名称/时间 + 正文占满宽度 */ .message { - display: flex; - align-items: flex-start; - gap: var(--space-3); - width: 100%; - margin-bottom: var(--space-3); + position: relative; + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + column-gap: var(--space-3); + padding: var(--space-1) var(--space-4); + margin-top: var(--space-3); } -.mine { - flex-direction: row-reverse; +.message:hover, +.message:focus-within { + background: var(--accent-soft); } .compact { - margin-top: calc(var(--space-2) * -1); - margin-bottom: var(--space-1); + margin-top: 0; +} + +.sending { + opacity: 0.6; } .avatar { width: 40px; height: 40px; - flex-shrink: 0; + margin-top: 2px; border-radius: var(--radius-avatar); object-fit: cover; } -.avatarSpacer { - width: 40px; - height: 1px; - flex: 0 0 40px; -} - .avatarFallback { display: grid; place-items: center; background: var(--surface-2); color: var(--text-dim); - font-size: 14px; + font-size: 15px; + font-weight: 600; } -.bubble { - position: relative; +/* 连发合并行:头像位置平时留空,悬停才显示时间 */ +.gutterTime { + align-self: start; + padding-top: 3px; + text-align: center; + opacity: 0; +} + +.message:hover .gutterTime, +.message:focus-within .gutterTime { + opacity: 1; +} + +.body { min-width: 0; - max-width: min(82%, 760px); - padding: 0 0 var(--space-1); } .message header { @@ -93,22 +111,28 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--accent); - font-size: 13px; + font-size: 14px; } -.message header time { - margin-inline-end: auto; +.messageText { + margin: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; + font-size: 14px; + line-height: 1.5; } .deleteAction { - width: 26px; - min-width: 26px; - height: 26px; - min-height: 26px; + position: absolute; + inset-block-start: 0; + inset-inline-end: var(--space-4); + width: 28px; + min-width: 28px; + height: 28px; + min-height: 28px; padding: 0; + background: var(--surface); opacity: 0; - transition: opacity 0.12s ease-out; } .message:hover .deleteAction, @@ -116,24 +140,11 @@ opacity: 1; } -.compactDelete { - position: absolute; - inset-block-start: 0; - inset-inline-end: calc(var(--space-2) * -1); -} - -.messageText { - margin: var(--space-1) 0 0; - overflow-wrap: anywhere; - white-space: pre-wrap; - font-size: 14px; -} - .dateDivider { display: flex; align-items: center; gap: var(--space-3); - margin: var(--space-4) 0 var(--space-3); + margin: var(--space-4) var(--space-4) var(--space-2); color: var(--text-dim); font-size: 11px; font-weight: 600; @@ -147,17 +158,26 @@ content: ''; } -.fileMessage { - width: 280px; - max-width: 100%; -} - .fileCard { display: flex; align-items: center; + gap: var(--space-1); + width: 360px; + max-width: 100%; + margin-top: var(--space-1); + padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3); + border: 1px solid var(--line-strong); + border-radius: var(--radius-md); + background: var(--surface-2); +} + +.fileOpen { + display: flex; + flex: 1; + align-items: center; gap: var(--space-3); - width: 100%; - padding: var(--space-3) 0; + min-width: 0; + padding: 0; border: 0; background: transparent; color: var(--text); @@ -165,6 +185,10 @@ cursor: pointer; } +.fileOpen:disabled { + cursor: default; +} + .fileIcon { flex-shrink: 0; } @@ -181,23 +205,19 @@ font-size: 13px; } -.fileInfo > span, -.delivery { +.fileInfo > span { color: var(--text-dim); overflow-wrap: anywhere; font-size: 11px; } -.fileActions { - display: flex; - gap: var(--space-2); - border-top: 1px solid var(--line); -} - .delivery { display: flex; flex-direction: column; gap: var(--space-1); + width: 360px; + max-width: 100%; + margin-top: var(--space-1); } .delivery progress { @@ -206,7 +226,23 @@ accent-color: var(--busy); } +.failure { + color: var(--danger); + font-size: 12px; +} + +.failure button { + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-decoration: underline; + cursor: pointer; +} + .integrity { + margin: 0; color: var(--danger); font-size: 12px; } @@ -214,36 +250,56 @@ .composer { display: flex; align-items: flex-end; - gap: var(--space-2); - padding: var(--space-2); - border-top: 1px solid var(--line); + gap: var(--space-1); + margin: var(--space-2) var(--space-4) var(--space-4); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-lg); + background: var(--surface-2); +} + +.composer:focus-within { + outline: 2px solid var(--line-strong); } .composer textarea { flex: 1; min-width: 0; - min-height: 42px; - max-height: 112px; - padding: var(--space-2); - resize: vertical; - border: 1px solid var(--line-strong); - border-radius: var(--radius-sm); - background: var(--bg); + min-height: 36px; + max-height: 160px; + padding: var(--space-2) var(--space-1); + resize: none; + border: 0; + outline: 0; + background: transparent; color: var(--text); font: inherit; - font-size: 13px; -} - -.error { - margin: 0; - padding: 0 var(--space-3) var(--space-2); - color: var(--danger); - font-size: 12px; + font-size: 14px; + line-height: 20px; } @media (width <= 899px) { - .pane { - flex: 1; - min-height: 0; + .message { + grid-template-columns: 32px minmax(0, 1fr); + column-gap: var(--space-2); + padding-inline: var(--space-3); + } + + .avatar { + width: 32px; + height: 32px; + } + + .deleteAction { + inset-inline-end: var(--space-2); + } + + .composer { + margin: var(--space-2); + } +} + +@media (hover: none) { + .deleteAction { + opacity: 1; } } diff --git a/web/src/features/channel/ChatPane.tsx b/web/src/features/channel/ChatPane.tsx index c1bd2d3..5412e50 100644 --- a/web/src/features/channel/ChatPane.tsx +++ b/web/src/features/channel/ChatPane.tsx @@ -8,36 +8,43 @@ import styles from './ChatPane.module.css'; interface Props { threadName: string; ids: string[]; enabled: boolean; onSend: (text: string) => Promise; onDelete: (id: string) => void; onOpenFile: (id: string) => void; onFiles?: (files: File[]) => void } +const MAX_INPUT_HEIGHT = 160; + export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFile, onFiles }: Props) { const [text, setText] = useState(''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(''); const messages = useRef(null); + const input = useRef(null); const fileInput = useRef(null); const byId = useWorkspace((s) => s.byId); const chronological = ids.slice().reverse(); useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]); - async function submit() { + function fit() { + const el = input.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_HEIGHT)}px`; + } + + // 乐观发送:不等后端,先清空输入框,消息立刻画出来;失败由消息行自己显示重试 + function submit() { const value = text.trim(); - if (!value || busy || !enabled) return; - setBusy(true); - setError(''); - try { await onSend(value); setText(''); } - catch (cause) { setError(cause instanceof Error ? cause.message : '发送失败'); } - finally { setBusy(false); } + if (!value || !enabled) return; + setText(''); + if (input.current) input.current.style.height = 'auto'; + void onSend(value); } function keyDown(event: KeyboardEvent) { - if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void submit(); } + if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); submit(); } } return (
-
{threadName}频道聊天
+
{threadName}
- {chronological.length === 0 ?

在这里发送消息或文件,成员可以看到你的名称和头像。

: null} + {chronological.length === 0 ?

这里是 #{threadName} 的开头。发送消息或文件,成员可以看到你的名称和头像。

: null} {(() => { let previous: Item | undefined; return chronological.map((id) => { @@ -46,7 +53,7 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil const dateKey = new Date(item.ts).toDateString(); const previousDate = previous ? new Date(previous.ts).toDateString() : undefined; const showDate = dateKey !== previousDate; - const sameSender = previous && (previous.author?.username ?? previous.deviceId) === (item.author?.username ?? item.deviceId); + const sameSender = previous && previous.author?.username === item.author?.username; const grouped = Boolean(sameSender && !showDate && item.ts - previous!.ts < 5 * 60_000); previous = item; return @@ -56,13 +63,13 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil }); })()}
-
{ event.preventDefault(); void submit(); }}> + { event.preventDefault(); submit(); }}> {onFiles ? <> { if (event.target.files?.length) onFiles([...event.target.files]); event.target.value = ''; }} /> -