From 125d470a3de43b00f97be1851333f48e497bd0e9 Mon Sep 17 00:00:00 2001 From: DocNR Date: Tue, 16 Jun 2026 22:58:19 -0400 Subject: [PATCH 1/3] fix(feed): keep reply composer open when live notes arrive The reply composer's open state lives in ReplyButton, which renders inside a virtua-virtualized NoteCard row. When a live note arrived while the user was at/near the top of a feed, NoteList.handleNewEvents prepended it and forced scrollToTop, re-laying out the virtual list and jumping the scroll. The row owning the open dialog could fall out of virtua's render window and unmount, destroying its open state and closing the composer out from under the user. The Radix modal blocks user scroll of the feed behind it but not this programmatic scroll, so live arrivals were the one thing that could disturb the feed mid-compose. Track open composers app-wide via a refcount on post-editor.service (registered at the LazyPostEditor chokepoint, before the lazy chunk resolves). While any composer is open, NoteList buffers live arrivals into the existing "new notes" pill instead of mutating/scrolling the rendered timeline, so the owning row stays mounted and the dialog stays open. The pill flushes when the composer closes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/NoteList/index.tsx | 18 +++++++ src/components/PostEditor/LazyPostEditor.tsx | 12 +++++ src/hooks/useAnyPostEditorOpen.ts | 18 +++++++ src/services/post-editor.service.spec.ts | 56 ++++++++++++++++++++ src/services/post-editor.service.ts | 30 +++++++++++ 5 files changed, 134 insertions(+) create mode 100644 src/hooks/useAnyPostEditorOpen.ts create mode 100644 src/services/post-editor.service.spec.ts diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index a41137d..a624b7f 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1,6 +1,7 @@ import NewNotesButton from '@/components/NewNotesButton' import { Button } from '@/components/ui/button' import { FUTURE_EVENT_TOLERANCE_SECONDS, NOTIFICATION_LIST_STYLE } from '@/constants' +import { useAnyPostEditorOpen } from '@/hooks/useAnyPostEditorOpen' import { useColumnVisible } from '@/hooks/useColumnVisible' import { useInfiniteScroll } from '@/hooks/useInfiniteScroll' import { isInMutedThread, isMentioningMutedUsers } from '@/lib/event' @@ -170,6 +171,13 @@ const NoteList = forwardRef< }, [events, newEvents]) const showNewNotesDirectlyRef = useRef(showNewNotesDirectly) showNewNotesDirectlyRef.current = showNewNotesDirectly + // While a composer is open, the live-arrival handler must not mutate or + // scroll the rendered timeline (it would unmount the NoteCard row that owns + // an open reply dialog and close it). Read via a ref so the subscription + // effect's closure sees the current value without resubscribing. + const anyComposerOpen = useAnyPostEditorOpen() + const composerOpenRef = useRef(anyComposerOpen) + composerOpenRef.current = anyComposerOpen const scrollContainerRef = useScrollContainer() const pinnedEventHexIdSet = useMemo(() => { @@ -420,6 +428,16 @@ const NoteList = forwardRef< ) const handleNewEvents = (newEvents: Event[]) => { + // A composer is open somewhere: never disturb the rendered timeline. + // Prepending (and the scrollToTop below) re-lays out the virtual list + // and can unmount the row that owns an open reply dialog, closing it + // out from under the user. Buffer into the "new notes" pill instead; + // it's revealed when they finish composing. Overrides + // showNewNotesDirectly too — the steadiness matters more here. + if (composerOpenRef.current) { + setNewEvents((oldEvents) => mergeTimelines([newEvents, oldEvents])) + return + } if (showNewNotesDirectlyRef.current) { setEvents((oldEvents) => mergeTimelines([newEvents, oldEvents])) } else { diff --git a/src/components/PostEditor/LazyPostEditor.tsx b/src/components/PostEditor/LazyPostEditor.tsx index 2a0abb4..68d2096 100644 --- a/src/components/PostEditor/LazyPostEditor.tsx +++ b/src/components/PostEditor/LazyPostEditor.tsx @@ -1,3 +1,4 @@ +import postEditor from '@/services/post-editor.service' import { ComponentProps, lazy, Suspense, useEffect, useState } from 'react' const PostEditor = lazy(() => import('./index')) @@ -20,6 +21,17 @@ export default function LazyPostEditor(props: ComponentProps) if (props.open) setMounted(true) }, [props.open]) + // Tell feeds a composer is open so they hold their timeline steady (see + // post-editor.service `openCount`). Registered here rather than inside the + // lazy PostEditor so it fires the moment `open` flips true — before the + // Suspense chunk resolves — closing the race where a live note arrives + // during first-compose chunk load and churns the row that owns this dialog. + useEffect(() => { + if (!props.open) return + postEditor.registerOpen() + return () => postEditor.unregisterOpen() + }, [props.open]) + if (!mounted) return null // Fallback is null: the editor is a modal/sheet that owns its own backdrop, diff --git a/src/hooks/useAnyPostEditorOpen.ts b/src/hooks/useAnyPostEditorOpen.ts new file mode 100644 index 0000000..6127b67 --- /dev/null +++ b/src/hooks/useAnyPostEditorOpen.ts @@ -0,0 +1,18 @@ +import postEditor from '@/services/post-editor.service' +import { useSyncExternalStore } from 'react' + +/** + * True while any composer dialog/sheet is open anywhere in the app. Feeds use + * this to keep their virtualized timeline steady while the user is composing — + * see post-editor.service `openCount` for the why (a re-laid-out feed can + * unmount the NoteCard row that owns an open reply dialog). + */ +export function useAnyPostEditorOpen() { + return useSyncExternalStore( + (onChange) => { + postEditor.addEventListener('openStateChange', onChange) + return () => postEditor.removeEventListener('openStateChange', onChange) + }, + () => postEditor.isAnyOpen + ) +} diff --git a/src/services/post-editor.service.spec.ts b/src/services/post-editor.service.spec.ts new file mode 100644 index 0000000..21b5c28 --- /dev/null +++ b/src/services/post-editor.service.spec.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import postEditor from './post-editor.service' + +// post-editor.service is a shared singleton; drain any open count between tests +// so leaked state from one case can't bleed into the next. +afterEach(() => { + while (postEditor.isAnyOpen) { + postEditor.unregisterOpen() + } +}) + +describe('post-editor.service open tracking', () => { + it('starts closed', () => { + expect(postEditor.isAnyOpen).toBe(false) + }) + + it('reports open after a register', () => { + postEditor.registerOpen() + expect(postEditor.isAnyOpen).toBe(true) + }) + + it('refcounts nested opens — stays open until the last close', () => { + postEditor.registerOpen() + postEditor.registerOpen() + expect(postEditor.isAnyOpen).toBe(true) + + postEditor.unregisterOpen() + expect(postEditor.isAnyOpen).toBe(true) // one composer still open + + postEditor.unregisterOpen() + expect(postEditor.isAnyOpen).toBe(false) + }) + + it('never underflows below closed', () => { + postEditor.unregisterOpen() + postEditor.unregisterOpen() + expect(postEditor.isAnyOpen).toBe(false) + }) + + it('fires openStateChange only on the closed<->open boundary', () => { + const onChange = vi.fn() + postEditor.addEventListener('openStateChange', onChange) + + postEditor.registerOpen() // closed -> open: fires + postEditor.registerOpen() // open -> still open: no fire + expect(onChange).toHaveBeenCalledTimes(1) + + postEditor.unregisterOpen() // still open: no fire + expect(onChange).toHaveBeenCalledTimes(1) + + postEditor.unregisterOpen() // open -> closed: fires + expect(onChange).toHaveBeenCalledTimes(2) + + postEditor.removeEventListener('openStateChange', onChange) + }) +}) diff --git a/src/services/post-editor.service.ts b/src/services/post-editor.service.ts index 607b6a3..469490a 100644 --- a/src/services/post-editor.service.ts +++ b/src/services/post-editor.service.ts @@ -3,6 +3,16 @@ class PostEditorService extends EventTarget { isSuggestionPopupOpen = false + // Number of composer dialogs/sheets currently open across the whole app. + // Reply composers live INSIDE virtualized NoteCard rows, so anything that + // re-lays out or scrolls a feed (e.g. NoteList auto-prepending a live + // arrival + scrollToTop) can unmount the owning row and close the dialog out + // from under the user. Feeds read `isAnyOpen` to hold the timeline steady + // while a composer is open, buffering arrivals into the "new notes" pill + // instead. Refcounted because multiple composers can be mounted at once + // (per-column reply buttons, top-level compose, quote-repost). + private openCount = 0 + constructor() { super() if (!PostEditorService.instance) { @@ -11,6 +21,26 @@ class PostEditorService extends EventTarget { return PostEditorService.instance } + get isAnyOpen() { + return this.openCount > 0 + } + + registerOpen() { + this.openCount += 1 + // Only the closed -> open boundary changes what consumers see. + if (this.openCount === 1) { + this.dispatchEvent(new CustomEvent('openStateChange')) + } + } + + unregisterOpen() { + if (this.openCount === 0) return + this.openCount -= 1 + if (this.openCount === 0) { + this.dispatchEvent(new CustomEvent('openStateChange')) + } + } + closeSuggestionPopup() { if (this.isSuggestionPopupOpen) { this.isSuggestionPopupOpen = false From 0a60f0420faac8c1453a0e7296730f80c959fa28 Mon Sep 17 00:00:00 2001 From: DocNR Date: Tue, 16 Jun 2026 22:58:29 -0400 Subject: [PATCH 2/3] fix(ui): correct Show more button contrast in light mode The Collapsible "Show more" button overrode its background to bg-foreground but kept the default Button variant's text-primary-foreground. Those tokens aren't a contrasting pair: in light mode both resolve to near-black (bg hsl(240 10% 3.9%), text hsl(186 90% 8%)), giving unreadable dark-on-dark text. It only looked fine in dark mode because bg-foreground flips to near-white there. Set the text to text-background, the correct inverse of foreground, so the inverted button reads in both themes (white text in light mode, near-black text in dark mode). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/Collapsible/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Collapsible/index.tsx b/src/components/Collapsible/index.tsx index 05321e0..0df6568 100644 --- a/src/components/Collapsible/index.tsx +++ b/src/components/Collapsible/index.tsx @@ -60,7 +60,7 @@ export default function Collapsible({