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
@@ -1,4 +1,4 @@
|
||||
import { ChannelSession, toBase64Url } from '@webdrop/core';
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
|
||||
@@ -7,14 +7,12 @@ import { apiFetch, readJson, getServerInfo } from './http';
|
||||
import { channelCache, isOfflineFailure } from './resourceMetadata';
|
||||
import { accountToken } from './accountSession';
|
||||
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
|
||||
import type { ChannelSettingsPatch as SettingsRequest, SeatJoined, MemberBody } from '../generated/api';
|
||||
import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody } from '../generated/api';
|
||||
import { getSession, sessions } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { makeLink } from './links';
|
||||
import { toChannelInfo, type ChannelWire } from './wire';
|
||||
import { stopChannel } from './channelTransport';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { channelMetadata } from './channelEvents';
|
||||
import { requireAccount, accountPublicKey } from './accountSession';
|
||||
import { sealChannelGrant } from './channelGrant';
|
||||
|
||||
@@ -32,7 +30,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
||||
settings: {
|
||||
...defaults,
|
||||
name: options.name?.trim() || undefined,
|
||||
seats: options.seats,
|
||||
max_members: options.maxMembers,
|
||||
ttl_seconds: options.ttlSeconds,
|
||||
members_can_upload: options.membersCanUpload,
|
||||
file_max_downloads: options.fileMaxDownloads,
|
||||
@@ -53,13 +51,9 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
||||
name: info.settings.name ?? undefined,
|
||||
link: session.link(self.location.origin), ownerLink: session.link(self.location.origin, 'owner'),
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats,
|
||||
maxMembers: info.settings.max_members,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: [],
|
||||
};
|
||||
const ownerLabel = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
await apiFetch(`/channels/${session.id}/seats/me`, {
|
||||
method: 'PUT', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, body: ownerLabel,
|
||||
});
|
||||
await saveResource(resource);
|
||||
sessions.set(session.id, session);
|
||||
attachDataKeys(session, resource);
|
||||
@@ -80,10 +74,10 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
|
||||
try {
|
||||
const info = await readJson<ChannelWire>(`/channels/${id}`, session.authHeaders('read'));
|
||||
session.verifyChannelInfo(info);
|
||||
const joined = await apiFetch(`/channels/${id}/seats`, {
|
||||
const joined = await apiFetch(`/channels/${id}/join`, {
|
||||
method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.password }),
|
||||
}).then((response) => response.json() as Promise<SeatJoined>);
|
||||
info.seats_used = joined.seats_used;
|
||||
}).then((response) => response.json() as Promise<JoinResult>);
|
||||
info.members_used = joined.members_used;
|
||||
const resource: SavedResource = {
|
||||
...channelCache(info),
|
||||
id, kind: 'channel', mode: session.mode, role: session.role,
|
||||
@@ -91,13 +85,9 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
|
||||
name: info.settings.name ?? undefined,
|
||||
link: session.link(self.location.origin), ownerLink: session.can('owner') ? session.link(self.location.origin, 'owner') : undefined,
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats,
|
||||
maxMembers: info.settings.max_members,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [],
|
||||
};
|
||||
const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
await apiFetch(`/channels/${id}/seats/me`, {
|
||||
method: 'PUT', headers: { ...session.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, body: label,
|
||||
});
|
||||
await saveResource(resource);
|
||||
sessions.set(id, session);
|
||||
return toChannelInfo(info, resource);
|
||||
@@ -114,7 +104,7 @@ export async function previewChannel(link: ParsedChannelLink): Promise<ChannelIn
|
||||
session.verifyChannelInfo(info);
|
||||
return toChannelInfo(info, { id: link.id, kind: 'channel', mode: session.mode, role: session.role,
|
||||
endpointSecret: session.endpointSecret, link: session.link(self.location.origin), createdAt: info.created_at,
|
||||
expiresAt: info.expires_at, seats: info.settings.seats });
|
||||
expiresAt: info.expires_at, maxMembers: info.settings.max_members });
|
||||
} finally { session.dispose(); }
|
||||
}
|
||||
|
||||
@@ -146,7 +136,7 @@ export async function listChannels(): Promise<ChannelInfo[]> {
|
||||
if (!isOfflineFailure(error)) throw error;
|
||||
if (resource.channelMetadata) return { ...toChannelInfo(resource.channelMetadata, resource), dataSource: 'cache' };
|
||||
return {
|
||||
id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, seats: resource.seats, maxDownloads: resource.maxDownloads ?? undefined,
|
||||
id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, maxMembers: resource.maxMembers, maxDownloads: resource.maxDownloads ?? undefined,
|
||||
creatorAccount: resource.creatorAccount,
|
||||
memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
|
||||
dataSource: 'cache',
|
||||
@@ -161,7 +151,7 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
||||
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
|
||||
const body: SettingsRequest = {};
|
||||
if (patch.name !== undefined) body.name = patch.name.trim();
|
||||
if (patch.seats !== undefined) body.seats = patch.seats;
|
||||
if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
|
||||
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
|
||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
|
||||
@@ -177,36 +167,6 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
||||
publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, updated) });
|
||||
}
|
||||
|
||||
export async function renameDevice(resourceId: string, endpointHash: string, name: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const ownEndpointHash = toBase64Url(session.endpointHash);
|
||||
const isSelf = endpointHash === ownEndpointHash;
|
||||
if (!isSelf && !session.can('owner')) throw new Error('只有所有者可以重命名其他设备');
|
||||
const normalized = name.trim();
|
||||
if (!normalized) throw new Error('设备名称不能为空');
|
||||
const label = await session.sealLabel(normalized, { endpointHash });
|
||||
const path = isSelf
|
||||
? `/channels/${resourceId}/seats/me`
|
||||
: `/channels/${resourceId}/seats/${endpointHash}/label`;
|
||||
const role = !isSelf || session.can('owner') ? 'owner' : 'read';
|
||||
await apiFetch(path, {
|
||||
method: 'PUT', headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream' }, body: label,
|
||||
});
|
||||
if (isSelf) {
|
||||
resource.deviceLabel = normalized;
|
||||
await saveResource(resource);
|
||||
}
|
||||
await channelMetadata(resource, session);
|
||||
}
|
||||
|
||||
export async function releaseDevice(resourceId: string, endpointHash: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (!session.can('owner')) throw new Error('只有所有者可以移除设备');
|
||||
if (endpointHash === toBase64Url(session.endpointHash)) throw new Error('不能移除当前设备');
|
||||
await apiFetch(`/channels/${resourceId}/seats/${endpointHash}`, { method: 'DELETE', headers: session.authHeaders('owner') });
|
||||
await channelMetadata(resource, session);
|
||||
}
|
||||
|
||||
export async function forgetChannel(id: string): Promise<void> {
|
||||
stopChannel(id);
|
||||
sessions.get(id)?.dispose();
|
||||
|
||||
Reference in new issue
Block a user