Files
WebDrop/web/src/store/reduce.ts
T
justme 4b8d0b5a5f
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
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
2026-10-14 22:19:25 +08:00

117 lines
4.6 KiB
TypeScript

import type {
ChannelInfo,
ChannelGroupInfo,
ConnectionState,
FolderInfo,
Item,
SenderInfo,
Snapshot,
ThreadInfo,
WorkspaceEvent,
} from '@/core-bridge/types';
export interface WorkspaceData {
ready: boolean;
connection: ConnectionState;
channel: ChannelInfo | null;
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;
error: string | null;
}
export const initialData: WorkspaceData = {
ready: false,
connection: 'offline',
channel: null,
threads: [],
groups: [],
folders: [],
locations: {},
byId: {},
authors: {},
people: {},
order: [],
snapshot: { text: '', updatedBy: '', updatedAt: 0 },
error: null,
};
function insertByTime(order: string[], byId: Record<string, Item>, item: Item): string[] {
const at = order.findIndex((id) => (byId[id]?.ts ?? 0) < item.ts);
const next = order.slice();
next.splice(at === -1 ? next.length : at, 0, item.id);
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': {
const people = new Map(e.people.map(person => [person.username, person]));
const locations = Object.fromEntries(e.state.locations.map(location => [location.itemId, { threadId: location.threadId, folderId: location.folderId ?? undefined, authorAccount: location.authorAccount ?? undefined }]));
const authors = { ...s.authors };
for (const location of e.state.locations) {
const person = location.authorAccount ? people.get(location.authorAccount) : undefined;
if (person) authors[location.itemId] = { username: person.username, displayName: person.displayName || person.username, avatar: person.avatar ?? undefined };
}
const byId = Object.fromEntries(Object.entries(s.byId).map(([id, item]) => [id, {
...item, ...(locations[id] ?? {}), ...(authors[id] ? { author: authors[id] } : {}),
}]));
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':
return { ...initialData, connection: s.connection };
case 'error':
return { ...s, error: e.message };
case 'ready':
return { ...s, ready: true };
case 'connection':
return { ...s, connection: e.state };
case 'channel':
return { ...s, channel: e.info };
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];
const author = s.authors[item.id] ?? item.author ?? current?.author;
if (author) item = { ...item, author };
if (e.t === 'item' && item.kind === 'file' && item.metadataHidden && current?.kind === 'file' && !current.metadataHidden) return s;
const location = s.locations[item.id];
if (location && !(item.kind === 'file' && item.metadataHidden)) item = { ...item, threadId: location.threadId, folderId: location.folderId };
const byId = { ...s.byId, [item.id]: item };
return { ...s, byId, ...(author ? { authors: { ...s.authors, [item.id]: author } } : {}), order: known ? s.order : insertByTime(s.order, byId, item) };
}
case 'removed': {
if (!(e.id in s.byId)) return s;
const byId = { ...s.byId };
delete byId[e.id];
const authors = { ...s.authors }; delete authors[e.id];
return { ...s, byId, authors, order: s.order.filter((id) => id !== e.id) };
}
default: {
const exhaustive: never = e;
return exhaustive;
}
}
}