feat(web): add spaces, grouped channels and file chat
This commit is contained in:
1 parent
c7d8ef15bc
commit
f6cfb12716
111 files changed
+4371
-506
No files matched your search
@@ -1,4 +1,4 @@
|
||||
/** protocol §2:频道链接。密钥只在 fragment 里,这里的结果只放内存,不写日志、不持久化 */
|
||||
/** protocol §2:空间链接。密钥只在 fragment 里,这里的结果只放内存,不写日志、不持久化 */
|
||||
|
||||
export interface ParsedChannelLink {
|
||||
id: string;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 极简二维码编码器:字节模式、纠错等级 M、版本 1–10(最多 213 字节)。
|
||||
* 足够容纳频道链接。结构参照 ISO/IEC 18004 与 Nayuki 的公开实现思路。
|
||||
* 足够容纳空间链接。结构参照 ISO/IEC 18004 与 Nayuki 的公开实现思路。
|
||||
* 为什么自写:避免为一个 100 行量级的功能引入依赖(frontend.md 依赖策略)。
|
||||
*/
|
||||
|
||||
|
||||
@@ -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(); });
|
||||
});
|
||||
@@ -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 } : {}) };
|
||||
}
|
||||
Reference in new issue
Block a user