feat(web): add spaces, grouped channels and file chat

This commit is contained in:
justme committed 2026-10-13 01:39:23 +08:00
1 parent c7d8ef15bc
commit f6cfb12716
111 files changed
+4371 -506

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
/** protocol §2:频道链接。密钥只在 fragment 里,这里的结果只放内存,不写日志、不持久化 */
/** protocol §2:空间链接。密钥只在 fragment 里,这里的结果只放内存,不写日志、不持久化 */
export interface ParsedChannelLink {
id: string;
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { fitFloatingPoint, readFileDrag, selectFiles } from './explorer';
describe('explorer selection', () => {
const rows = ['a', 'b', 'c', 'd'];
it('single clicks replace selection; modifier clicks toggle individual files', () => {
expect([...selectFiles(rows, new Set(['a', 'b']), 'c', 'a', { additive: false, range: false })]).toEqual(['c']);
expect([...selectFiles(rows, new Set(['a']), 'c', 'a', { additive: true, range: false })]).toEqual(['a', 'c']);
expect([...selectFiles(rows, new Set(['a', 'c']), 'c', 'a', { additive: true, range: false })]).toEqual(['a']);
});
it('shift selection uses visible order in either direction and can add a range', () => {
expect([...selectFiles(rows, new Set(['a']), 'b', 'd', { additive: false, range: true })]).toEqual(['b', 'c', 'd']);
expect([...selectFiles(rows, new Set(['a']), 'd', 'c', { additive: true, range: true })]).toEqual(['a', 'c', 'd']);
});
it('unknown rows cannot be injected into selection', () => {
expect([...selectFiles(rows, new Set(['a']), 'unknown', 'a', { additive: true, range: false })]).toEqual(['a']);
});
});
describe('internal file drag validation', () => {
const available = new Set(['a', 'b']);
it('deduplicates file ids from the same space and channel', () => {
expect(readFileDrag(JSON.stringify({ scope: 'space:channel', ids: ['a', 'a', 'b'] }), 'space:channel', available)).toEqual(['a', 'b']);
});
it.each([
{ scope: 'other:channel', ids: ['a'] }, { scope: 'space:channel', ids: ['unknown'] },
{ scope: 'space:channel', ids: [] }, { scope: 'space:channel', ids: Array(101).fill('a') },
])('rejects foreign or invalid payload %#', (payload) => {
expect(readFileDrag(JSON.stringify(payload), 'space:channel', available)).toBeNull();
});
it('rejects malformed JSON', () => { expect(readFileDrag('not json', 'space:channel', available)).toBeNull(); });
});
describe('floating menu position', () => {
it('keeps menus inside the viewport near the bottom-right edge', () => {
expect(fitFloatingPoint(390, 890, 210, 250, 400, 900)).toEqual({ x: 182, y: 642 });
expect(fitFloatingPoint(-100, -100, 210, 250, 400, 900)).toEqual({ x: 8, y: 8 });
});
});
+26
View File
@@ -0,0 +1,26 @@
export const FILE_DRAG_TYPE = 'application/x-webdrop-files';
export function selectFiles(ordered: string[], selected: ReadonlySet<string>, id: string, anchor: string | undefined,
modifiers: { additive: boolean; range: boolean }): Set<string> {
if (!ordered.includes(id)) return new Set(selected);
if (modifiers.range && anchor && ordered.includes(anchor)) {
const first = ordered.indexOf(anchor); const last = ordered.indexOf(id);
const range = ordered.slice(Math.min(first, last), Math.max(first, last) + 1);
return new Set(modifiers.additive ? [...selected, ...range] : range);
}
if (!modifiers.additive) return new Set([id]);
const next = new Set(selected); if (next.has(id)) next.delete(id); else next.add(id); return next;
}
export function readFileDrag(text: string, scope: string, available: ReadonlySet<string>): string[] | null {
try {
const value = JSON.parse(text) as { scope?: unknown; ids?: unknown };
if (value.scope !== scope || !Array.isArray(value.ids) || value.ids.length < 1 || value.ids.length > 100
|| !value.ids.every((id: unknown) => typeof id === 'string' && available.has(id))) return null;
return [...new Set(value.ids)] as string[];
} catch { return null; }
}
export function fitFloatingPoint(x: number, y: number, width: number, height: number, viewportWidth: number, viewportHeight: number) {
return { x: Math.max(8, Math.min(x, viewportWidth - width - 8)), y: Math.max(8, Math.min(y, viewportHeight - height - 8)) };
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* 极简二维码编码器:字节模式、纠错等级 M、版本 1–10(最多 213 字节)。
* 足够容纳频道链接。结构参照 ISO/IEC 18004 与 Nayuki 的公开实现思路。
* 足够容纳空间链接。结构参照 ISO/IEC 18004 与 Nayuki 的公开实现思路。
* 为什么自写:避免为一个 100 行量级的功能引入依赖(frontend.md 依赖策略)。
*/
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { readSender } from './sender';
describe('sender display snapshots', () => {
it('accepts account identity and safe local avatar data', () => {
expect(readSender({ username: 'alice', displayName: '小白', avatar: 'data:image/webp;base64,AA==' })).toEqual({ username: 'alice', displayName: '小白', avatar: 'data:image/webp;base64,AA==' });
expect(readSender({ username: 'alice', displayName: ' Alice ' })).toEqual({ username: 'alice', displayName: 'Alice' });
});
it.each([
{ username: 'alice', displayName: 'Alice', avatar: 'https://tracker.test/avatar.png' },
{ username: 'alice', displayName: 'Alice', avatar: 'data:image/svg+xml;base64,AA==' },
{ username: 'alice', displayName: 'Alice', avatar: `data:image/png;base64,${'A'.repeat(8000)}` },
{ username: 'alice', displayName: '\u0000Alice' },
{ username: 'alice', displayName: '' }, { username: 'invalid user', displayName: 'Alice' }, null,
])('rejects invalid or executable identity data %#', (value) => { expect(readSender(value)).toBeUndefined(); });
});
+10
View File
@@ -0,0 +1,10 @@
import type { SenderInfo } from '@/core-bridge/types';
export function readSender(value: unknown): SenderInfo | undefined {
if (!value || typeof value !== 'object') return undefined;
const row = value as Record<string, unknown>;
if (typeof row.username !== 'string' || !/^[a-z0-9_-]{3,32}$/.test(row.username)
|| typeof row.displayName !== 'string' || !row.displayName.trim() || row.displayName.length > 48 || /[\u0000-\u001f]/.test(row.displayName)
|| row.avatar !== undefined && (typeof row.avatar !== 'string' || row.avatar.length > 8000 || !/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/.test(row.avatar))) return undefined;
return { username: row.username, displayName: row.displayName.trim(), ...(row.avatar ? { avatar: row.avatar as string } : {}) };
}