diff --git a/package-lock.json b/package-lock.json index 2d430739..fcc5138e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.17.5", + "version": "23.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.17.5", + "version": "23.18.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 6e02c753..4b5bcc2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.17.5", + "version": "23.18.0", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/FeedFilterToolbarRow/index.tsx b/src/components/FeedFilterToolbarRow/index.tsx new file mode 100644 index 00000000..b402f3cf --- /dev/null +++ b/src/components/FeedFilterToolbarRow/index.tsx @@ -0,0 +1,44 @@ +import KindFilter from '@/components/KindFilter' +import { RefreshButton } from '@/components/RefreshButton' +import { cn } from '@/lib/utils' +import type { Ref } from 'react' + +/** Sticky/subheader chrome around the feed tool row (home subHeader, in-feed sticky). */ +export const feedFilterRowChromeClass = + 'w-full min-w-0 border-b border-border/80 bg-background/95 pb-1.5 pt-0.5 backdrop-blur supports-[backdrop-filter]:bg-background/80' + +/** + * Single-row feed controls: refresh (optional), kind filter, and 🔍 slot (portaled from {@link NoteList}). + * Use `flex-nowrap` so large text / narrow viewports do not wrap tools onto a second line. + */ +export default function FeedFilterToolbarRow({ + showKinds, + onShowKindsChange, + onRefresh, + feedFilterTabRowSlotRef, + includeFeedSearchSlot = false, + className +}: { + showKinds: number[] + onShowKindsChange: (kinds: number[]) => void + onRefresh?: () => void + /** Host element for {@link NoteList} feed-client-filter toggle via portal. */ + feedFilterTabRowSlotRef?: Ref + includeFeedSearchSlot?: boolean + className?: string +}) { + return ( +
+ {onRefresh != null ? : null} + + {includeFeedSearchSlot ? ( +
+ ) : null} +
+ ) +} diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 7d1c3e76..3357119f 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -1,15 +1,11 @@ import storage from '@/services/local-storage.service' import NoteList, { TNoteListRef } from '@/components/NoteList' -import { RefreshButton } from '@/components/RefreshButton' -import Tabs, { TabDefinition } from '@/components/Tabs' -import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' +import FeedFilterToolbarRow, { feedFilterRowChromeClass } from '@/components/FeedFilterToolbarRow' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' -import { HOME_GALLERY_TAB_KINDS, FAST_READ_RELAY_URLS } from '@/constants' import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import type { TPrimaryPageName } from '@/PageManager' import { TFeedSubRequest, TNoteListMode } from '@/types' import { cn } from '@/lib/utils' -import { normalizeAnyRelayUrl } from '@/lib/url' import type { Event } from 'nostr-tools' import { forwardRef, @@ -20,32 +16,6 @@ import { useState, type ReactNode } from 'react' -import KindFilter from '../KindFilter' - -/** - * Home Gallery: favorites (or chip relays) first, then {@link FAST_READ_RELAY_URLS} so NIP-71 / picture - * events are not starved when the user’s relay set is mostly text timelines. Deduped by normalized URL. - */ -function galleryRelayUrlsMergedWithReadLayer( - favoriteUrls: readonly string[], - mergeGlobalFastRead: boolean -): string[] { - const seen = new Set() - const out: string[] = [] - const add = (raw: string) => { - const n = normalizeAnyRelayUrl(raw.trim()) || raw.trim() - if (!n) return - const k = n.toLowerCase() - if (seen.has(k)) return - seen.add(k) - out.push(n) - } - for (const u of favoriteUrls) add(u) - if (mergeGlobalFastRead) { - for (const u of FAST_READ_RELAY_URLS) add(u) - } - return out -} const NormalFeed = forwardRef void - /** Shown in the subHeader row to the left of the kind filter (mobile primary feed). */ + /** Shown in the subHeader row beside the kind filter (mobile primary feed). */ onSubHeaderRefresh?: () => void /** * When true with {@link mergeTimelineWhenSubRequestFiltersMatch}, relay URL list can change (e.g. favorites @@ -63,15 +33,8 @@ const NormalFeed = forwardRef number extraShouldHideEvent?: (ev: Event) => boolean extraShouldHideRepliesEvent?: (ev: Event) => boolean - /** When set with home Gallery, filters rows (e.g. aggr-only) using the widened relay stack. */ - extraShouldHideGalleryEvent?: (ev: Event) => boolean /** Override default cap for merged one-shot batches (wide d-tag / search merges). */ oneShotMergedCap?: number /** When every relay in the subscribe wave fails before EOSE, merge a one-shot fetch from default read relays (home multi-relay feeds). */ @@ -122,12 +83,12 @@ const NormalFeed = forwardRef(function NormalFeed( { @@ -140,8 +101,6 @@ const NormalFeed = forwardRef(() => { - const storedMode = storage.getNoteListMode() - if (isMainFeed) { - if (storedMode === 'posts' || storedMode === 'postsAndReplies') { - return storedMode - } - return 'posts' - } - // Non-main feeds only expose Notes / Replies tabs — ignore stored "media" from the home gallery tab. - if (storedMode === 'posts' || storedMode === 'postsAndReplies') { - return storedMode - } - return 'posts' - }) const internalNoteListRef = useRef(null) const noteListRef = ref || internalNoteListRef const [feedFilterTabRowHost, setFeedFilterTabRowHost] = useState(null) @@ -196,9 +139,7 @@ const NormalFeed = forwardRef (Object.is(prev, node) ? prev : node)) }, []) - const MEDIA_KINDS = useMemo(() => [...HOME_GALLERY_TAB_KINDS], []) - - /** Every shard URL is a nostrarchives Wisp “trending notes” stream — replies/gallery tabs are not applicable. */ + /** Every shard URL is a nostrarchives Wisp "trending notes" stream — OP-only timeline. */ const isWispTrendingOnlyFeed = useMemo( () => subRequests.length > 0 && @@ -208,70 +149,27 @@ const NormalFeed = forwardRef { - if (!isWispTrendingOnlyFeed) return - setListMode((m) => (m === 'posts' ? m : 'posts')) - }, [isWispTrendingOnlyFeed]) + /** Replies feed by default; Wisp trending stays notes-only. Kind filter can hide replies client-side. */ + const listMode: TNoteListMode = isWispTrendingOnlyFeed ? 'posts' : 'postsAndReplies' - const tabs = useMemo((): TabDefinition[] => { - if (isMainFeed && isWispTrendingOnlyFeed) { - return [{ value: 'posts', label: 'Notes' }] - } - const base: TabDefinition[] = [ - { value: 'posts', label: 'Notes' }, - { value: 'postsAndReplies', label: 'Replies' } - ] - if (isMainFeed) base.push({ value: 'media', label: 'Gallery' }) - return base - }, [isMainFeed, isWispTrendingOnlyFeed]) + useEffect(() => { + if (!isMainFeed) return + if (storage.getNoteListMode() === listMode) return + storage.setNoteListMode(listMode) + window.dispatchEvent(new CustomEvent('noteListModeChanged')) + }, [isMainFeed, listMode]) - /** Replies may widen relays; Gallery swaps kinds and may use {@link mainFeedGalleryRelayUrls} on home. */ const effectiveSubRequests = useMemo(() => { if (listMode === 'postsAndReplies' && repliesSubRequests) { return repliesSubRequests } - if (listMode !== 'media') return subRequests - return subRequests.map((req) => ({ - ...req, - urls: - isMainFeed && mainFeedGalleryRelayUrls && mainFeedGalleryRelayUrls.length > 0 - ? mainFeedGalleryRelayUrls - : isMainFeed && widenMainGalleryRelays - ? galleryRelayUrlsMergedWithReadLayer(req.urls, useGlobalRelayBootstrap) - : req.urls, - filter: { ...req.filter, kinds: MEDIA_KINDS } - })) - }, [ - listMode, - subRequests, - repliesSubRequests, - MEDIA_KINDS, - isMainFeed, - widenMainGalleryRelays, - mainFeedGalleryRelayUrls, - useGlobalRelayBootstrap - ]) + return subRequests + }, [listMode, subRequests, repliesSubRequests]) const noteListExtraShouldHide = useMemo(() => { if (listMode === 'postsAndReplies') return extraShouldHideRepliesEvent - if (listMode === 'media' && extraShouldHideGalleryEvent) return extraShouldHideGalleryEvent return extraShouldHideEvent - }, [listMode, extraShouldHideRepliesEvent, extraShouldHideGalleryEvent, extraShouldHideEvent]) - - const handleListModeChange = useCallback( - (mode: TNoteListMode | string) => { - const noteListMode = mode as TNoteListMode - setListMode(noteListMode) - if (isMainFeed) { - storage.setNoteListMode(noteListMode) - window.dispatchEvent(new CustomEvent('noteListModeChanged')) - } - if (noteListRef && typeof noteListRef !== 'function') { - noteListRef.current?.scrollToTop('smooth') - } - }, - [isMainFeed, noteListRef] - ) + }, [listMode, extraShouldHideRepliesEvent, extraShouldHideEvent]) const handleShowKindsChange = useCallback((_newShowKinds: number[]) => { if (noteListRef && typeof noteListRef !== 'function') { @@ -298,76 +196,40 @@ const NormalFeed = forwardRef { - const kindRowOptions = ( -
- {onSubHeaderRefresh != null && } - - {mergeFilterWithTabsRow ? ( -
- ) : null} -
- ) - if (isMainFeed && isWispTrendingOnlyFeed) { - return ( -
{kindRowOptions}
- ) - } - return ( - ( + - ) - }, [ - isMainFeed, - isWispTrendingOnlyFeed, - listMode, - tabs, - handleListModeChange, - showKinds, - onSubHeaderRefresh, - handleShowKindsChange, - mergeFilterWithTabsRow - ]) - - const tabRowChromeClass = - 'w-full min-w-0 border-b border-border/80 bg-background/95 pb-1.5 pt-0.5 backdrop-blur supports-[backdrop-filter]:bg-background/80' + ), + [onSubHeaderRefresh, showKinds, handleShowKindsChange, showFeedClientFilter] + ) /** - * Push the tab row into {@link PrimaryPageLayout} subHeader. Use `useEffect` (not `useLayoutEffect`) so + * Push the filter toolbar into {@link PrimaryPageLayout} subHeader. Use `useEffect` (not `useLayoutEffect`) so * parent `setHomeSubHeader` runs after paint; synchronous layout updates here caused React #185 * (maximum update depth) when navigating onto the home feed after other primaries (e.g. notifications). - * Intentionally omit `tabsElement` from deps — covered by `listMode` + `subHeaderFilterDepsKey`. - * Omit `onSubHeaderRefresh` / `onFeedFilterTabRowSlotRef`: only embedded in `tabsElement`; unstable + * Intentionally omit `filterToolbarRow` from deps — covered by `subHeaderFilterDepsKey`. + * Omit `onSubHeaderRefresh` / `onFeedFilterTabRowSlotRef`: only embedded in `filterToolbarRow`; unstable * identities there would retrigger every render and loop with parent state. * Do not clear subHeader between dep updates — nulling remounts the filter portal slot and retriggers * NoteList subscriptions / layout churn on the home feed. */ useEffect(() => { if (!isMainFeed || !setSubHeader) return - if (mergeFilterWithTabsRow) { - setSubHeader(
{tabsElement}
) - } else { - setSubHeader(tabsElement) - } + setSubHeader(
{filterToolbarRow}
) }, [ isMainFeed, setSubHeader, - listMode, isWispTrendingOnlyFeed, subHeaderFilterDepsKey, - allowKindlessRelayExplore, - mergeFilterWithTabsRow + allowKindlessRelayExplore ]) useEffect(() => { @@ -377,15 +239,10 @@ const NormalFeed = forwardRef - {renderTabsInFeed && - (mergeFilterWithTabsRow ? ( -
{tabsElement}
- ) : ( - tabsElement - ))} -
+ {renderFilterToolbarInFeed ? ( +
{filterToolbarRow}
+ ) : null} +
(null) + const [feedFilterTabRowHost, setFeedFilterTabRowHost] = useState(null) + const onFeedFilterTabRowSlotRef = useCallback((node: HTMLDivElement | null) => { + setFeedFilterTabRowHost((prev) => (Object.is(prev, node) ? prev : node)) + }, []) const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey) @@ -106,9 +110,14 @@ const ProfileFeed = forwardRef< {t('Refreshing posts...')}
)} -
- - +
+
{pinEvents.filter((e) => !isEventDeleted(e)).length > 0 && (
@@ -142,6 +151,7 @@ const ProfileFeed = forwardRef< showKind1Replies={showKind1Replies} showKind1111={showKind1111} showFeedClientFilter + feedClientFilterTabRowHost={feedFilterTabRowHost} timelinePublicReadFallback revealBatchSize={48} /> diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 0c49c804..24c475d5 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -144,8 +144,6 @@ const RelaysFeed = forwardRef< onSubHeaderRefresh={onSubHeaderRefresh} preserveTimelineOnSubRequestsChange repliesSubRequests={repliesSubRequests} - mainFeedGalleryRelayUrls={stableReplyRelayUrls} - widenMainGalleryRelays={false} feedSubscriptionKey="home-all-favorites" feedTimelineScopeKey="all-favorites" homeFeedSeenOnAllowlistOp={homeFeedSeenOnAllowlistOp} diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 4c769007..6d009eb0 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -93,7 +93,7 @@ class LocalStorageService { private fontSize: TFontSize = DEFAULT_FONT_SIZE private accounts: TAccount[] = [] private currentAccount: TAccount | null = null - private noteListMode: TNoteListMode = 'posts' + private noteListMode: TNoteListMode = 'postsAndReplies' private defaultZapSats: number = DEFAULT_ZAP_SATS private defaultZapComment: string = 'Zap!' private preferredPaytoCategory: PaytoCategory | null = null