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
+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];
|
||||
|
||||
Reference in new issue
Block a user