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

+7 -13
View File
@@ -1,7 +1,9 @@
import type { ItemEvent } from '@webdrop/core';
import type { Device, FileItem, ShareEvent, WorkspaceEvent } from '../types';
import type { FileItem, ShareEvent, WorkspaceEvent } from '../types';
import { previewKindOf } from '@/lib/fileKind';
import { publish } from './port';
export type TerminalKind = 'desktop' | 'phone' | 'tablet';
interface ClientHints {
mobile?: boolean;
model?: string;
@@ -28,7 +30,7 @@ function version(value: string | undefined): string | undefined {
return value?.split('.').slice(0, 2).join('.') || undefined;
}
export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: Device['kind'] }> {
export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: TerminalKind }> {
const ua = navigator.userAgent;
const hints = await readClientHints();
const android = /Android/i.test(ua) || hints.platform === 'Android';
@@ -36,7 +38,7 @@ export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name
const ipad = /iPad/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const tablet = ipad || (android && (hints.mobile === false || (!hints.mobile && !/Mobile/i.test(ua))));
const mobile = !tablet && (android || iphone || /Mobile/i.test(ua));
const kind: Device['kind'] = tablet ? 'tablet' : mobile ? 'phone' : 'desktop';
const kind: TerminalKind = tablet ? 'tablet' : mobile ? 'phone' : 'desktop';
const iosVersion = ua.match(/(?:iPhone )?OS ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
const windowsVersion = version(hints.platformVersion);
const macVersion = ua.match(/Mac OS X ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
@@ -66,24 +68,16 @@ export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name
return { name: `${identity} · ${suffix}`, kind };
}
export function deviceInfo(id: string, joinedAt: number, label: string | undefined, selfId: string): Device {
const name = label ?? '未知设备';
const kind: Device['kind'] = /iPhone|iPod/i.test(name) ? 'phone'
: /iPad|Galaxy Tab|Pixel Tablet|Android.*Tablet/i.test(name) ? 'tablet'
: /Android|Pixel|Galaxy|OnePlus|Xiaomi|Redmi|OPPO|vivo|Huawei/i.test(name) ? 'phone'
: 'desktop';
return { id, name, kind, self: id === selfId, joinedAt };
}
export function showRejected(resourceId: string, event: Extract<ItemEvent, { type: 'rejected' }>, share = false): void {
if (share) publish(resourceId, { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' });
else if (event.itemId) publish(resourceId, {
t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), deviceId: '未知设备', state: { kind: 'tampered' }, text: '' },
t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), state: { kind: 'tampered' }, text: '' },
});
else publish(resourceId, { t: 'error', message: '内容校验失败,已拒绝该记录。' });
}
export function makeFileItem(id: string, name: string, mime: string, size: number, ts: number, state: FileItem['state'], remaining: number | null = null): FileItem {
return { id, kind: 'file', ts, deviceId: '创建者', state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
return { id, kind: 'file', ts, state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
}
export function publishWorkspace(resourceId: string, event: WorkspaceEvent): void {
publish(resourceId, event);