feat(web): connect quick share flow
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
1 parent
53768a195d
commit
e02e024605
138 files changed
+10614
No files matched your search
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' }));
|
||||
});
|
||||
@@ -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}`;
|
||||
Reference in new issue
Block a user