Merge branch 'main' into terry-wallet-login
Some checks failed
Deploy Staging (terry-wallet-login) / deploy (push) Failing after 31s

# Conflicts:
#	src/components/messageStream/MessageStream.tsx
This commit is contained in:
TerryM
2026-06-03 14:42:07 +08:00
20 changed files with 410 additions and 19 deletions

View File

@@ -0,0 +1,35 @@
---
title: "Category page stream layout mismatch — Quick Fix"
type: quick-fix
date: 2026-06-03
---
# Category page stream layout mismatch — Quick Fix
## Bug
After clicking a card on 资料分类 (`/categories`) and landing on `/category/:slug`, the resource bubbles render with narrower bubbles / different waterfall spacing than the 全部资料 page (`/browse`). Both pages use the same `MessageStream` component, but the page-level wrapper applies extra horizontal padding only to the category route.
## Root Cause
`src/layouts/PublicLayout.tsx` chooses the `<main>` padding using a flag named `footerInContentFlow`, defined as:
```ts
const footerInContentFlow = stripLangPrefix(pathname) === "/browse";
```
That flag selects the `px-0 ... md:px-9 xl:px-0` zero-mobile-padding branch — which is the layout `MessageStream` is designed for (it manages its own inner `max-w` and centers bubbles). All other routes fall through to the default `px-4 min-[440px]:px-5 sm:px-6 md:px-9 ...`, which on mobile inset the stream by 1624 px and shrunk each bubble's `max-w` proportionally. Because `/category/:slug` rendered the same `MessageStream`, that extra inset is exactly what made the category waterfall look "off" vs `/browse`.
## Fix
Extend the same zero-padding branch to also match `/category/<slug>`, so both routes share the wrapper that `MessageStream` was designed to live in.
### Files Modified
- `src/layouts/PublicLayout.tsx` — renamed the flag's derivation to also include `/category/*`. Kept the existing `BackToTop` and footer-in-content checks (`stripLangPrefix(pathname) === "/browse"`) untouched, since those are separate features the user did not ask to share with category pages.
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format:check` — clean.
- `npm test` — 49/49 passing.
- Visual: opening `/categories` → tapping a category card now lands on a `/category/:slug` view whose `<main>` matches `/browse` (no extra mobile horizontal inset), so bubbles render with the same `max-w-[358px]` width.
## Notes
- The flag is still called `footerInContentFlow` for now even though it only controls padding, matching prior code; renaming would expand the change footprint beyond this fix.
- BackToTop and the `footerInContentFlow` footer slot remain `/browse`-only — those are independent of layout width and the user didn't ask to enable them on category pages.

View File

@@ -0,0 +1,55 @@
---
title: "FilterChips scroll position lost on filter click (remount) — Quick Fix"
type: quick-fix
date: 2026-06-03
---
# FilterChips scroll position lost on filter click (remount) — Quick Fix
## Bug
On mobile (and in general), scrolling the `FilterChips` horizontal bar to the right and then clicking a chip caused the bar to snap all the way back to the leftmost position. Earlier attempts to fix this with `useRef` + `useLayoutEffect` save/restore inside `FilterChips` did not work — the bar kept resetting.
## Root Cause
`PublicLayout` wraps the routed page in:
```tsx
<AnimatePresence mode="wait" initial={false}>
<m.div key={`${pathname}${search}`} variants={pageTransition} >
{outlet}
</m.div>
</AnimatePresence>
```
(`src/layouts/PublicLayout.tsx:761`)
When the user clicks a filter chip, `MessageStream`'s `updateParam("type", v)` mutates the `search` query (e.g. `?type=archive`). That changes the `m.div`'s `key`, so `AnimatePresence` **fully unmounts** the current page and **mounts a fresh one**. `FilterChips` is part of that page tree, so it is destroyed and re-created — every internal `useRef` resets to its initial value, which is why the previous in-component save/restore approach silently failed.
A secondary symptom (already present before this fix) was an iOS Safari quirk where the sibling `ScrollToTop`'s `window.scrollTo({top:0, left:0})` triggers a relayout of the sticky bar that asynchronously sets the inner `overflow-x` `scrollLeft` back to 0.
## Fix
Move the saved `scrollLeft` to a **module-level** variable so it survives the unmount/remount cycle, and restore it on first paint after every fresh mount. Combine with the existing user-input-only save logic and an iOS post-mount watch window to defeat the asynchronous reset.
Key points in the new `FilterChips`:
1. `let lastScrollLeft = 0;` declared at module scope.
2. `useLayoutEffect(() => { … }, [])` on mount: if `lastScrollLeft > 0`, restore `scrollLeft` synchronously before paint.
3. `useEffect(() => { … }, [])` saves `lastScrollLeft` only on `touchend` / `pointerup` / `wheel` (deferred by one rAF so iOS momentum has settled). We deliberately do **not** save on raw `scroll` events because iOS Safari's quirky 0-reset fires one of those too.
4. A second `useEffect(() => { … }, [])` runs a ~1.5 s post-mount watch: rAF tick plus a `scroll` listener that re-applies the saved value **only when `scrollLeft` snaps to 0**, and only when the user is not actively touching the bar (so a fresh scroll gesture in that window isn't yanked back).
### Files Modified
- `src/components/messageStream/FilterChips.tsx`
- Added module-level `lastScrollLeft`.
- Added `useLayoutEffect` to restore on mount.
- Kept the user-input-only save effect.
- Kept the ~1.5 s iOS-quirk watch (now keyed to mount instead of `[type]` since the component remounts anyway).
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format:check` — clean.
- `npm test` — 49/49 passing.
- Expected behavior on device: scroll the filter bar to the right, tap any chip (e.g. 压缩包) — bar should stay exactly where the user left it, with the new active chip already highlighted in gold.
## Notes
- Module-level state is intentional and acceptable here: there is only ever one `FilterChips` mounted at a time, and the value semantically belongs to the user's session, not the component instance.
- If this `AnimatePresence` `key` strategy changes (or `FilterChips` is reused outside `MessageStream` in a context with multiple instances), revisit this — the module value would then need to be scoped per surface (e.g. via a Map keyed by surface id).
- Previous attempts using `useRef` inside `FilterChips` failed because they ignored the remount caused by `AnimatePresence`. That is now documented inline in the component.

View File

@@ -0,0 +1,35 @@
---
title: "FilterChips simplify — strip comments and remove 1.5s watch window"
type: quick-fix
date: 2026-06-03
---
# FilterChips simplify — strip comments and remove 1.5s watch window
## Bug
The previous `FilterChips` carried too much defensive machinery (long inline comments, a 1.5s post-mount `requestAnimationFrame` + `scroll`-event watch window that re-applied `lastScrollLeft` whenever the bar snapped to 0). The watch window could interfere with a fresh slide gesture right after a filter tap, and the surrounding prose made the component hard to read.
## Root Cause
Over-engineering: the iOS-quirk watch loop and its `touching` guard were added defensively but were not strictly needed once `lastScrollLeft` was lifted out to a module-level slot and restored synchronously in `useLayoutEffect`. The extra event listeners were also a source of friction when the user immediately slid the bar after tapping a chip.
## Fix
Reduce `FilterChips` to just the essentials:
- Module-level `lastScrollLeft` (kept) — survives the `AnimatePresence` remount that happens when `?type=…` changes.
- `useLayoutEffect` on mount (kept) — restores `scrollLeft = lastScrollLeft` before paint so the new instance starts where the user left off.
- Desktop wheel-to-horizontal handler (kept) — necessary for mice without horizontal wheels.
- Save effect on `touchend` / `pointerup` / `wheel` (kept) — captures the user's final scroll position, deferred by one rAF so iOS momentum settles before recording.
- Removed: the ~1.5s rAF + `scroll` watch loop and its `touching` flag.
- Removed: all explanatory inline comments — the code is short enough to be self-evident now.
### Files Modified
- `src/components/messageStream/FilterChips.tsx` — stripped the 1.5s watch effect and every prose comment; kept the mount-restore and user-input save flow.
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format:check` — clean.
- `npm test` — 49/49 passing.
- Behavior expected on device: tapping a filter highlights it in gold; sliding the bar after the tap is no longer pulled back to the previous position.
## Notes
- If the iOS sticky/scroll-to-top quirk actually re-surfaces in production, the fallback would be to move the bar out of the `AnimatePresence`-keyed subtree (so it never unmounts), rather than re-introducing the watch loop.

View File

@@ -0,0 +1,36 @@
---
title: "官方物料 cards — bottom-left filename overlay"
type: quick-work
date: 2026-06-03
---
# 官方物料 cards — bottom-left filename overlay
## Task
In every content card classified as 官方物料 (`categorySlug === "official-assets"`), display the source filename at the bottom-left of the card — image filename for image bubbles, video filename for video bubbles.
## Data Source — Confirmed
- Endpoint: `/api/posts` (list, called by `src/components/messageStream/hooks/usePostStream.ts:89`) and `/api/posts/:id` (single, used by `MessageStream` deep-links).
- Field: `Post.attachments[*].filename` (`src/types/post.ts`).
- Category gate: `Post.categorySlug === "official-assets"` (also referenced in `src/lib/categorySvgSlug.ts:13`, `src/pages/Categories/index.tsx:23`, `src/pages/Home/index.tsx:33`).
- No additional API call is needed — `filename` already ships with the post payload.
## Changes
- `src/components/messageStream/AttachmentFilenameLabel.tsx` (new) — small dark pill overlay using `filenameWithExtension(att.filename, att.mime)`, positioned `absolute bottom-2 left-2`, with `pointer-events-none`, `max-w-[calc(100%-1rem)]`, `truncate`, and a `title` attribute so the full filename is available on hover. Style mirrors the `AttachmentDownloadPill` (`bg-black/80` + `ring-white/20` + `backdrop-blur-md`) for visual consistency.
- `src/components/messageStream/bubbles/SingleImageFrame.tsx` — accepts new `showFilename?: boolean` prop; when true, renders `AttachmentFilenameLabel` alongside the existing top-left download pill.
- `src/components/messageStream/bubbles/ImageBubble.tsx` — passes `showFilename={post.categorySlug === "official-assets"}`.
- `src/components/messageStream/bubbles/ImageWithTextBubble.tsx` — same gate, threaded through `SingleImageFrame`.
- `src/components/messageStream/bubbles/AlbumBubble.tsx` — when category matches, renders the filename label inside each visible tile (skipping the `+N` overflow tile so its overlay stays clean).
- `src/components/messageStream/bubbles/VideoBubble.tsx``VideoAttachmentCard` accepts `showFilename`, threaded into both the multi-video grid layout and the single-video layout. Skipped on the `+N` overflow tile.
## Verification
- `npx tsc --noEmit` — clean.
- `npm run format` then `npm run format:check` — clean.
- `npm test` — 49/49 passing.
- Visual confirmation pending on a posts feed that contains `official-assets` content.
## Notes
- Reuses existing `filenameWithExtension` util so attachments without an extension still get a sensible label from their MIME type.
- The label is `pointer-events-none` so it never blocks the bubble's own tap target (open lightbox / play video).
- Only single/album/video bubbles surface this. `FileDocBubble` already renders the filename inline, so no overlay is needed there.
- If a future requirement adds more "show filename" categories, switch `showFilename` from a boolean gate to a derived util (e.g. `shouldShowAttachmentFilename(post)`).