feat(web): connect quick share flow

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:37 +08:00
1 parent 53768a195d
commit e02e024605
138 files changed
+10614

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { Home } from '@/features/home/Home';
import { JoinScreen, MissingAccess } from '@/features/scan/JoinScreen';
import { useChannels } from '@/store/channels';
import { useWorkspace } from '@/store/workspace';
import { ChannelScreen } from './ChannelScreen';
import { ShareScreen } from './ShareScreen';
import { channelPath, navigate, useRoute } from './navigation';
import { Splash } from './Splash';
const SEEN_KEY = 'wd.splash';
function splashSeen(): boolean {
try {
return sessionStorage.getItem(SEEN_KEY) === '1';
} catch {
return false;
}
}
export function App() {
const route = useRoute();
const list = useChannels((s) => s.list);
const pendingLink = useChannels((s) => s.pendingLink);
const setPendingLink = useChannels((s) => s.setPendingLink);
const wsReady = useWorkspace((s) => s.ready);
const [splash, setSplash] = useState(() => !splashSeen());
const saved = route.name === 'channel' && list.some((c) => c.id === route.id);
// 只有打开已保存的频道才需要等数据;首页与加入页没有加载过程
const ready = !saved || wsReady;
function onSplashDone() {
try {
sessionStorage.setItem(SEEN_KEY, '1');
} catch {
/* 无存储时每次都播,不影响功能 */
}
setSplash(false);
}
let screen;
if (route.name === 'home') screen = <Home />;
else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
else if (saved) screen = <ChannelScreen id={route.id} />;
else if (pendingLink?.id === route.id) {
screen = (
<JoinScreen
link={pendingLink}
onJoined={(c) => navigate(channelPath(c.id))}
onCancel={() => {
setPendingLink(null);
navigate('/');
}}
/>
);
} else screen = <MissingAccess onHome={() => navigate('/')} />;
return (
<>
{screen}
{splash ? <Splash ready={ready} onDone={onSplashDone} /> : null}
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { Workspace } from '@/features/channel/Workspace';
import { useWorkspace } from '@/store/workspace';
/** 打开(订阅)频道,数据就绪后显示暂存区 */
export function ChannelScreen({ id }: { id: string }) {
const open = useWorkspace((s) => s.open);
const ready = useWorkspace((s) => s.ready);
useEffect(() => open(id), [open, id]);
return ready ? <Workspace /> : null;
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { ItemSourceContext, shareSource } from '@/features/item/source';
import { MissingAccess } from '@/features/scan/JoinScreen';
import { ShareDraft } from '@/features/share/ShareDraft';
import { ShareView } from '@/features/share/ShareView';
import { useShares } from '@/store/shares';
import { useShareSession } from '@/store/shareSession';
import { navigate } from './navigation';
/**
* 便捷分享页。已在「我的分享」里就直接打开;从链接进来的访客**直接加入**,不预览、不展示人数;
* 都不是就提示缺少链接。条目组件通过 ItemSourceContext 读分享自己的数据。
*/
export function ShareScreen({ id }: { id: string }) {
const known = useShares((s) => s.list.some((x) => x.id === id));
const pending = useShares((s) => s.pendingLink);
const join = useShares((s) => s.join);
useEffect(() => {
if (!known && pending?.id === id) void join(pending);
}, [known, pending, id, join]);
if (known) return <ShareSession id={id} />;
if (pending?.id === id) return null; // 正在加入
return <MissingAccess onHome={() => navigate('/')} />;
}
function ShareSession({ id }: { id: string }) {
const open = useShareSession((s) => s.open);
const ready = useShareSession((s) => s.ready);
const share = useShareSession((s) => s.share);
useEffect(() => open(id), [open, id]);
if (!ready || !share) return null;
return (
<ItemSourceContext.Provider value={shareSource}>
{share.state === 'draft' && share.mine ? <ShareDraft /> : <ShareView />}
</ItemSourceContext.Provider>
);
}
+76
View File
@@ -0,0 +1,76 @@
.splash {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
background: var(--bg);
cursor: pointer;
}
.leaving {
animation: leave 0.2s ease-out forwards;
pointer-events: none;
}
.word {
width: min(62vw, 300px);
fill: none;
stroke: var(--text);
stroke-width: 3.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.stroke {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: draw 0.3s ease-out forwards;
animation-delay: calc(var(--i) * 0.13s);
}
.hint {
margin: 0;
color: var(--text-dim);
font-size: 13px;
opacity: 0;
animation: fade-in 0.3s ease-out 0.6s forwards;
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes fade-in {
to {
opacity: 1;
}
}
@keyframes leave {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stroke {
stroke-dashoffset: 0;
animation: none;
}
.hint {
opacity: 1;
animation: none;
}
.leaving {
animation: none;
opacity: 0;
}
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState, type AnimationEvent } from 'react';
import styles from './Splash.module.css';
/** 单线手写 WebDrop,逐笔画出。每个字母一条路径,按序号错开 */
const STROKES = [
'M4 14 L14 58 L24 30 L34 58 L44 14',
'M52 42 H76 C76 28 66 26 60 31 C50 38 53 58 63 58 C69 59 74 56 77 52',
'M88 8 L88 58 M88 46 C92 34 106 36 106 47 C106 59 92 62 88 52',
'M120 14 L120 58 M120 14 C156 12 160 58 120 58',
'M172 30 L172 58 M172 42 C176 31 186 29 192 33',
'M204 46 C204 33 222 33 222 46 C222 59 204 59 204 46',
'M236 32 L236 74 M236 46 C240 34 256 36 256 48 C256 60 242 64 236 54',
];
const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
interface Props {
ready: boolean;
onDone: () => void;
}
export function Splash({ ready, onDone }: Props) {
const [animDone, setAnimDone] = useState(reduced);
const [skipped, setSkipped] = useState(false);
const leaving = skipped || (animDone && ready);
useEffect(() => {
const skip = () => setSkipped(true);
window.addEventListener('keydown', skip);
return () => window.removeEventListener('keydown', skip);
}, []);
function onEnd(e: AnimationEvent) {
if (e.animationName.includes('draw') && e.target instanceof SVGElement && e.target.dataset.last) setAnimDone(true);
if (e.animationName.includes('leave') && e.target === e.currentTarget) onDone();
}
return (
<div
className={`${styles.splash} ${leaving ? styles.leaving : ''}`}
onClick={() => setSkipped(true)}
onAnimationEnd={onEnd}
role="status"
aria-label="加载中"
>
<svg viewBox="0 0 262 82" className={styles.word} aria-hidden="true">
{STROKES.map((d, i) => (
<path
key={d}
d={d}
pathLength={1}
className={styles.stroke}
style={{ '--i': i } as React.CSSProperties}
data-last={i === STROKES.length - 1 ? '1' : undefined}
/>
))}
</svg>
<p className={styles.hint}>加载中</p>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { parseChannelLink, type ParsedChannelLink } from '@/lib/channelLink';
import { parseShareLink, type ParsedShareLink } from '@/lib/shareLink';
export type EntryLink =
| { kind: 'channel'; link: ParsedChannelLink }
| { kind: 'share'; link: ParsedShareLink };
/**
* 页面一加载就取出 URL fragment 里的密钥,并立刻 history.replaceState 清掉(threat-model §4)。
* 必须在任何异步代码和渲染之前同步调用。
*/
export function takeLinkFromLocation(): EntryLink | null {
if (!location.hash) return null;
const href = location.href;
history.replaceState(null, '', location.pathname + location.search);
const share = parseShareLink(href);
if (share.ok) return { kind: 'share', link: share.link };
const channel = parseChannelLink(href);
return channel.ok ? { kind: 'channel', link: channel.link } : null;
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
// navigation.ts 在模块加载时读 location/window,这里只测纯函数,用最小的桩
Object.assign(globalThis, {
location: { pathname: '/', origin: 'http://x' },
window: { addEventListener: () => {} },
history: { state: null },
});
const { parseRoute } = await import('./navigation');
describe('parseRoute', () => {
it('根路径是首页', () => expect(parseRoute('/')).toEqual({ name: 'home' }));
it('/c/:id 是频道', () => {
expect(parseRoute('/c/k3Fq9xWm2LpA7sTdQ8vZ1n')).toEqual({ name: 'channel', id: 'k3Fq9xWm2LpA7sTdQ8vZ1n' });
});
it('/s/:id 是便捷分享', () => {
expect(parseRoute('/s/k3Fq9xWm2LpA7sTdQ8vZ1n')).toEqual({ name: 'share', id: 'k3Fq9xWm2LpA7sTdQ8vZ1n' });
});
it('id 格式不对回到首页', () => expect(parseRoute('/c/short')).toEqual({ name: 'home' }));
it('未知路径回到首页', () => expect(parseRoute('/other')).toEqual({ name: 'home' }));
});
+50
View File
@@ -0,0 +1,50 @@
import { useSyncExternalStore } from 'react';
import { isSheetEntry } from '@/lib/useBackToClose';
export type Route = { name: 'home' } | { name: 'channel'; id: string } | { name: 'share'; id: string };
const CHANNEL_RE = /^\/c\/([A-Za-z0-9_-]{22})\/?$/;
const SHARE_RE = /^\/s\/([A-Za-z0-9_-]{22})\/?$/;
export function parseRoute(pathname: string): Route {
const c = CHANNEL_RE.exec(pathname);
if (c?.[1]) return { name: 'channel', id: c[1] };
const s = SHARE_RE.exec(pathname);
return s?.[1] ? { name: 'share', id: s[1] } : { name: 'home' };
}
let current: Route = parseRoute(location.pathname);
const listeners = new Set<() => void>();
const same = (a: Route, b: Route) => a.name === b.name && (a.name === 'home' || ('id' in b && a.id === b.id));
function set(route: Route) {
if (same(current, route)) return;
current = route;
for (const l of listeners) l();
}
window.addEventListener('popstate', () => set(parseRoute(location.pathname)));
/**
* 所有页面跳转的唯一入口。
* 从弹层里跳转时替换掉弹层压入的那条历史,避免返回键多退一步(lib/useBackToClose.ts)。
*/
export function navigate(path: string): void {
if (isSheetEntry()) history.replaceState(null, '', path);
else history.pushState(null, '', path);
set(parseRoute(path));
}
export function useRoute(): Route {
return useSyncExternalStore(
(cb) => {
listeners.add(cb);
return () => listeners.delete(cb);
},
() => current,
);
}
export const channelPath = (id: string) => `/c/${id}`;
export const sharePath = (id: string) => `/s/${id}`;