Browse Source

bug-fix janky feeds

imwald
Silberengel 2 weeks ago
parent
commit
2bf261efb2
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 123
      src/PageManager.tsx
  4. 14
      src/components/PostEditor/PostContent.tsx
  5. 32
      src/components/PostEditor/PostTextarea/index.tsx
  6. 25
      src/components/PostEditor/index.tsx
  7. 6
      src/index.css

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.19.0", "version": "23.19.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.19.0", "version": "23.19.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.19.0", "version": "23.19.1",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

123
src/PageManager.tsx

@ -479,31 +479,7 @@ export function useSmartNoteNavigation() {
} }
const { noteId } = parsed const { noteId } = parsed
navigationEventStore.clear() primeNoteNavigationCache(noteId, event, relatedEvents)
if (event) {
navigationEventStore.setEvent(event, noteId)
client.addEventToCache(event)
await prefetchThreadContextForNavigation(event).then((prefetched) => {
for (const ev of prefetched) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
})
}
// Pre-cache related events (parent, root) and nostr embeds so NotePage avoids skeletons.
if (relatedEvents?.length) {
for (const ev of relatedEvents) {
if (ev && ev !== event) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
}
}
if (event) {
client.prefetchEmbeddedEventsForParents(
[event, ...(relatedEvents ?? []).filter((ev) => ev && ev !== event)]
)
}
// Build contextual URL based on current page // Build contextual URL based on current page
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
@ -552,25 +528,7 @@ export function useSmartNoteNavigationOptional() {
return return
} }
const { noteId } = parsed const { noteId } = parsed
navigationEventStore.clear() primeNoteNavigationCache(noteId, event, relatedEvents)
if (event) {
navigationEventStore.setEvent(event, noteId)
client.addEventToCache(event)
await prefetchThreadContextForNavigation(event).then((prefetched) => {
for (const ev of prefetched) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
})
}
if (relatedEvents?.length) {
for (const ev of relatedEvents) {
if (ev && ev !== event) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
}
}
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage) const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)
if (isSmallScreen) { if (isSmallScreen) {
push(contextualUrl) push(contextualUrl)
@ -2010,6 +1968,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
if (isCurrentPage(secondaryStackRef.current, url)) { if (isCurrentPage(secondaryStackRef.current, url)) {
const top = secondaryStackRef.current[secondaryStackRef.current.length - 1] const top = secondaryStackRef.current[secondaryStackRef.current.length - 1]
if (top && !top.component) {
const restored = ensureStackItemComponent(top)
if (restored.component) {
const next = [...secondaryStackRef.current.slice(0, -1), restored]
secondaryStackRef.current = next
setSecondaryStack(next)
}
}
if (isSmallScreen && top) { if (isSmallScreen && top) {
window.history.pushState({ index: top.index, url }, '', url) window.history.pushState({ index: top.index, url }, '', url)
} }
@ -2018,8 +1984,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
recentSecondaryPushRef.current = { url, at: now } recentSecondaryPushRef.current = { url, at: now }
noteStatsService.setBackgroundStatsPaused(true) // Mobile overlays the feed — keep stats/live updates on the visible timeline.
if (!isSmallScreen) { if (!isSmallScreen) {
noteStatsService.setBackgroundStatsPaused(true)
client.interruptBackgroundQueries() client.interruptBackgroundQueries()
} }
@ -2066,6 +2033,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isCurrentPage(prevStack, url)) { if (isCurrentPage(prevStack, url)) {
const top = prevStack[prevStack.length - 1] const top = prevStack[prevStack.length - 1]
if (top && !top.component) {
const restored = ensureStackItemComponent(top)
if (restored.component) {
if (isSmallScreen) {
window.history.pushState({ index: restored.index, url }, '', url)
}
return [...prevStack.slice(0, -1), restored]
}
}
if (isSmallScreen && top) { if (isSmallScreen && top) {
window.history.pushState({ index: top.index, url }, '', url) window.history.pushState({ index: top.index, url }, '', url)
} }
@ -2280,8 +2256,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const mobileSecondaryOverlaysFeed = const mobileSecondaryOverlaysFeed =
isSmallScreen && secondaryStack.length > 0 && primaryNoteView == null isSmallScreen && secondaryStack.length > 0 && primaryNoteView == null
const primaryFeedStillVisible =
panelMode === 'double' || !primaryObscured || mobileSecondaryOverlaysFeed
useLayoutEffect(() => { useLayoutEffect(() => {
noteStatsService.setBackgroundStatsPaused(primaryFrozen) const pauseBackgroundStats = primaryObscured && !primaryFeedStillVisible
noteStatsService.setBackgroundStatsPaused(pauseBackgroundStats)
if (primaryFrozen) { if (primaryFrozen) {
extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS) extendProfileNetworkDeferral(PROFILE_SECONDARY_PANEL_DEFER_MS)
// Keep in-flight REQ on double-pane and mobile feed overlay; interrupt only when primary is unmounted. // Keep in-flight REQ on double-pane and mobile feed overlay; interrupt only when primary is unmounted.
@ -2290,7 +2270,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
client.interruptBackgroundQueries() client.interruptBackgroundQueries()
} }
} }
}, [primaryFrozen, isSmallScreen, panelMode, primaryNoteView]) }, [primaryObscured, primaryFeedStillVisible, isSmallScreen, panelMode, primaryNoteView])
const primaryPageContextValue = useMemo( const primaryPageContextValue = useMemo(
(): PrimaryPageContextValue => ({ (): PrimaryPageContextValue => ({
@ -2581,12 +2561,57 @@ export function SecondaryPageLink({
) )
} }
/** Re-mount a stack frame when LRU eviction cleared `component` (otherwise the panel is blank). */
function ensureStackItemComponent(item: TStackItem): TStackItem {
if (item.component) return item
const { component, ref } = findAndCreateComponent(item.url, item.index)
if (!component) return item
return { ...item, component, ref }
}
function primeNoteNavigationCache(
noteId: string,
event?: Event,
relatedEvents?: Event[]
): void {
navigationEventStore.clear()
if (event) {
navigationEventStore.setEvent(event, noteId)
client.addEventToCache(event)
void prefetchThreadContextForNavigation(event).then((prefetched) => {
for (const ev of prefetched) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
})
}
if (relatedEvents?.length) {
for (const ev of relatedEvents) {
if (ev && ev !== event) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
}
}
if (event) {
void client.prefetchEmbeddedEventsForParents(
[event, ...(relatedEvents ?? []).filter((ev) => ev && ev !== event)]
)
}
}
function isCurrentPage(stack: TStackItem[], url: string) { function isCurrentPage(stack: TStackItem[], url: string) {
const currentPage = stack[stack.length - 1] const currentPage = stack[stack.length - 1]
if (!currentPage) return false if (!currentPage) return false
logger.component('PageManager', 'isCurrentPage check', { currentUrl: currentPage.url, newUrl: url, match: currentPage.url === url }) const match =
return currentPage.url === url currentPage.url === url || secondaryPanelUrlsMatch(currentPage.url, url)
logger.component('PageManager', 'isCurrentPage check', {
currentUrl: currentPage.url,
newUrl: url,
match
})
return match
} }
/** Route elements are `<Suspense><LazyPage /></Suspense>` — props must be applied to the lazy leaf, not Suspense. */ /** Route elements are `<Suspense><LazyPage /></Suspense>` — props must be applied to the lazy leaf, not Suspense. */

14
src/components/PostEditor/PostContent.tsx

@ -2538,8 +2538,10 @@ export default function PostContent({
<NeventPickerProvider> <NeventPickerProvider>
<div <div
className={cn( className={cn(
'space-y-2 min-w-0', 'min-w-0',
isSmallScreen && 'min-h-0 flex-1 overflow-y-auto overscroll-y-contain' isSmallScreen
? 'flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-y-contain'
: 'space-y-2'
)} )}
> >
{/* Dynamic Title based on mode */} {/* Dynamic Title based on mode */}
@ -3377,6 +3379,7 @@ export default function PostContent({
</div> </div>
)} )}
<div className={cn(isSmallScreen && 'flex min-h-0 min-w-0 flex-1 flex-col')}>
<PostTextarea <PostTextarea
ref={textareaRef} ref={textareaRef}
text={text} text={text}
@ -3385,7 +3388,11 @@ export default function PostContent({
parentEvent={isDiscussionThread && !parentEvent ? THREAD_POST_EDITOR_PARENT : parentEvent} parentEvent={isDiscussionThread && !parentEvent ? THREAD_POST_EDITOR_PARENT : parentEvent}
onSubmit={() => post()} onSubmit={() => post()}
className={cn( className={cn(
isPoll ? 'min-h-20' : isSmallScreen ? 'min-h-36' : 'min-h-52', isPoll
? 'min-h-20'
: isSmallScreen
? 'min-h-[min(42vh,20rem)]'
: 'min-h-52',
isDiscussionThread && threadErrors.content && 'border-destructive' isDiscussionThread && threadErrors.content && 'border-destructive'
)} )}
onUploadStart={handleUploadStart} onUploadStart={handleUploadStart}
@ -3644,6 +3651,7 @@ export default function PostContent({
})() })()
} }
/> />
</div>
{isDiscussionThread && !parentEvent && ( {isDiscussionThread && !parentEvent && (
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
{threadErrors.content && <p className="text-sm text-destructive">{threadErrors.content}</p>} {threadErrors.content && <p className="text-sm text-destructive">{threadErrors.content}</p>}

32
src/components/PostEditor/PostTextarea/index.tsx

@ -226,18 +226,6 @@ const PostTextarea = forwardRef<
}) })
}, [editor, editorSurfaceClass]) }, [editor, editorSurfaceClass])
useEffect(() => {
if (!editor || !isSmallScreen) return
const scrollEditorIntoView = () => {
requestAnimationFrame(() => {
editor.view.dom.scrollIntoView({ block: 'nearest', inline: 'nearest' })
})
}
editor.on('focus', scrollEditorIntoView)
return () => {
editor.off('focus', scrollEditorIntoView)
}
}, [editor, isSmallScreen])
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
appendText: (text: string, addNewline = false) => { appendText: (text: string, addNewline = false) => {
@ -327,8 +315,14 @@ const PostTextarea = forwardRef<
) )
return ( return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2"> <Tabs
<div className="flex min-w-0 flex-col gap-2"> value={activeTab}
onValueChange={setActiveTab}
className={cn(
isSmallScreen ? 'flex min-h-0 flex-1 flex-col gap-2' : 'space-y-2'
)}
>
<div className="flex min-w-0 shrink-0 flex-col gap-2">
<TabsList className="w-auto shrink-0 justify-start"> <TabsList className="w-auto shrink-0 justify-start">
<TabsTrigger value="edit" title={t('Edit')}> <TabsTrigger value="edit" title={t('Edit')}>
{t('Edit')} {t('Edit')}
@ -346,10 +340,16 @@ const PostTextarea = forwardRef<
<TabsContent <TabsContent
value="edit" value="edit"
forceMount forceMount
className="mt-0 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0" className={cn(
'mt-0 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0',
isSmallScreen && 'flex min-h-0 flex-1 flex-col'
)}
> >
{editor ? ( {editor ? (
<EditorContent className="tiptap" editor={editor} /> <EditorContent
className={cn('tiptap', isSmallScreen && 'flex min-h-0 flex-1 flex-col')}
editor={editor}
/>
) : ( ) : (
<div <div
className={cn(editorShellClass, 'text-muted-foreground')} className={cn(editorShellClass, 'text-muted-foreground')}

25
src/components/PostEditor/index.tsx

@ -18,7 +18,7 @@ import { pubkeyToNpub } from '@/lib/pubkey'
import postEditor from '@/services/post-editor.service' import postEditor from '@/services/post-editor.service'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import postEditorService from '@/services/post-editor.service' import postEditorService from '@/services/post-editor.service'
import { Dispatch, useEffect, useMemo } from 'react' import { Dispatch, useEffect, useMemo, useRef, useState } from 'react'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import type { TDiscussionDynamicTopics } from '@/lib/discussion-thread-composer' import type { TDiscussionDynamicTopics } from '@/lib/discussion-thread-composer'
import PostContent from './PostContent' import PostContent from './PostContent'
@ -49,6 +49,20 @@ export default function PostEditor({
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { isAccountSessionHydrating, isNip07LoginInFlight } = useNostr() const { isAccountSessionHydrating, isNip07LoginInFlight } = useNostr()
/** Lock sheet height at open so the mobile keyboard does not resize/jank the composer. */
const [mobileSheetHeightPx, setMobileSheetHeightPx] = useState<number | null>(null)
const wasOpenRef = useRef(false)
useEffect(() => {
if (open && isSmallScreen && !wasOpenRef.current) {
const vh = window.visualViewport?.height ?? window.innerHeight
setMobileSheetHeightPx(Math.round(vh))
}
if (!open) {
setMobileSheetHeightPx(null)
}
wasOpenRef.current = open
}, [open, isSmallScreen])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@ -98,7 +112,12 @@ export default function PostEditor({
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetContent <SheetContent
className="flex h-[var(--vh,100dvh)] max-h-[var(--vh,100dvh)] w-full max-w-full flex-col p-0 border-none overflow-hidden data-[state=open]:duration-200 data-[state=closed]:duration-200" className="flex w-full max-w-full flex-col p-0 border-none overflow-hidden data-[state=open]:duration-200 data-[state=closed]:duration-200"
style={
mobileSheetHeightPx != null
? { height: mobileSheetHeightPx, maxHeight: mobileSheetHeightPx }
: { height: 'var(--vh, 100dvh)', maxHeight: 'var(--vh, 100dvh)' }
}
side="bottom" side="bottom"
hideClose hideClose
onInteractOutside={(e) => { onInteractOutside={(e) => {
@ -114,7 +133,7 @@ export default function PostEditor({
} }
}} }}
> >
<div className="flex min-h-0 flex-1 flex-col px-4 pt-4 pb-2 min-w-0"> <div className="flex min-h-0 flex-1 flex-col px-4 pt-3 pb-2 min-w-0 overflow-hidden">
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
<SheetTitle>Post Editor</SheetTitle> <SheetTitle>Post Editor</SheetTitle>
<SheetDescription>Create a new post or reply</SheetDescription> <SheetDescription>Create a new post or reply</SheetDescription>

6
src/index.css

@ -102,6 +102,12 @@
sans-serif; sans-serif;
} }
@media (max-width: 768px) {
.tiptap .ProseMirror {
min-height: min(42vh, 20rem);
}
}
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; /* Internet Explorer 10+ */ -ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */

Loading…
Cancel
Save