feat(web): discord-style chat, members pane, invitations and optimistic send [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

- 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
This commit is contained in:
justme committed 2026-10-14 22:19:25 +08:00
1 parent 5fd4ddd411
commit 4b8d0b5a5f
83 files changed
+1291 -661

No files matched your search

+13 -6
View File
@@ -2,7 +2,6 @@ import type {
ChannelInfo,
ChannelGroupInfo,
ConnectionState,
Device,
FolderInfo,
Item,
SenderInfo,
@@ -15,13 +14,14 @@ export interface WorkspaceData {
ready: boolean;
connection: ConnectionState;
channel: ChannelInfo | null;
devices: Device[];
threads: ThreadInfo[];
groups: ChannelGroupInfo[];
folders: FolderInfo[];
locations: Record<string, { threadId: string; folderId?: string; authorAccount?: string }>;
byId: Record<string, Item>;
authors: Record<string, SenderInfo>;
/** 空间成员的资料(昵称、头像),按用户名索引 */
people: Record<string, SenderInfo>;
/** 新的在前 */
order: string[];
snapshot: Snapshot;
@@ -32,13 +32,13 @@ export const initialData: WorkspaceData = {
ready: false,
connection: 'offline',
channel: null,
devices: [],
threads: [],
groups: [],
folders: [],
locations: {},
byId: {},
authors: {},
people: {},
order: [],
snapshot: { text: '', updatedBy: '', updatedAt: 0 },
error: null,
@@ -51,6 +51,12 @@ function insertByTime(order: string[], byId: Record<string, Item>, item: Item):
return next;
}
export function withoutItem(s: WorkspaceData, id: string): WorkspaceData {
if (!(id in s.byId)) return s;
const byId = { ...s.byId }; delete byId[id];
return { ...s, byId, order: s.order.filter((entry) => entry !== id) };
}
export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
switch (e.t) {
case 'structure': {
@@ -64,7 +70,8 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
const byId = Object.fromEntries(Object.entries(s.byId).map(([id, item]) => [id, {
...item, ...(locations[id] ?? {}), ...(authors[id] ? { author: authors[id] } : {}),
}]));
return { ...s, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups,
const profiles = Object.fromEntries(e.people.map(person => [person.username, { username: person.username, displayName: person.displayName || person.username, ...(person.avatar ? { avatar: person.avatar } : {}) }]));
return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups,
folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId };
}
case 'reset':
@@ -77,12 +84,12 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
return { ...s, connection: e.state };
case 'channel':
return { ...s, channel: e.info };
case 'devices':
return { ...s, devices: e.devices };
case 'snapshot':
return { ...s, snapshot: e.snapshot };
case 'file-catalog':
case 'item': {
// 乐观渲染:真实条目到达时,同一次更新里替换本地占位项,列表不闪、不重复
if (e.item.localId && e.item.localId !== e.item.id) s = withoutItem(s, e.item.localId);
const known = e.item.id in s.byId;
let item = e.item;
const current = s.byId[item.id];