Files
photo-live/apps/viewer/src/realtime/router.ts
T
浅念 cdead49838 feat(viewer): likes, bubbles, visit reporting and hot sorting
Task 10 viewer 互动化:

- 点赞:useLikes 批次状态机(500ms 防抖 / 5 次满额即冲 / 失败同 batchId
  退避重试 3 次后回滚丢弃),乐观计数 + 服务端 total 权威校准
  (countVersion 单调),大图与格子双入口;
- WS 实时:RealtimeClient 纯状态机(半频心跳、指数退避重连、三败降级、
  重连成功 resync 第 1 页重取),消息路由器做 likes.burst 自身过滤
  (originBatchIds 求交)、隐藏页暂停与减少动画下仅校准不出气泡;
  photo.hidden/deleted 即时移出序列并回退邻居大图;降级后 8s 轮询接管;
- 浏览上报:visitorId 跨标签 BroadcastChannel first-writer-wins 协调,
  可见时上报、requestId 持久幂等、网络失败指数重试;
- 排序:最新/热门 chips(热门走 ov 冷快照),大图打开期间禁用切换;
  顶栏「减少动画」偏好开关;
- 契约包新增 ./realtime 子路径出口(浏览器面不可引桶出口——其连带
  internal-sync 的 node:crypto 会被 Vite 外置化致整页崩溃);
- e2e 注入钩子 __viewerWsUrl/__viewerWsRetryBaseMs/__viewerRealtimePollMs/
  __viewerBubbleMs 与 .activity[data-connection] 连接态锚。

vitest:viewer 105(新增 36:likes 批次 8、visitor 协调 5、上报门控 4、
路由 8、WS 状态机 7、排序 4);Playwright:viewer 21(新增 likes.spec 7、
journey 补互动腿断言)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 19:52:27 +08:00

59 lines
2.6 KiB
TypeScript

// apps/viewer/src/realtime/router.ts —— 服务端消息路由(P2 Task 10)。
// 纯函数 + 依赖注入:帧语义集中一处,单测直测(自身过滤/隐藏暂停/减少动画)。
//
// likes.burst 自身过滤(A21 客户端侧):originBatchIds 与本会话已提交批次求交
// 得己方 delta——己方贡献不播气泡(点击时已见过动画),他人贡献照常;total
// 恒为权威值,无论过滤与否都校准。页面不可见(visibilitychange)或「减少动画」
// 开启时跳过气泡(队列即弃——回台只看得到校准后的数字),计数校准不受影响。
import type { ServerMessage } from '@photo/contracts';
export interface RealtimeRouterDeps {
/** 页面当前是否不可见(document.hidden)。 */
isHidden(): boolean;
/** 「减少动画」偏好是否开启。 */
reducedMotion(): boolean;
/** originBatchIds 中属于本会话已提交批次的 delta 合计。 */
ownDeltaIn(batchIds: string[]): number;
/** 点赞计数权威校准(版本化)。 */
calibrateLike(photoId: string, total: number, countVersion: number): void;
/** 他人点赞气泡(photoId + 他人贡献 delta)。 */
showBubble(photoId: string, delta: number): void;
/** 新照片到达:亮提示条(不自动插入)。 */
notifyNewPhotos(): void;
/** 照片隐藏/删除:移出序列(调用方负责大图收口与提示)。 */
removePhoto(photoId: string): void;
/** 相册浏览数更新(stats.updated)。 */
setViewCount(viewCount: number): void;
}
export function handleServerMessage(message: ServerMessage, deps: RealtimeRouterDeps): void {
switch (message.type) {
case 'pong':
return; // 心跳应答:无动作
case 'error':
// subscribe 校验失败等:连接保持,服务端已说明——本地备注即可。
console.info(`[viewer] realtime error frame: ${message.code}`);
return;
case 'photo.published':
deps.notifyNewPhotos();
return;
case 'photo.hidden':
case 'photo.deleted':
deps.removePhoto(message.photoId);
return;
case 'likes.burst': {
const own = deps.ownDeltaIn(message.originBatchIds);
// 校准无条件执行(含纯己方 burst——批次响应丢失时这是唯一权威回执)。
deps.calibrateLike(message.photoId, message.total, message.countVersion);
const others = message.delta - own;
if (others > 0 && !deps.isHidden() && !deps.reducedMotion()) {
deps.showBubble(message.photoId, others);
}
return;
}
case 'stats.updated':
deps.setViewCount(message.viewCount);
return;
}
}