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

+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
import { accountApi } from '@/core-bridge/account';
import { useAccount } from '@/store/account';
import { useChannels } from '@/store/channels';
import { AccountScreen } from '@/features/home/AccountScreen';
import { MissingAccess } from '@/features/scan/JoinScreen';
import { Button } from '@/ui/Button';
import styles from '@/features/scan/JoinScreen.module.css';
export function AccountChannelAccess({ id, onHome }: { id: string; onHome: () => void }) {
const username = useAccount((s) => s.username);
const [invited, setInvited] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
setInvited(false); setError('');
if (username) void accountApi.invitations().then((rows) => {
if (active) setInvited(rows.some((row) => row.channel_id === id));
}).catch(() => { if (active) setError('邀请读取失败,请重试同步。'); });
return () => { active = false; };
}, [id, username]);
async function accept() {
setBusy(true); setError('');
try { await accountApi.acceptInvitation(id); await useChannels.getState().refresh(); }
catch (cause) { setError(cause instanceof Error ? cause.message : '加入失败'); }
finally { setBusy(false); }
}
if (!username) return <AccountScreen />;
if (!invited) return <MissingAccess onHome={onHome} />;
return <div className={styles.page}><main className={styles.card}><h1 className={styles.title}>空间邀请</h1><p className={styles.dim}>接受邀请后,使用你的账号密钥自动解锁此空间,无需复制密钥链接。</p>
<Button variant="primary" disabled={busy} onClick={() => { void accept(); }}>{busy ? '加入中…' : '接受并加入空间'}</Button>
<Button variant="ghost" onClick={onHome}>回到首页</Button>
{error ? <p role="alert">{error}</p> : null}</main></div>;
}
+18 -7
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Home } from '@/features/home/Home';
import { JoinScreen, MissingAccess } from '@/features/scan/JoinScreen';
import { JoinScreen } from '@/features/scan/JoinScreen';
import { AccountChannelAccess } from './AccountChannelAccess';
import { useChannels } from '@/store/channels';
import { useShares } from '@/store/shares';
import { useWorkspace } from '@/store/workspace';
@@ -8,6 +9,8 @@ import { ChannelScreen } from './ChannelScreen';
import { ShareScreen } from './ShareScreen';
import { channelPath, navigate, useRoute } from './navigation';
import { Splash } from './Splash';
import { AccountScreen } from '@/features/home/AccountScreen';
import { useAccount } from '@/store/account';
const SEEN_KEY = 'wd.splash';
@@ -20,6 +23,13 @@ function splashSeen(): boolean {
}
export function App() {
const username = useAccount((s) => s.username);
const accountHydrated = useAccount((s) => s.hydrated);
const refreshAccount = useAccount((s) => s.refresh);
useEffect(() => {
const timer = window.setInterval(() => { void refreshAccount(); }, 5000);
return () => window.clearInterval(timer);
}, [refreshAccount]);
const route = useRoute();
const list = useChannels((s) => s.list);
const channelsHydrated = useChannels((s) => s.hydrated);
@@ -31,12 +41,12 @@ export function App() {
const [splash, setSplash] = useState(() => !splashSeen());
const saved = route.name === 'channel' && list.some((c) => c.id === route.id);
// 只有打开已保存的频道才需要等数据;首页与加入页没有加载过程
// 只有打开已保存的空间才需要等数据;首页与加入页没有加载过程
const waitingOnSavedChannel = route.name === 'channel' && !pendingLink && !channelsHydrated;
const waitingOnSavedShare = route.name === 'share' && pendingShareLink?.id !== route.id && !sharesHydrated;
const ready = route.name === 'home' || (route.name === 'channel'
const ready = accountHydrated && (route.name === 'home' || (route.name === 'channel'
? pendingLink?.id === route.id || (channelsHydrated && (!saved || wsReady))
: pendingShareLink?.id === route.id || sharesHydrated);
: pendingShareLink?.id === route.id || sharesHydrated));
function onSplashDone() {
try {
@@ -48,7 +58,8 @@ export function App() {
}
let screen;
if (route.name === 'home') screen = <Home />;
if (!accountHydrated) screen = null;
else if (route.name === 'home') screen = username ? <Home /> : <AccountScreen />;
else if (route.name === 'share' && waitingOnSavedShare) screen = null;
else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
else if (route.name === 'channel' && waitingOnSavedChannel) screen = null;
@@ -64,7 +75,7 @@ export function App() {
}}
/>
);
} else screen = <MissingAccess onHome={() => navigate('/')} />;
} else screen = <AccountChannelAccess id={route.name === 'channel' ? route.id : ''} onHome={() => navigate('/')} />;
return (
<>
+7 -3
View File
@@ -1,13 +1,17 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Workspace } from '@/features/channel/Workspace';
import { useWorkspace } from '@/store/workspace';
import { CreateSheet } from '@/features/home/CreateSheet';
import { channelPath, navigate } from './navigation';
/** 打开(订阅)频道,数据就绪后显示暂存区 */
/** 打开(订阅)空间,数据就绪后显示暂存区 */
export function ChannelScreen({ id }: { id: string }) {
const open = useWorkspace((s) => s.open);
const ready = useWorkspace((s) => s.ready);
const [creating, setCreating] = useState(false);
useEffect(() => open(id), [open, id]);
return ready ? <Workspace /> : null;
return ready ? <><Workspace onCreateChannel={() => setCreating(true)} />
{creating ? <CreateSheet onClose={() => setCreating(false)} onCreated={(channel) => { setCreating(false); navigate(channelPath(channel.id)); }} /> : null}</> : null;
}