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

+8 -1
View File
@@ -3,7 +3,7 @@ import type { TextItem } from '@/core-bridge/types';
import { initialData, reduce } from './reduce';
const text = (id: string, ts: number, body = id): TextItem => ({
id, kind: 'text', ts, deviceId: 'self', state: { kind: 'complete' }, text: body,
id, kind: 'text', ts, state: { kind: 'complete' }, text: body,
});
describe('reduce', () => {
@@ -23,6 +23,13 @@ describe('reduce', () => {
state = reduce(state, ungrouped); expect(state.threads[0]?.groupId).toBeUndefined();
state = reduce(state, { t: 'reset' }); expect(state.groups).toEqual([]);
});
it('乐观发送:真实条目带 localId 到达时替换占位项,不重复', () => {
let s = reduce(initialData, { t: 'item', item: text('a', 1) });
s = { ...s, byId: { ...s.byId, 'local:1': { ...text('local:1', 2), localId: 'local:1', state: { kind: 'receiving', progress: 0 } } }, order: ['local:1', ...s.order] };
s = reduce(s, { t: 'item', item: { ...text('real', 2), localId: 'local:1' } });
expect(s.order).toEqual(['real', 'a']);
expect(s.byId['local:1']).toBeUndefined();
});
it('新条目按时间倒序,新的在前', () => {
let s = reduce(initialData, { t: 'item', item: text('a', 1) });
s = reduce(s, { t: 'item', item: text('c', 3) });
+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];
+1 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { Share, TextItem } from '@/core-bridge/types';
import { initialShareData, reduceShare } from './shareReduce';
const text = (id: string, ts: number): TextItem => ({ id, kind: 'text', ts, deviceId: 'x', state: { kind: 'complete' }, text: id });
const text = (id: string, ts: number): TextItem => ({ id, kind: 'text', ts, state: { kind: 'complete' }, text: id });
const share: Share = { id: 's', state: 'draft', mine: true, link: 'l', createdAt: 0, expiresAt: 86400000, ttlSeconds: 1, maxVisitors: 2, maxDownloads: null };
describe('reduceShare', () => {
+29
View File
@@ -0,0 +1,29 @@
import { create } from 'zustand';
export interface Toast {
id: number;
kind: 'ok' | 'error' | 'info';
text: string;
action?: { label: string; run: () => void };
}
interface ToastState {
toasts: Toast[];
push: (toast: Omit<Toast, 'id'>) => void;
dismiss: (id: number) => void;
}
let next = 1;
const SHOW_MS = 4000;
/** 带操作按钮的通知停留更久,给用户时间点 */
const SHOW_WITH_ACTION_MS = 10000;
export const useToast = create<ToastState>()((set, get) => ({
toasts: [],
push: (toast) => {
const id = next++;
set((s) => ({ toasts: [...s.toasts.slice(-3), { ...toast, id }] }));
window.setTimeout(() => get().dismiss(id), toast.action ? SHOW_WITH_ACTION_MS : SHOW_MS);
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((toast) => toast.id !== id) })),
}));
+71 -8
View File
@@ -1,7 +1,56 @@
import { create } from 'zustand';
import { api } from '@/core-bridge/instance';
import type { ChannelSettingsPatch, FileUploadOptions } from '@/core-bridge/types';
import { initialData, reduce, type WorkspaceData } from './reduce';
import type { FileItem, Item } from '@/core-bridge/types';
import { previewKindOf } from '@/lib/fileKind';
import { errorMessage } from '@/core-bridge/errors';
import { useAccount } from './account';
import { initialData, reduce, withoutItem, type WorkspaceData } from './reduce';
/** 乐观发送:点发送即在本地画出占位项,后台再加密上传;worker 发布真实条目时带回 localId 替换占位项 */
type PendingJob =
| { kind: 'text'; text: string; threadId?: string }
| { kind: 'file'; file: File; options?: FileUploadOptions; threadId?: string; folderId?: string };
const pendingJobs = new Map<string, PendingJob>();
export const isLocalId = (id: string): boolean => pendingJobs.has(id);
const newLocalId = (): string => `local:${crypto.randomUUID()}`;
function localBase(threadId?: string, folderId?: string) {
const { username, personal } = useAccount.getState();
return {
ts: Date.now(),
state: { kind: 'receiving' as const, progress: 0 },
...(username ? { author: { username, displayName: personal?.displayName || username, ...(personal?.avatar ? { avatar: personal.avatar } : {}) } } : {}),
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
};
}
function putLocal(item: Item): void {
useWorkspace.setState((s) => ({ byId: { ...s.byId, [item.id]: item }, order: [item.id, ...s.order] }));
}
function failLocal(id: string, cause: unknown): void {
useWorkspace.setState((s) => {
const item = s.byId[id];
return item ? { byId: { ...s.byId, [id]: { ...item, state: { kind: 'incomplete' } } }, error: errorMessage(cause) } : { error: errorMessage(cause) };
});
}
function settleLocal(id: string): void {
pendingJobs.delete(id);
useWorkspace.setState((s) => withoutItem(s, id));
}
async function deliver(id: string): Promise<void> {
const job = pendingJobs.get(id);
if (!job) return;
useWorkspace.setState((s) => s.byId[id] ? { byId: { ...s.byId, [id]: { ...s.byId[id], state: { kind: 'receiving', progress: 0 } } } } : s);
try {
if (job.kind === 'text') await api.sendText(job.text, job.threadId, id);
else await api.sendFiles([job.file], job.options, job.threadId, job.folderId, [id]);
settleLocal(id);
} catch (cause) { failLocal(id, cause); }
}
interface Actions {
inviteUser: typeof api.inviteUser;
@@ -10,9 +59,9 @@ interface Actions {
/** 打开(订阅)一个空间:先清空上一个空间的数据,返回取消订阅函数 */
open: (channelId: string) => () => void;
sendText: (text: string, threadId?: string) => Promise<void>;
/** 重试发送失败的本地占位项 */
retrySend: (localId: string) => Promise<void>;
updateChannelSettings: (patch: ChannelSettingsPatch) => Promise<void>;
renameDevice: (endpointHash: string, name: string) => Promise<void>;
releaseDevice: (endpointHash: string) => Promise<void>;
sendFiles: (files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string) => Promise<void>;
createThread: (name: string, groupId?: string) => Promise<string>;
createGroup: typeof api.createGroup;
@@ -41,11 +90,25 @@ export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
useWorkspace.setState(initialData);
return api.connect(channelId, (e) => useWorkspace.setState((s) => reduce(s, e)));
},
sendText: (t, threadId) => api.sendText(t, threadId),
sendText: (text, threadId) => {
const id = newLocalId();
pendingJobs.set(id, { kind: 'text', text, ...(threadId ? { threadId } : {}) });
putLocal({ id, kind: 'text', text, localId: id, ...localBase(threadId) });
return deliver(id);
},
retrySend: (id) => deliver(id),
updateChannelSettings: (patch) => api.updateChannelSettings(patch),
renameDevice: (endpointHash, name) => api.renameDevice(endpointHash, name),
releaseDevice: (endpointHash) => api.releaseDevice(endpointHash),
sendFiles: (f, options, threadId, folderId) => api.sendFiles(f, options, threadId, folderId),
sendFiles: async (files, options, threadId, folderId) => {
const ids = files.map((file) => {
const id = newLocalId();
pendingJobs.set(id, { kind: 'file', file, ...(options ? { options } : {}), ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}) });
const item: FileItem = { id, kind: 'file', localId: id, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, remaining: null,
preview: previewKindOf(file.type, file.name), ...localBase(threadId, folderId), state: { kind: 'receiving', progress: 0, phase: 'encrypting' } };
putLocal(item);
return id;
});
for (const id of ids) await deliver(id);
},
createThread: (name, groupId) => api.createThread(name, groupId),
createGroup: (name) => api.createGroup(name),
renameGroup: (id, name) => api.renameGroup(id, name),
@@ -57,7 +120,7 @@ export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
moveItems: (itemIds, threadId, folderId) => api.moveItems(itemIds, threadId, folderId),
editText: (id, t): Promise<void> => api.editText(id, t, useWorkspace.getState().byId[id]?.threadId),
resolveConflict: (id, c) => api.resolveConflict(id, c),
remove: (id) => api.remove(id),
remove: (id) => { if (!pendingJobs.has(id)) return api.remove(id); settleLocal(id); return Promise.resolve(); },
setSnapshot: (t) => api.setSnapshot(t),
getBlob: (id, v) => api.getBlob(id, v),
markSeen: (id) => api.markSeen(id),