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 -16
View File
@@ -11,6 +11,7 @@ import type { FileItem, FileUploadOptions, Item } from '../types';
import { blobs, getSession } from './sessions';
import { deviceIdentity } from './presentation';
import { accountDisplay } from './accountSession';
@@ -18,18 +19,15 @@ import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPla
import { publishWorkspace } from './presentation';
export async function sendText(resourceId: string, text: string, threadId?: string,
event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }): Promise<{ itemId: string; uploaded: boolean }> {
event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }, localId?: string): Promise<{ itemId: string; uploaded: boolean }> {
if (!threadId) throw new Error('请先选择频道');
const { session, resource } = await getSession(resourceId);
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
const identity = await deviceIdentity(session.endpointSecret);
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
session.deviceName = device.name;
const ts = Date.now();
const author = await senderSnapshot();
const prepared = await prepareObject(resource, session, threadId, 'chat');
const plan = session.encryptText(text, { dev: device.name, ts, threadId, itemId: prepared.itemIdBytes });
const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}) };
const plan = session.encryptText(text, { ts, threadId, itemId: prepared.itemIdBytes });
const item: Item = { id: plan.itemId, kind: 'text', ts, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}), ...(localId ? { localId } : {}) };
blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' }));
publishWorkspace(resourceId, { t: 'item', item });
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch,
@@ -41,17 +39,16 @@ export async function sendText(resourceId: string, text: string, threadId?: stri
return { itemId: plan.itemId, uploaded };
}
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string): Promise<void> {
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise<void> {
if (!threadId) throw new Error('请先选择频道');
const { session, resource } = await getSession(resourceId);
const options = suppliedOptions ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 };
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
const identity = await deviceIdentity(session.endpointSecret);
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
session.deviceName = device.name;
const device = await deviceIdentity(session.endpointSecret);
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
const author = await senderSnapshot();
for (const file of files) {
for (const [index, file] of files.entries()) {
const localId = localIds?.[index];
const ts = Date.now();
let item: FileItem | undefined;
try {
@@ -59,12 +56,12 @@ export async function sendFiles(resourceId: string, files: File[], suppliedOptio
const plan = session.encryptFile(file, {}, {
itemId: prepared.itemIdBytes,
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
dev: device.name, ts, maxFrameData, threadId, folderId,
ts, maxFrameData, threadId, folderId,
});
item = {
id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
id: plan.itemId, kind: 'file', ts, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
...(author ? { author } : {}),
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}),
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
remaining: plan.gated
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
@@ -127,11 +124,11 @@ export async function sendFiles(resourceId: string, files: File[], suppliedOptio
export async function updateSnapshot(resourceId: string, text: string): Promise<void> {
const { session, resource } = await getSession(resourceId);
const name = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
const name = accountDisplay()?.username ?? '';
const { structure } = await import('./workspaceStructure').then(module => module.loadWorkspace(resourceId));
if (!structure.defaultChannelId) throw new Error('请先创建默认频道');
const prepared = await prepareObject(resource, session, structure.defaultChannelId, 'chat');
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name, itemId: prepared.itemIdBytes });
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, itemId: prepared.itemIdBytes });
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'chat.snapshot' });
await flushOutbox(resource, session);
}