---
title: "Telegram-style Resource Stream — Implementation Plan"
type: plan
date: 2026-05-25
workbranch: feat/telegram-stream
specs:
  - .unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md
---

# Telegram-style Resource Stream — Implementation Plan

## Overview

实现 `/browse` 与 `/category/:slug` 的 Telegram-style 消息流重构。前端先用 mock data 完成全部视觉与交互，等后端 `/api/posts` 系列接口 ready 后切换。同时收尾删除收藏功能与 `ResourceDetail` 详情页。

分支：`feat/telegram-stream`（在当前目录新建，不走 worktree）。完成后由 Terry 显式确认才 merge。

## Sequencing

```
Task 1 (基础类型 + mock + utils)
        ↓
Task 2 (hooks) ─────────────────┐
                                │
Task 3 (overlays) ─────┐        │
                       ↓        │
                   Task 4 (bubbles)
                       ↓
                   Task 5 (stream 容器)
                       ↓
                   Task 6 (页面改写)
                       
Task 7 (清理收藏 / 详情页) ── 可与 1-5 并行，但合并到 Task 6 之前完成
Task 8 (验证 + 文档 + API 契约) ── 最后
```

依赖关键路径：1 → 2/3 → 4 → 5 → 6 → 8。Task 7 独立，建议早做以减少 imports 残留。

## Tasks

- unstarted: Task 0 — 创建分支
  - Description: 在当前目录创建并切到 `feat/telegram-stream` 分支
  - Dependencies: 无
  - Acceptance Criteria: `git branch --show-current` 输出 `feat/telegram-stream`；`git status` 干净
  - Steps:
    1. `git status --short --branch` 确认无未提交改动
    2. `git checkout -b feat/telegram-stream`
    3. 确认 `.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md` 与 `.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md` 已在该分支

- unstarted: Task 1 — 类型定义 + Mock data + 纯函数 utils
  - Description: 建立 Post / Attachment 类型，写覆盖 7 种 bubble 的 mock 样本，写 3 个无依赖纯函数 util
  - Dependencies: Task 0
  - Acceptance Criteria:
    - `src/types/post.ts` 导出 `Post`、`Attachment`、`PostListResponse`、`PostScope`
    - `src/mocks/mockPosts.ts` 至少 12 条 Post 覆盖：2 图片当文档 + 2 PDF/AI + 2 纯文本+链接 + 1 视频 + 2 单图 + 1 图+文 + 1 三图相册 + 1 七图相册；publishedAt 跨 ≥3 个不同日期
    - `formatBytes(3549239)` → `"3.4 MB"`（unit tests pass）
    - `autolink("点 https://x.com 看")` 返回 React 节点数组，链接处为 `<a target="_blank" rel="noopener noreferrer">`（unit tests pass）
    - `fileIcon({ mime, filename })` 返回 `{ Icon, color }`，PDF 红、AI 橙、PPT 红、图片走 thumbnail 不返回 Icon
    - `npx tsc --noEmit` 通过
  - Steps:
    1. 写 `src/types/post.ts`
    2. 写 `src/components/messageStream/utils/formatBytes.ts` + `.test.ts`
    3. 写 `src/components/messageStream/utils/autolink.tsx` + `.test.tsx`
    4. 写 `src/components/messageStream/utils/fileIcon.ts`
    5. 写 `src/mocks/mockPosts.ts`（图片用 picsum.photos 占位，视频用公开 sample mp4 + 一张占位 poster）
    6. 跑 `npm test` 与 `npx tsc --noEmit`

- unstarted: Task 2 — Stream hooks（usePostStream + useGroupedByDay）
  - Description: 数据层抽象，mock/real 双模式 + 按日期分组
  - Dependencies: Task 1
  - Acceptance Criteria:
    - `usePostStream({ scope, type, language })` 在 `VITE_USE_MOCK_POSTS !== "false"` 时从 `MOCK_POSTS` 过滤 + 倒序 + cursor 切片（每页 20）+ 200ms 假延迟
    - 真接口分支调用 `getJSON</api/posts?...>`（占位即可，后端未 ready 时不会走到）
    - 返回 `{ items, isLoading, error, hasMore, loadMore, reset }`
    - `useGroupedByDay(posts, lang)` 返回 `Array<{ dayLabel: string; items: Post[] }>`，按本地时区日期分组，dayLabel 通过 `Intl.DateTimeFormat` 按 lang 切换（zh-TW/zh-CN/en）
    - 单元测试：useGroupedByDay 在跨天数据上分出正确组数
  - Steps:
    1. 写 `src/components/messageStream/hooks/usePostStream.ts`
    2. 写 `src/components/messageStream/hooks/useGroupedByDay.ts` + `.test.ts`
    3. 在 `.env.example` 加 `VITE_USE_MOCK_POSTS=true` 注释
    4. 跑 `npm test` 与 `npx tsc --noEmit`

- unstarted: Task 3 — Overlay 基础设施（ImageLightbox + VideoPlayer）
  - Description: 全屏画廊与视频播放器，portal 渲染，单一入口 context
  - Dependencies: Task 1
  - Acceptance Criteria:
    - `<ImageLightboxProvider>` 包在 App 根，暴露 `useLightbox()` → `openLightbox(images, startIndex)`
    - `ImageLightbox` 支持：左右切换（按钮 + 键盘 ← → + 触屏左右滑）、ESC/点遮罩关闭、右上角下载按钮
    - `VideoPlayer` 支持：全屏遮罩 + `<video controls autoPlay>`、接 `currentTime` 参数避免重新加载、ESC/点遮罩关闭
    - 两个 overlay 在手机端不溢出、不被底部 nav 遮挡
    - `npx tsc --noEmit` 通过
  - Steps:
    1. 写 `src/components/messageStream/overlays/ImageLightbox.tsx` + Provider/Context
    2. 写 `src/components/messageStream/overlays/VideoPlayer.tsx`
    3. 在 `App.tsx` / 根布局挂 Provider
    4. 手动验证：在 dev console 临时调用 `openLightbox` 看是否正确呈现

- unstarted: Task 4 — Bubble 子组件（6 个 + 分发器）
  - Description: 按截图实现 6 种气泡 + `MessageBubble` 分发
  - Dependencies: Task 1, Task 3
  - Acceptance Criteria:
    - `FileDocBubble` 处理 `kind: "document"`：
      - 若 `mime.startsWith("image/")` → 左侧用 `thumbnailUrl` 缩略 + ↓ 覆盖；右侧 filename.ext + size（截图 1）
      - 否则 → 左侧蓝圆 ↓ 图标（按 mime 取色）+ 右侧 filename + size（截图 2）
    - `TextBubble` 渲染 `text`，调 `autolink`（截图 3）
    - `VideoBubble` 初始显示 posterUrl + ▶️ + 时长，第一次点 inline `<video controls autoPlay>`，第二次点（已播放）调 `openVideoPlayer`（截图 4）
    - `ImageBubble` 单张图，点击调 `openLightbox([att], 0)`（截图 5）
    - `ImageWithTextBubble` 单图 + 下方文本（autolink）（截图 6）
    - `AlbumBubble` 2-4 格 grid，间距 2px；attachments.length > 4 时第 4 格 `bg-black/45 backdrop-blur-sm` 覆盖 `+N`；点任一格调 `openLightbox(images, index)`（截图 7）
    - `MessageBubble` 实现 spec §4 的 `pickBubble` 分发，右下角绝对定位时间戳 `text-[11px] text-neutral-500`
    - 所有 bubble 容器：`rounded-2xl bg-ark-panel p-3`（文本气泡 `px-4 py-2.5`），左对齐，无头像
    - `npx tsc --noEmit` 通过
  - Steps:
    1. 写 `MessageBubble.tsx`（含 pickBubble）
    2. 写 `bubbles/TextBubble.tsx`
    3. 写 `bubbles/FileDocBubble.tsx`
    4. 写 `bubbles/ImageBubble.tsx`
    5. 写 `bubbles/ImageWithTextBubble.tsx`
    6. 写 `bubbles/AlbumBubble.tsx`
    7. 写 `bubbles/VideoBubble.tsx`
    8. 在 dev 中临时挂一个 demo route 跑 MOCK_POSTS 全量渲染，目视检查 7 张截图对照

- unstarted: Task 5 — Stream 容器 + FilterChips + DaySeparator
  - Description: 顶层组件接管 fetch、分组、无限滚动、sticky 筛选
  - Dependencies: Task 2, Task 4
  - Acceptance Criteria:
    - `FilterChips`：sticky top，横向滚动 `overflow-x-auto whitespace-nowrap`，类型 chips（all/image/video/ppt/pdf/text/link/archive，沿用 `typeFilterLabel`）+ 语言 chips（all/zh-TW/zh-CN/en），改变时 reset cursor 并同步 URL `?type=&language=`
    - `DaySeparator`：胶囊样式，居中
    - `MessageStream`：
      - 接 `scope: { kind: "all" } | { kind: "category", slug: string }`
      - 调用 `usePostStream` + `useGroupedByDay`
      - 用 `IntersectionObserver` 监听底部 sentinel，触发 `loadMore`（loadingRef 守护避免重复）
      - 容器：手机 `max-w-full px-3 mx-auto`，md+ `max-w-[640px] mx-auto`
      - 空状态用 `t("noResults")`，错误用红色 inline 横幅 + 重试按钮
    - `npx tsc --noEmit` 通过
  - Steps:
    1. 写 `FilterChips.tsx`
    2. 写 `DaySeparator.tsx`
    3. 写 `MessageStream.tsx`
    4. 在 dev demo route 挂 `<MessageStream scope={{ kind: "all" }} />` 验证滚动 + 筛选 + 分组

- unstarted: Task 6 — 重写 CategoryPage 与 Browse
  - Description: 两个页面瘦身为单一组件调用
  - Dependencies: Task 5, Task 7
  - Acceptance Criteria:
    - `src/pages/CategoryPage.tsx`：仅渲染分类标题 + `<MessageStream scope={{ kind: "category", slug }} />`，行数 < 30
    - `src/pages/Browse.tsx`：仅渲染页面标题 + `<MessageStream scope={{ kind: "all" }} />`，行数 < 30；不再读 `sort` / `tag` / `page` 参数
    - 排序 tabs 全部去掉
    - 移除对 `ResourceCard` / `ResourceListFooter` 的 import
    - `App.tsx` 路由保持 `/browse` 与 `/category/:slug` 不变
    - `npx tsc --noEmit` 通过
  - Steps:
    1. 改写 `CategoryPage.tsx`
    2. 改写 `Browse.tsx`
    3. 跑 dev server 在 `/browse` 与 `/category/<slug>` 验证

- unstarted: Task 7 — 移除收藏功能 + ResourceDetail
  - Description: 整套清理
  - Dependencies: Task 0（可与 Task 1-5 并行）
  - Acceptance Criteria:
    - 删除文件：`src/pages/FavoritesPage.tsx`、`src/pages/ResourceDetail.tsx`、`src/components/ResourceCard.tsx`、`src/components/ResourceListFooter.tsx`
    - `App.tsx` 移除 `/favorites` 路由；`/r/:id` 改为新组件 `PostRedirect`（mock 模式下从 `MOCK_POSTS` 找 slug；找不到 → navigate `/browse`）
    - `src/api.ts` 移除 `postFavoriteDelta`
    - 全代码无 `postFavoriteDelta` / `FavoritesPage` / `ResourceDetail` / `ResourceCard` / `ResourceListFooter` 引用
    - Home 中的 `/favorites` 入口（如有）移除
    - `src/i18n.tsx` 移除 `favorites` / `addFavorite` / `removeFavorite` 等收藏 key（三语言同步）
    - `npx tsc --noEmit` 通过（无 unused / 未引用错误）
  - Steps:
    1. `grep -rn "postFavoriteDelta\|FavoritesPage\|ResourceDetail\|ResourceCard\|ResourceListFooter\|/favorites\|/r/:id" src/` 列出所有引用点
    2. 写 `src/pages/PostRedirect.tsx`，挂到 `/r/:id` 路由
    3. 删除 4 个文件 + 修改 `App.tsx` + `api.ts` + `i18n.tsx`
    4. 再 grep 一次确认清零
    5. `npx tsc --noEmit`

- unstarted: Task 8 — 验证 + 文档 + API 契约抽出
  - Description: 全量验证 + 给后端的接口文档
  - Dependencies: Task 6, Task 7
  - Acceptance Criteria:
    - `npx tsc --noEmit` 通过
    - `npm run format:check` 通过（若不通过先 `npm run format`）
    - `npm test` 全绿
    - `npm run build` 成功
    - 手动视觉验证：Chrome DevTools iPhone 14 Pro 视口逐一对照 7 张参考截图
    - 新增 `.unipi/docs/specs/2026-05-25-posts-api-contract.md`：从主 spec §1-§2 抽出后端需要的所有内容（Post/Attachment schema、6 个 endpoint、删除清单、迁移要求）
    - 更新 `README.md` 增加 `VITE_USE_MOCK_POSTS` 段落
    - 不 commit、不 push（等 Terry 显式确认）
  - Steps:
    1. 跑全套检查命令
    2. dev server 手机视口检查
    3. 写 API 契约文档
    4. 改 README
    5. 报告完成，等 Terry 审阅与 commit 指令

## Risks

- **mock 数据视觉与真实数据偏差**：mock 用 picsum 占位图，可能掩盖真实图片不同宽高比的边界情况。缓解：mockPosts 中包含横图 / 竖图 / 接近正方形三种比例样本。
- **video poster 在 mock 模式不易获取**：用一张本地 SVG 占位即可，避免依赖外部链接。
- **i18n 删除收藏 key 后未使用的引用**：tsconfig 的 `noUnusedLocals` 不覆盖 i18n key 的字符串引用，需手动 grep。
- **`PostRedirect` 在真接口模式下的实现**：当前先写 mock 分支，真接口分支 TODO 注释标明等 `/api/posts/:id` ready 后补。
- **infinite scroll + URL 同步**：用户改 filter chip 时既要 reset cursor 又要更新 URL，注意避免 `setSearchParams` 触发额外 effect 循环。
- **后端最终 schema 与本 spec 偏差**：如有偏差，必须先回 spec 改契约，再调前端类型，避免散点修改。

## Out of Scope（本 plan 不涵盖，遵循 spec）

- Home 页布局调整
- Admin 后台 Post 编辑器
- 真实后端 API 实现 + 数据迁移
- 长按菜单 / 评论 / Reaction
- 桌面端多列布局
- SEO 优化
