Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jank",
"version": "26.15.0",
"version": "26.15.1",
"description": "A TweetDeck-style multi-column deck for Nostr — just another nostr klient",
"type": "module",
"author": "DocNR",
Expand Down
2 changes: 1 addition & 1 deletion src/components/Collapsible/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export default function Collapsible({
<div className="to-background/90 absolute bottom-0 z-10 flex h-40 w-full items-end justify-center bg-linear-to-b from-transparent pb-4">
<div className="bg-background rounded-lg">
<Button
className="bg-foreground hover:bg-foreground/80"
className="bg-foreground text-background hover:bg-foreground/80"
onClick={(e) => {
e.stopPropagation()
setExpanded(!expanded)
Expand Down
18 changes: 18 additions & 0 deletions src/components/NoteList/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions src/components/PostEditor/LazyPostEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import postEditor from '@/services/post-editor.service'
import { ComponentProps, lazy, Suspense, useEffect, useState } from 'react'

const PostEditor = lazy(() => import('./index'))
Expand All @@ -20,6 +21,17 @@ export default function LazyPostEditor(props: ComponentProps<typeof PostEditor>)
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,
Expand Down
18 changes: 18 additions & 0 deletions src/hooks/useAnyPostEditorOpen.ts
Original file line number Diff line number Diff line change
@@ -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
)
}
8 changes: 8 additions & 0 deletions src/release-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export type ReleaseNote = {
}

export const RELEASE_NOTES: ReleaseNote[] = [
{
version: '26.15.1',
date: '2026-06-16',
highlights: [
'Replying is no longer interrupted by a busy feed. When new notes arrive while you are writing a reply, the compose box stays open instead of occasionally closing on you; the new notes wait in the "show new notes" pill until you are done.',
'Fixed the "Show more" button on long notes showing up with hard-to-read, low-contrast text in light mode.'
]
},
{
version: '26.15.0',
date: '2026-06-16',
Expand Down
56 changes: 56 additions & 0 deletions src/services/post-editor.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
30 changes: 30 additions & 0 deletions src/services/post-editor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
Loading