Avatar rendered a bare <img> with no error handling, so an avatar_url whose
storage object is unreachable (e.g. a 404 after the server move) showed a
broken image instead of the coloured letter-circle fallback. Track an onError
flag and fall back to the circle; reset it when the URL changes so a fresh
valid avatar is retried. This is client-side resilience only — it does not
restore a genuinely missing storage object.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make stick-to-bottom intent the single source of truth in MessageList and
drive onAtBottomChange from intent, not raw scroll position. A measurement
reflow can no longer flip the intent off (RC1), the second scrollPositions
writer no longer persists a drifting topmost index while stuck (RC2), and a
pin-on-rows layout effect re-pins through the two-phase data swap (RC3).
- scrollController: add tested nextStickIntent() state machine
- MessageList: input-event-based unstick (wheel/key/touch + scrollbar drag),
reveal after 2 stable frames, tabIndex for keyboard nav, remove debug overlay
- ConversationPage: reuse resolveInitialAnchor; harden handleRangeChanged
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Data from the on-screen overlay showed SCROLLABLE=YES but scrollTop=84/0 and atBottom=false: the initial pin happened before rows finished measuring, then a measurement reflow fired onScroll with the stale (top) scrollTop, flipping atBottom=false and disabling re-pinning, so the list never reached the bottom. Fix: a ResizeObserver re-pins to the true bottom as the content measures/grows; a programmatic-scroll guard makes onScroll ignore the scrolls we cause (so measurement reflows no longer flip the stick intent); overflow-anchor:none so the browser doesn't fight us; reveal waits for the height to settle. Overlay kept for one more verification pass.
scrollToIndex raced the virtualizer's own layout effect and depended on size estimates, leaving the list pinned at the top on open (and flickering as it settled). Drive scrollTop = scrollHeight directly for the bottom case (order-independent, true bottom) and re-pin on measure; switch the scroll root from h-full to flex-1 min-h-0 so it always has a bounded, scrollable height.
Audit of write-actions revealed that send (and edit via realtime UPDATE) are
already optimistic via local state insertion in `useConversationMessages`,
and friend nicknames are pure-local localStorage. Five user-write actions
were waiting on the ~100-200 ms server roundtrip + realtime echo:
* Toggle mute (`setConversationMutedUntil`)
* Toggle mentions-only (`setConversationMentionsOnly`)
* Toggle archive (`setConversationArchived`)
* Pin / unpin message (`pinMessage` / `unpinMessage`)
* Revoke device (`revokeDevice` RPC)
All five now flip local state synchronously and roll back on failure. The
existing realtime subscriptions reconcile canonically (no-op when the
optimistic patch already matches the server row), so this is purely a UX
latency improvement — no protocol or persistence changes.
Reactions (`toggleReaction` / `voteExclusive`) were intentionally skipped
this round: rollback semantics for the exclusive-vote path with multiple
sequential awaits are messy enough to warrant a dedicated pass.
Adds opt-in (default OFF) auto-lock: after X minutes of no user input
the app calls signOut() (full memory wipe + PIN re-entry on next open).
Settings dropdown (Aus / 5 / 15 / 30 / 60 min) lives in SecurityCenter
below the existing wipe-on-close toggle. The idle timer is mounted in
AppShell via useIdleAutoLock; activity events are throttled to 1 Hz to
avoid timer thrash on rapid mouse movement. The localStorage key is
added to PRESERVE_LOCAL_STORAGE so a wipe never silently disables the
feature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fire a no-await profiles query in AuthContext on session establish to absorb
cold-connection latency before the first user-triggered request. Add
loading='lazy' default to the central Avatar component so all off-screen
avatars (chat list, friends list, popovers, message senders) skip eager
Supabase Storage fetches; set loading='eager' on ConversationHeader (active
conv header) and CallParticipantTile inline imgs (both AudioContent and
VideoStub) which are always above-the-fold when visible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All 57 exported SVG icon components in icons.tsx are now memoised via
React.memo, giving React permission to skip re-renders when props are
referentially equal. Consumer icon-prop types updated from the legacy
SVGProps (includes string refs) to ComponentPropsWithoutRef<'svg'> so
the MemoExoticComponent return type satisfies TypeScript without casts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Update globals.css reduced-motion block: 0.01ms → 0.001ms durations
and add scroll-behavior: auto to suppress all transitions/animations
when OS reduced-motion preference is active.
- Gate canvas-confetti burst in GameModal behind matchMedia check so
the particle effect is skipped entirely for users who opt out of motion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mount useSoundboardSync in SoundboardManagerDialog, thread badges map through
SoundboardCategoryGroup/SoundboardRow, render a cloud-state glyph badge inline
with each row's size/mime metadata, and wrap handleDelete to attempt a
best-effort remote delete via deleteRemoteSound before the local deleteSound call.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a whiteboard branch to the MessageBubble ternary chain that renders
a card with an icon, label, and Öffnen button dispatching the
chatapp:open-whiteboard CustomEvent. ConversationPage now listens for
that event via a useEffect and calls setOpenWhiteboardId to open the
modal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shrink the StripToggleIcon SVG from 18x18 to 14x14 px so the 2-people
icon sits centred with ~11 px of breathing room inside its 36x36 button,
matching the h-4/w-4 small-icon convention used elsewhere in InCallPanel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Google closed Tenor v2 to new API clients in Jan 2026, so the only
people who could use the picker were those with a pre-existing Google
Cloud Console key. Swapped to GIPHY's Developer API (still open, free
keys at https://developers.giphy.com/dashboard/).
- Env var renamed VITE_TENOR_API_KEY → VITE_GIPHY_API_KEY
- Endpoint, response mapping, error sentinel updated
- File still named tenor.ts for import-path stability — renaming
later if it bothers anyone
- Public GifResult interface unchanged so the picker UI didn't need
edits beyond the error-message switch