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
This commit is contained in:
1 parent
5fd4ddd411
commit
4b8d0b5a5f
83 files changed
+1291
-661
No files matched your search
@@ -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
@@ -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];
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) })),
|
||||
}));
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user