Browse Source

move feed search up a level

imwald
Silberengel 1 month ago
parent
commit
e5a5453ddb
  1. 2
      src/components/BookmarkButton/index.tsx
  2. 7
      src/components/ContentPreview/FollowPackPreview.tsx
  3. 3
      src/components/ContentPreview/index.tsx
  4. 5
      src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx
  5. 9
      src/components/FavoriteRelaysActiveStrip/index.tsx
  6. 3
      src/components/FollowButton/index.tsx
  7. 3
      src/components/LatestFromFollowsSection/index.tsx
  8. 3
      src/components/MuteButton/index.tsx
  9. 83
      src/components/NormalFeed/index.tsx
  10. 3
      src/components/Note/index.tsx
  11. 3
      src/components/NoteCard/RepostNoteCard.tsx
  12. 3
      src/components/NoteCard/index.tsx
  13. 456
      src/components/NoteList/index.tsx
  14. 3
      src/components/NoteOptions/useMenuActions.tsx
  15. 3
      src/components/PostEditor/Mentions.tsx
  16. 1
      src/components/Profile/ProfileMediaFeed.tsx
  17. 3
      src/components/ProfileOptions/index.tsx
  18. 10
      src/components/Relay/index.tsx
  19. 3
      src/components/RelayInfo/RelayReviewsPreview.tsx
  20. 3
      src/components/ReplyNote/index.tsx
  21. 18
      src/i18n/locales/de.ts
  22. 18
      src/i18n/locales/en.ts
  23. 3
      src/lib/event.ts
  24. 68
      src/lib/feed-full-search-relays.ts
  25. 7
      src/lib/mute-set.ts
  26. 3
      src/lib/notification.ts
  27. 3
      src/lib/thread-response-filter.ts
  28. 1
      src/pages/primary/NoteListPage/FollowingFeed.tsx
  29. 1
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  30. 2
      src/pages/primary/RelayPage/index.tsx
  31. 2
      src/pages/primary/SpellsPage/CreateSpellDialog.tsx
  32. 4
      src/pages/primary/SpellsPage/index.tsx
  33. 24
      src/providers/BookmarksProvider.tsx
  34. 60
      src/providers/MuteListProvider.tsx
  35. 10
      src/providers/NostrProvider/index.tsx
  36. 27
      src/providers/bookmarks-context.tsx

2
src/components/BookmarkButton/index.tsx

@ -1,7 +1,7 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { NostrContext } from '@/providers/nostr-context' import { NostrContext } from '@/providers/nostr-context'
import { useBookmarksOptional } from '@/providers/BookmarksProvider' import { useBookmarksOptional } from '@/providers/bookmarks-context'
import { BookmarkIcon } from 'lucide-react' import { BookmarkIcon } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useContext, useMemo, useState } from 'react' import { useContext, useMemo, useState } from 'react'

7
src/components/ContentPreview/FollowPackPreview.tsx

@ -5,6 +5,7 @@ import logger from '@/lib/logger'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useFollowListOptional } from '@/providers/FollowListProvider' import { useFollowListOptional } from '@/providers/FollowListProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { Users } from 'lucide-react' import { Users } from 'lucide-react'
@ -67,7 +68,7 @@ export default function FollowPackPreview({
const followingSet = useMemo(() => new Set(followings), [followings]) const followingSet = useMemo(() => new Set(followings), [followings])
const availablePubkeys = useMemo( const availablePubkeys = useMemo(
() => packPubkeys.filter((p) => !mutePubkeySet.has(p)), () => packPubkeys.filter((p) => !muteSetHas(mutePubkeySet, p)),
[packPubkeys, mutePubkeySet] [packPubkeys, mutePubkeySet]
) )
const alreadyFollowingAll = const alreadyFollowingAll =
@ -83,9 +84,9 @@ export default function FollowPackPreview({
} }
if (!followList) return if (!followList) return
const { follow } = followList const { follow } = followList
const toFollow = packPubkeys.filter((p) => !followingSet.has(p) && !mutePubkeySet.has(p)) const toFollow = packPubkeys.filter((p) => !followingSet.has(p) && !muteSetHas(mutePubkeySet, p))
if (toFollow.length === 0) { if (toFollow.length === 0) {
const mutedCount = packPubkeys.filter((p) => mutePubkeySet.has(p) && !followingSet.has(p)).length const mutedCount = packPubkeys.filter((p) => muteSetHas(mutePubkeySet, p) && !followingSet.has(p)).length
if (mutedCount > 0) { if (mutedCount > 0) {
toast.info(t('All available members are already followed or muted')) toast.info(t('All available members are already followed or muted'))
} else { } else {

3
src/components/ContentPreview/index.tsx

@ -12,6 +12,7 @@ import {
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteListOptional } from '@/contexts/mute-list-context' import { useMuteListOptional } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -78,7 +79,7 @@ export default function ContentPreview({
const contentPolicy = useContentPolicyOptional() const contentPolicy = useContentPolicyOptional()
const hideContentMentioningMutedUsers = contentPolicy?.hideContentMentioningMutedUsers ?? false const hideContentMentioningMutedUsers = contentPolicy?.hideContentMentioningMutedUsers ?? false
const isMuted = useMemo( const isMuted = useMemo(
() => (event ? mutePubkeySet.has(event.pubkey) : false), () => (event ? muteSetHas(mutePubkeySet, event.pubkey) : false),
[mutePubkeySet, event] [mutePubkeySet, event]
) )
const isMentioningMuted = useMemo( const isMentioningMuted = useMemo(

5
src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx

@ -15,6 +15,7 @@ import {
truncateAbout truncateAbout
} from '@/lib/relay-pulse-nip05' } from '@/lib/relay-pulse-nip05'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context'
import { SecondaryPageLink } from '@/PageManager' import { SecondaryPageLink } from '@/PageManager'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
@ -133,14 +134,14 @@ export function RelayPulseActiveNpubsSheet() {
const followWithProfile = useMemo( const followWithProfile = useMemo(
() => () =>
followPubkeys.filter( followPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) (pk) => profileKind0ByPubkey[pk] && !muteSetHas(mutePubkeySet, pk)
), ),
[followPubkeys, profileKind0ByPubkey, mutePubkeySet] [followPubkeys, profileKind0ByPubkey, mutePubkeySet]
) )
const othersWithProfile = useMemo( const othersWithProfile = useMemo(
() => () =>
otherPubkeys.filter( otherPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) (pk) => profileKind0ByPubkey[pk] && !muteSetHas(mutePubkeySet, pk)
), ),
[otherPubkeys, profileKind0ByPubkey, mutePubkeySet] [otherPubkeys, profileKind0ByPubkey, mutePubkeySet]
) )

9
src/components/FavoriteRelaysActiveStrip/index.tsx

@ -6,6 +6,7 @@ import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context' import { useFavoriteRelaysActivity } from '@/providers/favorite-relays-activity-context'
import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet' import { RelayPulseActiveNpubsOpenButton } from './RelayPulseActiveNpubsSheet'
import type { TFunction } from 'i18next' import type { TFunction } from 'i18next'
@ -231,14 +232,14 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
const followPubkeysForAvatars = useMemo( const followPubkeysForAvatars = useMemo(
() => () =>
followPubkeys.filter( followPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) (pk) => profileKind0ByPubkey[pk] && !muteSetHas(mutePubkeySet, pk)
), ),
[followPubkeys, profileKind0ByPubkey, mutePubkeySet] [followPubkeys, profileKind0ByPubkey, mutePubkeySet]
) )
const otherPubkeysForAvatars = useMemo( const otherPubkeysForAvatars = useMemo(
() => () =>
otherPubkeys.filter( otherPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) (pk) => profileKind0ByPubkey[pk] && !muteSetHas(mutePubkeySet, pk)
), ),
[otherPubkeys, profileKind0ByPubkey, mutePubkeySet] [otherPubkeys, profileKind0ByPubkey, mutePubkeySet]
) )
@ -338,14 +339,14 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
const followPubkeysForAvatars = useMemo( const followPubkeysForAvatars = useMemo(
() => () =>
followPubkeys.filter( followPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) (pk) => profileKind0ByPubkey[pk] && !muteSetHas(mutePubkeySet, pk)
), ),
[followPubkeys, profileKind0ByPubkey, mutePubkeySet] [followPubkeys, profileKind0ByPubkey, mutePubkeySet]
) )
const otherPubkeysForAvatars = useMemo( const otherPubkeysForAvatars = useMemo(
() => () =>
otherPubkeys.filter( otherPubkeys.filter(
(pk) => profileKind0ByPubkey[pk] && !mutePubkeySet.has(pk) (pk) => profileKind0ByPubkey[pk] && !muteSetHas(mutePubkeySet, pk)
), ),
[otherPubkeys, profileKind0ByPubkey, mutePubkeySet] [otherPubkeys, profileKind0ByPubkey, mutePubkeySet]
) )

3
src/components/FollowButton/index.tsx

@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFollowListOptional } from '@/providers/FollowListProvider' import { useFollowListOptional } from '@/providers/FollowListProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -28,7 +29,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
const followings = followList?.followings ?? [] const followings = followList?.followings ?? []
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey]) const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey]) const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey])
if (!followList || !accountPubkey || (pubkey && pubkey === accountPubkey)) return null if (!followList || !accountPubkey || (pubkey && pubkey === accountPubkey)) return null

3
src/components/LatestFromFollowsSection/index.tsx

@ -20,6 +20,7 @@ import { useSecondaryPage } from '@/PageManager'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { queryService, replaceableEventService } from '@/services/client.service' import { queryService, replaceableEventService } from '@/services/client.service'
@ -167,7 +168,7 @@ export default function LatestFromFollowsSection({
if (!feedKindSet.has(e.kind)) return false if (!feedKindSet.has(e.kind)) return false
if (isEventDeleted(e)) return false if (isEventDeleted(e)) return false
if (shouldFilterEvent(e)) return false if (shouldFilterEvent(e)) return false
if (mutePubkeySet.has(e.pubkey)) return false if (muteSetHas(mutePubkeySet, e.pubkey)) return false
if (hideUntrustedNotes && !isUserTrusted(e.pubkey)) return false if (hideUntrustedNotes && !isUserTrusted(e.pubkey)) return false
return true return true
}, },

3
src/components/MuteButton/index.tsx

@ -8,6 +8,7 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { BellOff } from 'lucide-react' import { BellOff } from 'lucide-react'
@ -22,7 +23,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } =
useMuteList() useMuteList()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey]) const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey])
if (!accountPubkey || (pubkey && pubkey === accountPubkey)) return null if (!accountPubkey || (pubkey && pubkey === accountPubkey)) return null

83
src/components/NormalFeed/index.tsx

@ -4,8 +4,10 @@ import Tabs, { TabDefinition } from '@/components/Tabs'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import type { TPrimaryPageName } from '@/PageManager'
import { TFeedSubRequest, TNoteListMode } from '@/types' import { TFeedSubRequest, TNoteListMode } from '@/types'
import { forwardRef, useLayoutEffect, useMemo, useRef, useState } from 'react' import { cn } from '@/lib/utils'
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import KindFilter from '../KindFilter' import KindFilter from '../KindFilter'
const NormalFeed = forwardRef<TNoteListRef, { const NormalFeed = forwardRef<TNoteListRef, {
@ -34,6 +36,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
* Client-side 🔍 feed filter. When omitted: hidden on main following, shown on relay explore and non-main feeds. * Client-side 🔍 feed filter. When omitted: hidden on main following, shown on relay explore and non-main feeds.
*/ */
showFeedClientFilter?: boolean showFeedClientFilter?: boolean
/** When set, {@link NoteList} clears 🔍 filters when another primary tab is shown (mounted-but-hidden pages). */
hostPrimaryPageName?: TPrimaryPageName
}>(function NormalFeed( }>(function NormalFeed(
{ {
subRequests, subRequests,
@ -48,7 +52,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
useFilterAsIs = false, useFilterAsIs = false,
clientSideKindFilter = false, clientSideKindFilter = false,
allowKindlessRelayExplore = false, allowKindlessRelayExplore = false,
showFeedClientFilter: showFeedClientFilterProp showFeedClientFilter: showFeedClientFilterProp,
hostPrimaryPageName
}, },
ref ref
) { ) {
@ -67,6 +72,10 @@ const NormalFeed = forwardRef<TNoteListRef, {
}) })
const internalNoteListRef = useRef<TNoteListRef>(null) const internalNoteListRef = useRef<TNoteListRef>(null)
const noteListRef = ref || internalNoteListRef const noteListRef = ref || internalNoteListRef
const [feedFilterTabRowHost, setFeedFilterTabRowHost] = useState<HTMLDivElement | null>(null)
const onFeedFilterTabRowSlotRef = useCallback((node: HTMLDivElement | null) => {
setFeedFilterTabRowHost(node)
}, [])
const tabs = useMemo( const tabs = useMemo(
(): TabDefinition[] => [ (): TabDefinition[] => [
@ -76,7 +85,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
[] []
) )
const handleListModeChange = (mode: TNoteListMode | string) => { const handleListModeChange = useCallback(
(mode: TNoteListMode | string) => {
const noteListMode = mode as TNoteListMode const noteListMode = mode as TNoteListMode
setListMode(noteListMode) setListMode(noteListMode)
if (isMainFeed) { if (isMainFeed) {
@ -86,13 +96,15 @@ const NormalFeed = forwardRef<TNoteListRef, {
if (noteListRef && typeof noteListRef !== 'function') { if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.scrollToTop('smooth') noteListRef.current?.scrollToTop('smooth')
} }
} },
[isMainFeed, noteListRef]
)
const handleShowKindsChange = (_newShowKinds: number[]) => { const handleShowKindsChange = useCallback((_newShowKinds: number[]) => {
if (noteListRef && typeof noteListRef !== 'function') { if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.scrollToTop() noteListRef.current?.scrollToTop()
} }
} }, [noteListRef])
const showKindsKey = useMemo(() => JSON.stringify(showKinds), [showKinds]) const showKindsKey = useMemo(() => JSON.stringify(showKinds), [showKinds])
@ -107,11 +119,12 @@ const NormalFeed = forwardRef<TNoteListRef, {
/** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */ /** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */
const subHeaderFilterDepsKey = `${allowKindlessRelayExplore ? 'kle' : 'std'}|${showKindsKey}|${feedKindFilterBypass}` const subHeaderFilterDepsKey = `${allowKindlessRelayExplore ? 'kle' : 'std'}|${showKindsKey}|${feedKindFilterBypass}`
const tabsElement = ( const tabsElement = useMemo(
() => (
<Tabs <Tabs
value={listMode} value={listMode}
tabs={tabs} tabs={tabs}
onTabChange={(tab) => handleListModeChange(tab)} onTabChange={handleListModeChange}
options={ options={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{onSubHeaderRefresh != null && <RefreshButton onClick={onSubHeaderRefresh} />} {onSubHeaderRefresh != null && <RefreshButton onClick={onSubHeaderRefresh} />}
@ -119,12 +132,38 @@ const NormalFeed = forwardRef<TNoteListRef, {
</div> </div>
} }
/> />
),
[
listMode,
tabs,
handleListModeChange,
showKinds,
onSubHeaderRefresh,
handleShowKindsChange
]
) )
const renderTabsInFeed = !(isMainFeed && setSubHeader) && !allowKindlessRelayExplore
const mergeFilterWithTabsRow =
showFeedClientFilter && ((isMainFeed && !!setSubHeader) || renderTabsInFeed)
/** Same row for multi-relay and single-relay chips: Notes/Replies + refresh + kind picker (REQ may stay kindless for single relay; NoteList filters client-side). */ /** Same row for multi-relay and single-relay chips: Notes/Replies + refresh + kind picker (REQ may stay kindless for single relay; NoteList filters client-side). */
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isMainFeed || !setSubHeader) return if (!isMainFeed || !setSubHeader) return
if (mergeFilterWithTabsRow) {
setSubHeader(
<div className="flex w-full min-w-0 flex-wrap items-end gap-x-2 gap-y-1 border-b border-border/80 bg-background/95 pb-1.5 pt-0.5 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="min-w-0 flex-1">{tabsElement}</div>
<div
ref={onFeedFilterTabRowSlotRef}
className="flex shrink-0 flex-col items-end justify-center self-center"
/>
</div>
)
} else {
setSubHeader(tabsElement) setSubHeader(tabsElement)
}
return () => setSubHeader(null) return () => setSubHeader(null)
}, [ }, [
isMainFeed, isMainFeed,
@ -132,15 +171,31 @@ const NormalFeed = forwardRef<TNoteListRef, {
listMode, listMode,
subHeaderFilterDepsKey, subHeaderFilterDepsKey,
onSubHeaderRefresh, onSubHeaderRefresh,
allowKindlessRelayExplore allowKindlessRelayExplore,
mergeFilterWithTabsRow,
tabsElement,
onFeedFilterTabRowSlotRef
]) ])
const renderTabsInFeed = !(isMainFeed && setSubHeader) && !allowKindlessRelayExplore
return ( return (
<> <>
{renderTabsInFeed && tabsElement} {renderTabsInFeed &&
<div className="min-w-0 pt-2"> (mergeFilterWithTabsRow ? (
<div className="sticky top-0 z-20 border-b border-border/80 bg-background/95 pb-1.5 pt-0.5 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="flex w-full min-w-0 flex-wrap items-end gap-x-2 gap-y-1">
<div className="min-w-0 flex-1">{tabsElement}</div>
<div
ref={onFeedFilterTabRowSlotRef}
className="flex shrink-0 flex-col items-end justify-center self-center"
/>
</div>
</div>
) : (
tabsElement
))}
<div
className={cn('min-w-0', mergeFilterWithTabsRow && renderTabsInFeed ? 'pt-0' : 'pt-2')}
>
<NoteList <NoteList
ref={noteListRef} ref={noteListRef}
showKinds={showKinds} showKinds={showKinds}
@ -160,6 +215,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
clientSideKindFilter={clientSideKindFilter} clientSideKindFilter={clientSideKindFilter}
allowKindlessRelayExplore={allowKindlessRelayExplore} allowKindlessRelayExplore={allowKindlessRelayExplore}
showFeedClientFilter={showFeedClientFilter} showFeedClientFilter={showFeedClientFilter}
hostPrimaryPageName={hostPrimaryPageName}
feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined}
/> />
</div> </div>
</> </>

3
src/components/Note/index.tsx

@ -23,6 +23,7 @@ import logger from '@/lib/logger'
import client from '@/services/client.service' import client from '@/services/client.service'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteListOptional } from '@/contexts/mute-list-context' import { useMuteListOptional } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import type { HighlightData } from '@/components/PostEditor/HighlightEditor' import type { HighlightData } from '@/components/PostEditor/HighlightEditor'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@ -159,7 +160,7 @@ export default function Note({
if (!isRenderableNoteKind(event.kind)) { if (!isRenderableNoteKind(event.kind)) {
logger.debug('Note component - rendering UnknownNote for unsupported kind:', event.kind) logger.debug('Note component - rendering UnknownNote for unsupported kind:', event.kind)
content = <UnknownNote className="mt-2" event={event} omitKindLabel /> content = <UnknownNote className="mt-2" event={event} omitKindLabel />
} else if (mutePubkeySet.has(event.pubkey) && !showMuted) { } else if (muteSetHas(mutePubkeySet, event.pubkey) && !showMuted) {
content = <MutedNote show={() => setShowMuted(true)} /> content = <MutedNote show={() => setShowMuted(true)} />
} else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) { } else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) {
content = <NsfwNote show={() => setShowNsfw(true)} /> content = <NsfwNote show={() => setShowNsfw(true)} />

3
src/components/NoteCard/RepostNoteCard.tsx

@ -3,6 +3,7 @@ import { isMentioningMutedUsers } from '@/lib/event'
import { generateBech32IdFromATag, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import { generateBech32IdFromATag, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import client from '@/services/client.service' import client from '@/services/client.service'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import { Event, kinds, nip19, verifyEvent } from 'nostr-tools' import { Event, kinds, nip19, verifyEvent } from 'nostr-tools'
@ -25,7 +26,7 @@ export default function RepostNoteCard({
const [targetEvent, setTargetEvent] = useState<Event | null>(null) const [targetEvent, setTargetEvent] = useState<Event | null>(null)
const shouldHide = useMemo(() => { const shouldHide = useMemo(() => {
if (!targetEvent) return true if (!targetEvent) return true
if (filterMutedNotes && mutePubkeySet.has(targetEvent.pubkey)) { if (filterMutedNotes && muteSetHas(mutePubkeySet, targetEvent.pubkey)) {
return true return true
} }
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(targetEvent, mutePubkeySet)) { if (hideContentMentioningMutedUsers && isMentioningMutedUsers(targetEvent, mutePubkeySet)) {

3
src/components/NoteCard/index.tsx

@ -2,6 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { isMentioningMutedUsers, isNip18RepostKind } from '@/lib/event' import { isMentioningMutedUsers, isNip18RepostKind } from '@/lib/event'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
import MainNoteCard from './MainNoteCard' import MainNoteCard from './MainNoteCard'
@ -26,7 +27,7 @@ const NoteCard = memo(function NoteCard({
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const shouldHide = useMemo(() => { const shouldHide = useMemo(() => {
if (filterMutedNotes && mutePubkeySet.has(event.pubkey)) { if (filterMutedNotes && muteSetHas(mutePubkeySet, event.pubkey)) {
return true return true
} }
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) { if (hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) {

456
src/components/NoteList/index.tsx

@ -20,6 +20,7 @@ import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
@ -52,14 +53,19 @@ import { CircleAlert } from 'lucide-react'
import { useLongPressAction } from '@/hooks/use-long-press-action' import { useLongPressAction } from '@/hooks/use-long-press-action'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh' import PullToRefresh from 'react-simple-pull-to-refresh'
import { createPortal } from 'react-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, inviteInputToHexPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { usePrimaryPageOptional } from '@/contexts/primary-page-context'
import type { TPrimaryPageName } from '@/PageManager'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { buildFeedFullSearchRelayUrls } from '@/lib/feed-full-search-relays'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { import {
Select, Select,
SelectContent, SelectContent,
@ -89,9 +95,14 @@ if (import.meta.env.DEV && import.meta.hot) {
const SHOW_COUNT = 20 // Increased from 10 to show more events at once, reducing scroll load frequency const SHOW_COUNT = 20 // Increased from 10 to show more events at once, reducing scroll load frequency
/** Hard cap after merging parallel one-shot fetches (e.g. interests = one REQ per topic). */ /** Hard cap after merging parallel one-shot fetches (e.g. interests = one REQ per topic). */
const ONE_SHOT_MERGED_CAP =100 const ONE_SHOT_MERGED_CAP =100
/** Max events kept after merging parallel full-search REQ results across relays. */
const FEED_FULL_SEARCH_MERGE_CAP = 400
/** Client-side feed time window units (Day.js `.subtract` names). */ /** Client-side feed time window units (Day.js `.subtract` names). */
type TFeedClientTimeUnit = 'minute' | 'day' | 'week' | 'month' | 'year' type TFeedClientTimeUnit = 'minute' | 'day' | 'week' | 'month' | 'year'
/** Client-side “who wrote this” filter on already-loaded posts. */
type TFeedClientAuthorMode = 'everyone' | 'me' | 'npub'
/** Short debounce: batch rapid timeline updates without delaying first paint on feeds like notifications. */ /** Short debounce: batch rapid timeline updates without delaying first paint on feeds like notifications. */
const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50 const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50
/** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */ /** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */
@ -122,6 +133,71 @@ function timelineFilterHasNonKindScope(f: Filter): boolean {
) )
} }
/** REQ filter for the first subrequest, matching {@link NoteList} timeline mapping (for full relay search). */
function buildNoteListMappedFilterForFullSearch(
req: TFeedSubRequest,
options: {
showKinds: number[]
useFilterAsIs: boolean
allowKindlessRelayExplore: boolean
clientSideKindFilter: boolean
seeAllFeedEvents: boolean
areAlgoRelays: boolean
}
): Filter | null {
const { urls, filter } = req
const defaultKinds = options.showKinds.length > 0 ? options.showKinds : [kinds.ShortTextNote]
const baseLimit = filter.limit ?? (options.areAlgoRelays ? ALGO_LIMIT : LIMIT)
const seeAllNoSpell = options.seeAllFeedEvents && !options.useFilterAsIs
let f: Filter
if (options.useFilterAsIs) {
const hasKindsInRequest = Array.isArray(filter.kinds) && filter.kinds.length > 0
if (options.allowKindlessRelayExplore && urls.length === 1 && !hasKindsInRequest) {
const finalFilter: Filter = {
...filter,
limit: filter.limit ?? RELAY_EXPLORE_LIMIT
}
delete finalFilter.kinds
f = finalFilter
} else {
const finalFilter: Filter = { ...filter, limit: baseLimit }
if (options.clientSideKindFilter) {
if (hasKindsInRequest) {
finalFilter.kinds = filter.kinds
} else {
delete finalFilter.kinds
}
} else if (hasKindsInRequest) {
finalFilter.kinds = filter.kinds
} else {
finalFilter.kinds = defaultKinds
}
f = finalFilter
}
} else if (seeAllNoSpell) {
const { kinds: _omitKinds, ...rest } = filter
f = {
...rest,
limit: options.areAlgoRelays ? ALGO_LIMIT : LIMIT
}
} else {
f = {
...filter,
kinds: defaultKinds,
limit: options.areAlgoRelays ? ALGO_LIMIT : LIMIT
}
}
if (seeAllNoSpell) return f
const missingKinds = !f.kinds || f.kinds.length === 0
if (!missingKinds) return f
if (options.useFilterAsIs && options.clientSideKindFilter && timelineFilterHasNonKindScope(f)) return f
if (options.useFilterAsIs && options.allowKindlessRelayExplore && urls.length === 1) return f
return null
}
const NoteList = forwardRef( const NoteList = forwardRef(
( (
{ {
@ -201,7 +277,17 @@ const NoteList = forwardRef(
* When true (default), show the 🔍 client-side filter bar (search / from me / time window). * When true (default), show the 🔍 client-side filter bar (search / from me / time window).
* Set false on feeds where it should stay hidden (e.g. main following). * Set false on feeds where it should stay hidden (e.g. main following).
*/ */
showFeedClientFilter = true showFeedClientFilter = true,
/**
* When set, clear 🔍 filter + full-search results whenever this primary tab is not visible (other tabs stay
* mounted with `hidden`) or when the in-page feed identity changes see {@link feedClientFilterScopeKey}.
*/
hostPrimaryPageName,
/**
* When {@link NormalFeed} renders Notes/Replies + kind row, it passes the slot element so the 🔍 control
* sits on that row instead of an extra bar above the list. Omitted on spells / standalone NoteList.
*/
feedClientFilterTabRowHost
}: { }: {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
showKinds: number[] showKinds: number[]
@ -241,6 +327,8 @@ const NoteList = forwardRef(
oneShotEoseTimeoutMs?: number oneShotEoseTimeoutMs?: number
oneShotFirstRelayGraceMs?: number | false oneShotFirstRelayGraceMs?: number | false
showFeedClientFilter?: boolean showFeedClientFilter?: boolean
hostPrimaryPageName?: TPrimaryPageName
feedClientFilterTabRowHost?: HTMLElement | null
}, },
ref ref
) => { ) => {
@ -251,8 +339,13 @@ const NoteList = forwardRef(
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap() const { zapReplyThreshold } = useZap()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const eventsRef = useRef<Event[]>([]) const eventsRef = useRef<Event[]>([])
const [feedFullSearchEvents, setFeedFullSearchEvents] = useState<Event[] | null>(null)
const [feedFullSearchLoading, setFeedFullSearchLoading] = useState(false)
const feedFullSearchEventsRef = useRef<Event[] | null>(null)
const displayTimelineSourceRef = useRef<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true) const [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -261,10 +354,21 @@ const NoteList = forwardRef(
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
const [feedClientFilterOpen, setFeedClientFilterOpen] = useState(false) const [feedClientFilterOpen, setFeedClientFilterOpen] = useState(false)
const [feedClientSearch, setFeedClientSearch] = useState('') const [feedClientSearch, setFeedClientSearch] = useState('')
const [feedClientFromMeOnly, setFeedClientFromMeOnly] = useState(false) const [feedClientAuthorMode, setFeedClientAuthorMode] = useState<TFeedClientAuthorMode>('everyone')
const [feedClientAuthorNpubInput, setFeedClientAuthorNpubInput] = useState('')
const [feedClientTimeAmount, setFeedClientTimeAmount] = useState('') const [feedClientTimeAmount, setFeedClientTimeAmount] = useState('')
const [feedClientTimeUnit, setFeedClientTimeUnit] = useState<TFeedClientTimeUnit>('day') const [feedClientTimeUnit, setFeedClientTimeUnit] = useState<TFeedClientTimeUnit>('day')
const supportTouch = useMemo(() => isTouchDevice(), []) const supportTouch = useMemo(() => isTouchDevice(), [])
const timelineEventsForFilter = feedFullSearchEvents ?? events
useEffect(() => {
feedFullSearchEventsRef.current = feedFullSearchEvents
}, [feedFullSearchEvents])
useEffect(() => {
displayTimelineSourceRef.current = timelineEventsForFilter
}, [timelineEventsForFilter])
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)
const topRef = useRef<HTMLDivElement | null>(null) const topRef = useRef<HTMLDivElement | null>(null)
const spellFeedFirstPaintLoggedKeyRef = useRef('') const spellFeedFirstPaintLoggedKeyRef = useRef('')
@ -330,6 +434,52 @@ const NoteList = forwardRef(
) )
}, [subRequests]) }, [subRequests])
/** Feed identity for scoping client filter state (timeline key minus unrelated churn where possible). */
const feedClientFilterScopeKey = useMemo(
() => feedTimelineScopeKey ?? feedSubscriptionKey ?? subRequestsKey,
[feedTimelineScopeKey, feedSubscriptionKey, subRequestsKey]
)
const primaryPageCtx = usePrimaryPageOptional()
const primaryPageCurrent = primaryPageCtx?.current ?? null
/** Clears text/author/time/full-search; does not change panel open state. */
const clearFeedClientSearchCriteria = useCallback(() => {
setFeedClientSearch('')
setFeedClientAuthorMode('everyone')
setFeedClientAuthorNpubInput('')
setFeedClientTimeAmount('')
setFeedClientTimeUnit('day')
setFeedFullSearchEvents(null)
setFeedFullSearchLoading(false)
}, [])
const resetFeedClientFilterState = useCallback(() => {
clearFeedClientSearchCriteria()
setFeedClientFilterOpen(false)
}, [clearFeedClientSearchCriteria])
const onToggleFeedClientFilterPanel = useCallback(() => {
setFeedClientFilterOpen((wasOpen) => {
if (wasOpen) {
clearFeedClientSearchCriteria()
return false
}
return true
})
}, [clearFeedClientSearchCriteria])
useEffect(() => {
resetFeedClientFilterState()
}, [feedClientFilterScopeKey, resetFeedClientFilterState])
useEffect(() => {
if (hostPrimaryPageName === undefined) return
if (primaryPageCurrent !== hostPrimaryPageName) {
resetFeedClientFilterState()
}
}, [hostPrimaryPageName, primaryPageCurrent, resetFeedClientFilterState])
const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey
const prevSubRequestsKeyForTimelineRef = useRef<string | null>(null) const prevSubRequestsKeyForTimelineRef = useRef<string | null>(null)
const feedTimelineScopePrevRef = useRef<string | undefined>(undefined) const feedTimelineScopePrevRef = useRef<string | undefined>(undefined)
@ -367,7 +517,7 @@ const NoteList = forwardRef(
} }
} }
} }
for (const e of events) { for (const e of timelineEventsForFilter) {
addPk(e.pubkey) addPk(e.pubkey)
addPkFromEventTags(e) addPkFromEventTags(e)
} }
@ -388,7 +538,7 @@ const NoteList = forwardRef(
if (!changed) return prev if (!changed) return prev
return { ...prev, pending, version: prev.version + 1 } return { ...prev, pending, version: prev.version + 1 }
}) })
}, [events, newEvents]) }, [timelineEventsForFilter, newEvents])
const subRequestsRef = useRef(subRequests) const subRequestsRef = useRef(subRequests)
subRequestsRef.current = subRequests subRequestsRef.current = subRequests
@ -475,7 +625,7 @@ const NoteList = forwardRef(
if (isEventDeleted(evt)) return true if (isEventDeleted(evt)) return true
if (hideReplies && isReplyNoteEvent(evt)) return true if (hideReplies && isReplyNoteEvent(evt)) return true
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true
if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true if (filterMutedNotes && muteSetHas(mutePubkeySet, evt.pubkey)) return true
if ( if (
filterMutedNotes && filterMutedNotes &&
hideContentMentioningMutedUsers && hideContentMentioningMutedUsers &&
@ -501,8 +651,10 @@ const NoteList = forwardRef(
return false return false
}, },
[ [
filterMutedNotes,
hideReplies, hideReplies,
hideUntrustedNotes, hideUntrustedNotes,
hideContentMentioningMutedUsers,
mutePubkeySet, mutePubkeySet,
pinnedEventIds, pinnedEventIds,
isEventDeleted, isEventDeleted,
@ -519,7 +671,7 @@ const NoteList = forwardRef(
const filteredEvents = useMemo(() => { const filteredEvents = useMemo(() => {
const idSet = new Set<string>() const idSet = new Set<string>()
return events.slice(0, showCount).filter((evt) => { return timelineEventsForFilter.slice(0, showCount).filter((evt) => {
if (applyKindPickerInUi) { if (applyKindPickerInUi) {
if (!showKinds.includes(evt.kind)) return false if (!showKinds.includes(evt.kind)) return false
// Kind 1: show only OPs if showKind1OPs, only replies if showKind1Replies // Kind 1: show only OPs if showKind1OPs, only replies if showKind1Replies
@ -543,7 +695,7 @@ const NoteList = forwardRef(
return true return true
}) })
}, [ }, [
events, timelineEventsForFilter,
showCount, showCount,
shouldHideEvent, shouldHideEvent,
showKinds, showKinds,
@ -593,6 +745,8 @@ const NoteList = forwardRef(
]) ])
const filteredNewEvents = useMemo(() => { const filteredNewEvents = useMemo(() => {
if (feedFullSearchEvents !== null) return []
const idSet = new Set<string>() const idSet = new Set<string>()
return newEvents.filter((event: Event) => { return newEvents.filter((event: Event) => {
@ -618,6 +772,7 @@ const NoteList = forwardRef(
return true return true
}) })
}, [ }, [
feedFullSearchEvents,
newEvents, newEvents,
shouldHideEvent, shouldHideEvent,
showKinds, showKinds,
@ -634,12 +789,31 @@ const NoteList = forwardRef(
return dayjs().subtract(n, feedClientTimeUnit).unix() return dayjs().subtract(n, feedClientTimeUnit).unix()
}, [feedClientTimeAmount, feedClientTimeUnit]) }, [feedClientTimeAmount, feedClientTimeUnit])
const filterAuthorHexForRelayBootstrap = useMemo(() => {
if (feedClientAuthorMode === 'me' && pubkey) return pubkey
if (feedClientAuthorMode === 'npub') {
return inviteInputToHexPubkey(feedClientAuthorNpubInput)
}
return null
}, [feedClientAuthorMode, feedClientAuthorNpubInput, pubkey])
const applyClientFeedFilter = useCallback( const applyClientFeedFilter = useCallback(
(evts: Event[]) => { (evts: Event[]) => {
let rows = evts let rows = evts
if (feedClientFromMeOnly && pubkey) { if (feedClientAuthorMode === 'me' && pubkey) {
const p = pubkey.toLowerCase() const p = pubkey.toLowerCase()
rows = rows.filter((e) => e.pubkey.toLowerCase() === p) rows = rows.filter((e) => e.pubkey.toLowerCase() === p)
} else if (feedClientAuthorMode === 'npub') {
const raw = feedClientAuthorNpubInput.trim()
if (raw) {
const pk = inviteInputToHexPubkey(feedClientAuthorNpubInput)
if (pk) {
const pl = pk.toLowerCase()
rows = rows.filter((e) => e.pubkey.toLowerCase() === pl)
} else {
rows = []
}
}
} }
if (feedClientMinCreatedAt !== null) { if (feedClientMinCreatedAt !== null) {
rows = rows.filter((e) => e.created_at >= feedClientMinCreatedAt) rows = rows.filter((e) => e.created_at >= feedClientMinCreatedAt)
@ -658,7 +832,13 @@ const NoteList = forwardRef(
} }
return rows return rows
}, },
[feedClientFromMeOnly, pubkey, feedClientMinCreatedAt, feedClientSearch] [
feedClientAuthorMode,
feedClientAuthorNpubInput,
pubkey,
feedClientMinCreatedAt,
feedClientSearch
]
) )
const clientFilteredEvents = useMemo( const clientFilteredEvents = useMemo(
@ -678,10 +858,18 @@ const NoteList = forwardRef(
!!( !!(
showFeedClientFilter && showFeedClientFilter &&
(feedClientSearch.trim() || (feedClientSearch.trim() ||
feedClientFromMeOnly || (feedClientAuthorMode === 'me' && !!pubkey) ||
(feedClientAuthorMode === 'npub' && feedClientAuthorNpubInput.trim() !== '') ||
feedClientMinCreatedAt !== null) feedClientMinCreatedAt !== null)
), ),
[showFeedClientFilter, feedClientSearch, feedClientFromMeOnly, feedClientMinCreatedAt] [
showFeedClientFilter,
feedClientSearch,
feedClientAuthorMode,
feedClientAuthorNpubInput,
pubkey,
feedClientMinCreatedAt
]
) )
useLayoutEffect(() => { useLayoutEffect(() => {
@ -723,7 +911,7 @@ const NoteList = forwardRef(
} }
} }
} }
for (const e of events) { for (const e of timelineEventsForFilter) {
addPk(e.pubkey) addPk(e.pubkey)
addPkFromEventTags(e) addPkFromEventTags(e)
} }
@ -792,7 +980,7 @@ const NoteList = forwardRef(
})() })()
}, FEED_PROFILE_BATCH_DEBOUNCE_MS) }, FEED_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle) return () => window.clearTimeout(handle)
}, [events, newEvents]) }, [timelineEventsForFilter, newEvents])
const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => { const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => {
setTimeout(() => { setTimeout(() => {
@ -807,6 +995,117 @@ const NoteList = forwardRef(
}, 500) }, 500)
}, [scrollToTop]) }, [scrollToTop])
const onPerformFeedFullSearch = useCallback(async () => {
if (!showFeedClientFilter) return
const reqs = subRequestsRef.current
if (!reqs.length) {
toast.error(t('Feed full search invalid feed'))
return
}
const hasSearch = feedClientSearch.trim().length > 0
const hasTime = feedClientMinCreatedAt !== null
let hasAuthor = false
if (feedClientAuthorMode === 'me' && pubkey) hasAuthor = true
if (feedClientAuthorMode === 'npub' && inviteInputToHexPubkey(feedClientAuthorNpubInput)) {
hasAuthor = true
}
if (!hasSearch && !hasTime && !hasAuthor) {
toast.error(t('Feed full search need constraint'))
return
}
const base = buildNoteListMappedFilterForFullSearch(reqs[0]!, {
showKinds,
useFilterAsIs,
allowKindlessRelayExplore,
clientSideKindFilter,
seeAllFeedEvents,
areAlgoRelays
})
if (!base) {
toast.error(t('Feed full search invalid feed'))
return
}
const finalFilter: Filter = { ...base }
if (hasSearch) {
finalFilter.search = feedClientSearch.trim()
}
if (feedClientAuthorMode === 'me' && pubkey) {
finalFilter.authors = [pubkey]
} else if (feedClientAuthorMode === 'npub') {
const pk = inviteInputToHexPubkey(feedClientAuthorNpubInput)
if (pk) finalFilter.authors = [pk]
}
if (feedClientMinCreatedAt !== null) {
finalFilter.since = Math.max(
feedClientMinCreatedAt,
typeof finalFilter.since === 'number' ? finalFilter.since : 0
)
}
const hasRelayScope =
timelineFilterHasNonKindScope(finalFilter) ||
(typeof finalFilter.since === 'number' && finalFilter.since > 0) ||
(Array.isArray(finalFilter.kinds) && finalFilter.kinds.length > 0)
if (!hasRelayScope) {
toast.error(t('Feed full search need constraint'))
return
}
setFeedFullSearchLoading(true)
try {
const relayUrls = await buildFeedFullSearchRelayUrls({
viewerPubkey: pubkey ?? null,
filterAuthorHex: filterAuthorHexForRelayBootstrap,
favoriteRelays,
blockedRelays
})
if (relayUrls.length === 0) {
toast.error(t('Feed full search invalid feed'))
return
}
const raw = await client.fetchEvents(relayUrls, finalFilter, {
cache: true,
globalTimeout: 22_000,
eoseTimeout: 3500,
firstRelayResultGraceMs: false
})
const merged = mergeEventBatchesById([], raw, FEED_FULL_SEARCH_MERGE_CAP)
setFeedFullSearchEvents(merged)
setShowCount(revealBatchSize ?? SHOW_COUNT)
scrollToTop()
} catch (e) {
logger.warn('[NoteList] Feed full search failed', { error: e })
toast.error(t('Feed full search failed'))
} finally {
setFeedFullSearchLoading(false)
}
}, [
showFeedClientFilter,
feedClientSearch,
feedClientMinCreatedAt,
feedClientAuthorMode,
feedClientAuthorNpubInput,
pubkey,
filterAuthorHexForRelayBootstrap,
favoriteRelays,
blockedRelays,
showKinds,
useFilterAsIs,
allowKindlessRelayExplore,
clientSideKindFilter,
seeAllFeedEvents,
areAlgoRelays,
revealBatchSize,
scrollToTop,
t
])
const onClearFeedFullSearch = useCallback(() => {
setFeedFullSearchEvents(null)
}, [])
const emptyFeedHardReloadLongPress = useLongPressAction(hardReloadPreservingFeedSnapshots) const emptyFeedHardReloadLongPress = useLongPressAction(hardReloadPreservingFeedSnapshots)
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh]) useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh])
@ -1513,7 +1812,7 @@ const NoteList = forwardRef(
} }
const loadMore = async (): Promise<void> => { const loadMore = async (): Promise<void> => {
const currentEvents = eventsRef.current const currentEvents = displayTimelineSourceRef.current
const currentShowCount = showCountRef.current const currentShowCount = showCountRef.current
const currentLoading = loadingRef.current const currentLoading = loadingRef.current
const currentHasMore = hasMoreRef.current const currentHasMore = hasMoreRef.current
@ -1542,6 +1841,8 @@ const NoteList = forwardRef(
} }
} }
if (feedFullSearchEventsRef.current !== null) return
const canLoadFromTimeline = !!currentTimelineKey && currentHasMore const canLoadFromTimeline = !!currentTimelineKey && currentHasMore
if (currentLoading || (!canLoadFromTimeline && currentShowCount >= currentEvents.length)) return if (currentLoading || (!canLoadFromTimeline && currentShowCount >= currentEvents.length)) return
@ -1867,8 +2168,16 @@ const NoteList = forwardRef(
}, 0) }, 0)
} }
const feedClientFilterBar = ( const useFeedFilterTabRowPortal =
<div className="sticky top-0 z-20 border-b border-border/80 bg-background/95 px-1 py-1 backdrop-blur supports-[backdrop-filter]:bg-background/80"> showFeedClientFilter && typeof feedClientFilterTabRowHost !== 'undefined'
const feedClientFilterPanelSurfaceClass =
useFeedFilterTabRowPortal && feedClientFilterTabRowHost
? 'mt-1 space-y-3 w-full min-w-[min(100vw-2rem,22rem)] max-w-md rounded-md border border-border bg-background px-3 py-3 shadow-md'
: 'space-y-3 border-t border-border/60 py-3'
const feedClientFilterChrome = (
<>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
type="button" type="button"
@ -1879,13 +2188,13 @@ const NoteList = forwardRef(
aria-controls="feed-client-filter-panel" aria-controls="feed-client-filter-panel"
aria-label={t('Feed filter')} aria-label={t('Feed filter')}
title={t('Feed filter')} title={t('Feed filter')}
onClick={() => setFeedClientFilterOpen((o) => !o)} onClick={onToggleFeedClientFilterPanel}
> >
<span aria-hidden>🔍</span> <span aria-hidden>🔍</span>
</Button> </Button>
</div> </div>
{feedClientFilterOpen ? ( {feedClientFilterOpen ? (
<div id="feed-client-filter-panel" className="space-y-3 border-t border-border/60 py-3"> <div id="feed-client-filter-panel" className={feedClientFilterPanelSurfaceClass}>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="feed-client-search" className="text-sm font-medium"> <Label htmlFor="feed-client-search" className="text-sm font-medium">
{t('Search loaded posts')} {t('Search loaded posts')}
@ -1899,15 +2208,53 @@ const NoteList = forwardRef(
className="w-full" className="w-full"
/> />
</div> </div>
{pubkey ? ( <div className="space-y-2">
<Label className="text-sm font-medium">{t('Feed filter author')}</Label>
<RadioGroup
value={feedClientAuthorMode}
onValueChange={(v) => setFeedClientAuthorMode(v as TFeedClientAuthorMode)}
className="grid gap-2"
>
<label className="flex cursor-pointer items-center gap-2 text-sm"> <label className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox <RadioGroupItem value="everyone" id="feed-client-author-everyone" />
checked={feedClientFromMeOnly} <span>{t('Feed filter author everyone')}</span>
onCheckedChange={(v) => setFeedClientFromMeOnly(v === true)} </label>
/> <label
{t('From me only')} className={`flex cursor-pointer items-center gap-2 text-sm ${!pubkey ? 'cursor-not-allowed opacity-60' : ''}`}
title={!pubkey ? t('Feed filter author me needs login') : undefined}
>
<RadioGroupItem value="me" id="feed-client-author-me" disabled={!pubkey} />
<span>{t('Feed filter author me')}</span>
</label> </label>
<div className="space-y-1.5">
<label className="flex cursor-pointer items-center gap-2 text-sm">
<RadioGroupItem value="npub" id="feed-client-author-npub" />
<span>{t('Feed filter author npub')}</span>
</label>
{feedClientAuthorMode === 'npub' ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 pl-6">
<span className="text-sm text-muted-foreground">
{t('Feed filter author npub from prefix')}
</span>
<Input
id="feed-client-author-npub-input"
value={feedClientAuthorNpubInput}
onChange={(e) => setFeedClientAuthorNpubInput(e.target.value)}
placeholder={t('Feed filter author npub placeholder')}
autoComplete="off"
className="min-w-[12rem] flex-1"
aria-invalid={
feedClientAuthorNpubInput.trim() !== '' &&
!inviteInputToHexPubkey(feedClientAuthorNpubInput)
? true
: undefined
}
/>
</div>
) : null} ) : null}
</div>
</RadioGroup>
</div>
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
<div className="grid min-w-0 flex-1 gap-1.5 sm:max-w-[10rem]"> <div className="grid min-w-0 flex-1 gap-1.5 sm:max-w-[10rem]">
<Label htmlFor="feed-client-time-n" className="text-sm font-medium"> <Label htmlFor="feed-client-time-n" className="text-sm font-medium">
@ -1948,11 +2295,49 @@ const NoteList = forwardRef(
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground">{t('Feed filter client-side hint')}</p> <p className="text-xs text-muted-foreground">{t('Feed filter client-side hint')}</p>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button
type="button"
variant="secondary"
size="sm"
disabled={feedFullSearchLoading}
onClick={() => void onPerformFeedFullSearch()}
>
{feedFullSearchLoading ? t('Feed full search running') : t('Feed full search')}
</Button>
{feedFullSearchEvents !== null ? (
<Button type="button" variant="outline" size="sm" onClick={onClearFeedFullSearch}>
{t('Feed full search clear')}
</Button>
) : null}
</div>
{feedFullSearchEvents !== null ? (
<p className="text-xs text-muted-foreground">{t('Feed full search active hint')}</p>
) : null}
</div> </div>
) : null} ) : null}
</>
)
const feedClientFilterBarEmbedded = (
<div className="sticky top-0 z-20 border-b border-border/80 bg-background/95 px-1 py-1 backdrop-blur supports-[backdrop-filter]:bg-background/80">
{feedClientFilterChrome}
</div> </div>
) )
const feedClientFilterBar =
useFeedFilterTabRowPortal && feedClientFilterTabRowHost
? createPortal(
<div className="flex flex-col items-end gap-0">{feedClientFilterChrome}</div>,
feedClientFilterTabRowHost
)
: useFeedFilterTabRowPortal && !feedClientFilterTabRowHost
? null
: feedClientFilterBarEmbedded
const listSourceEvents = timelineEventsForFilter
const feedFullSearchActive = feedFullSearchEvents !== null
const list = ( const list = (
<div className="min-h-screen"> <div className="min-h-screen">
{feedClientFilterActive && filteredEvents.length > 0 && clientFilteredEvents.length === 0 ? ( {feedClientFilterActive && filteredEvents.length > 0 && clientFilteredEvents.length === 0 ? (
@ -1960,6 +2345,11 @@ const NoteList = forwardRef(
{t('No loaded posts match your filters.')} {t('No loaded posts match your filters.')}
</div> </div>
) : null} ) : null}
{feedFullSearchActive && listSourceEvents.length === 0 && !feedFullSearchLoading ? (
<div className="px-2 py-8 text-center text-sm text-muted-foreground">
{t('Feed full search empty')}
</div>
) : null}
{clientFilteredEvents.map((event) => ( {clientFilteredEvents.map((event) => (
<NoteCard <NoteCard
key={event.id} key={event.id}
@ -1968,7 +2358,8 @@ const NoteList = forwardRef(
filterMutedNotes={filterMutedNotes} filterMutedNotes={filterMutedNotes}
/> />
))} ))}
{events.length === 0 && {listSourceEvents.length === 0 &&
!feedFullSearchActive &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? ( (loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
<div <div
ref={bottomRef} ref={bottomRef}
@ -1981,7 +2372,8 @@ const NoteList = forwardRef(
<NoteCardLoadingSkeleton key={i} /> <NoteCardLoadingSkeleton key={i} />
))} ))}
</div> </div>
) : events.length > 0 && hasMore ? ( ) : listSourceEvents.length > 0 &&
(feedFullSearchActive ? showCount < listSourceEvents.length : hasMore) ? (
<div <div
ref={bottomRef} ref={bottomRef}
className={ className={
@ -1994,9 +2386,13 @@ const NoteList = forwardRef(
> >
{loading ? <NoteCardLoadingSkeleton /> : null} {loading ? <NoteCardLoadingSkeleton /> : null}
</div> </div>
) : events.length > 0 ? ( ) : listSourceEvents.length > 0 ? (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div> <div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
) : !loading && feedTimelineEmptyUiReady && subRequests.length > 0 ? ( ) : listSourceEvents.length === 0 &&
!feedFullSearchActive &&
!loading &&
feedTimelineEmptyUiReady &&
subRequests.length > 0 ? (
<div <div
ref={bottomRef} ref={bottomRef}
className="mt-6 flex min-h-[35vh] flex-col items-center justify-start gap-4 px-4 text-center text-sm text-muted-foreground" className="mt-6 flex min-h-[35vh] flex-col items-center justify-start gap-4 px-4 text-center text-sm text-muted-foreground"

3
src/components/NoteOptions/useMenuActions.tsx

@ -16,6 +16,7 @@ import { generateBech32IdFromATag } from '@/lib/tag'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -128,7 +129,7 @@ export function useMenuActions({
}) })
}, []) }, [])
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList() const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList()
const isMuted = useMemo(() => mutePubkeySet.has(event.pubkey), [mutePubkeySet, event]) const isMuted = useMemo(() => muteSetHas(mutePubkeySet, event.pubkey), [mutePubkeySet, event])
// Check if event is pinned // Check if event is pinned
const [isPinned, setIsPinned] = useState(false) const [isPinned, setIsPinned] = useState(false)

3
src/components/PostEditor/Mentions.tsx

@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns'
@ -46,7 +47,7 @@ export default function Mentions({
pubkeys pubkeys
.filter((p) => potentialMentions.includes(p)) .filter((p) => potentialMentions.includes(p))
.concat( .concat(
potentialMentions.filter((p) => mutePubkeySet.has(p) && p !== _parentEventPubkey) potentialMentions.filter((p) => muteSetHas(mutePubkeySet, p) && p !== _parentEventPubkey)
) )
) )
) )

1
src/components/Profile/ProfileMediaFeed.tsx

@ -123,6 +123,7 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey
ref={ref} ref={ref}
subRequests={subRequests} subRequests={subRequests}
feedSubscriptionKey={feedSubscriptionKey} feedSubscriptionKey={feedSubscriptionKey}
hostPrimaryPageName="profile"
showKinds={showKinds} showKinds={showKinds}
useFilterAsIs useFilterAsIs
/** /**

3
src/components/ProfileOptions/index.tsx

@ -10,6 +10,7 @@ import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
@ -78,7 +79,7 @@ export default function ProfileOptions({
fetchEvent() fetchEvent()
}, [pubkey, profileEvent]) }, [pubkey, profileEvent])
const isMuted = useMemo(() => mutePubkeySet.has(pubkey), [mutePubkeySet, pubkey]) const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey])
const displayName = profile?.username ?? (accountPubkey ? formatPubkey(accountPubkey) : 'jumble') const displayName = profile?.username ?? (accountPubkey ? formatPubkey(accountPubkey) : 'jumble')
/** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */ /** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */

10
src/components/Relay/index.tsx

@ -3,16 +3,17 @@ import type { TNoteListRef } from '@/components/NoteList'
import RelayInfo from '@/components/RelayInfo' import RelayInfo from '@/components/RelayInfo'
import SearchInput from '@/components/SearchInput' import SearchInput from '@/components/SearchInput'
import { useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo } from '@/hooks'
import type { TPrimaryPageName } from '@/PageManager'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NotFound from '../NotFound' import NotFound from '../NotFound'
const Relay = forwardRef<TNoteListRef, { url?: string; className?: string }>(function Relay( const Relay = forwardRef<
{ url, className }, TNoteListRef,
ref { url?: string; className?: string; hostPrimaryPageName?: TPrimaryPageName }
) { >(function Relay({ url, className, hostPrimaryPageName }, ref) {
const { t } = useTranslation() const { t } = useTranslation()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays() const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
@ -85,6 +86,7 @@ const Relay = forwardRef<TNoteListRef, { url?: string; className?: string }>(fun
useFilterAsIs useFilterAsIs
allowKindlessRelayExplore allowKindlessRelayExplore
showFeedClientFilter showFeedClientFilter
hostPrimaryPageName={hostPrimaryPageName}
/> />
</div> </div>
) )

3
src/components/RelayInfo/RelayReviewsPreview.tsx

@ -14,6 +14,7 @@ import { toRelayReviews } from '@/lib/link'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { cn, isTouchDevice } from '@/lib/utils' import { cn, isTouchDevice } from '@/lib/utils'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
@ -54,7 +55,7 @@ export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string })
const ingestReviewEvent = useCallback( const ingestReviewEvent = useCallback(
(evt: NostrEvent) => { (evt: NostrEvent) => {
if (mutePubkeySet.has(evt.pubkey)) return if (muteSetHas(mutePubkeySet, evt.pubkey)) return
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return
const stars = getStarsFromRelayReviewEvent(evt) const stars = getStarsFromRelayReviewEvent(evt)
if (!stars) return if (!stars) return

3
src/components/ReplyNote/index.tsx

@ -18,6 +18,7 @@ import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@ -74,7 +75,7 @@ export default function ReplyNote({
if (showMuted) { if (showMuted) {
return true return true
} }
if (mutePubkeySet.has(event.pubkey)) { if (muteSetHas(mutePubkeySet, event.pubkey)) {
return false return false
} }
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) { if (hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) {

18
src/i18n/locales/de.ts

@ -780,7 +780,13 @@ export default {
'Feed filter': 'Feed-Filter', 'Feed filter': 'Feed-Filter',
'Search loaded posts': 'Geladene Beiträge durchsuchen', 'Search loaded posts': 'Geladene Beiträge durchsuchen',
'Filter loaded posts placeholder': 'Nach Text in Inhalt oder Tags filtern…', 'Filter loaded posts placeholder': 'Nach Text in Inhalt oder Tags filtern…',
'From me only': 'Nur von mir', 'Feed filter author': 'Autor',
'Feed filter author everyone': 'Von allen',
'Feed filter author me': 'Nur von mir',
'Feed filter author npub': 'Von Benutzer (npub oder Hex)',
'Feed filter author npub from prefix': 'von:',
'Feed filter author npub placeholder': 'npub1… oder 64-Zeichen-Hex',
'Feed filter author me needs login': 'Zum Filtern nach Ihrem Pubkey anmelden',
'Within the last': 'Innerhalb der letzten', 'Within the last': 'Innerhalb der letzten',
'Time unit': 'Zeiteinheit', 'Time unit': 'Zeiteinheit',
Minutes: 'Minuten', Minutes: 'Minuten',
@ -790,6 +796,16 @@ export default {
Years: 'Jahre', Years: 'Jahre',
'Feed filter client-side hint': 'Feed filter client-side hint':
'Filter gelten nur für bereits geladene Beiträge; Relays werden nicht erneut abgefragt.', 'Filter gelten nur für bereits geladene Beiträge; Relays werden nicht erneut abgefragt.',
'Feed full search': 'Vollständige Suche starten',
'Feed full search running': 'Suche läuft…',
'Feed full search clear': 'Zurücksetzen',
'Feed full search active hint':
'Es werden Ergebnisse einer Relay-Suche angezeigt. Zurücksetzen, um zum Live-Feed zurückzukehren.',
'Feed full search need constraint':
'Bitte Suchtext, Autorenfilter oder einen Zeitraum setzen, bevor Relays abgefragt werden.',
'Feed full search invalid feed': 'Für diesen Feed ist keine Relay-Suche möglich.',
'Feed full search failed': 'Relay-Suche fehlgeschlagen. Bitte erneut versuchen.',
'Feed full search empty': 'Auf den abgefragten Relays wurden keine passenden Beiträge gefunden.',
'No loaded posts match your filters.': 'Keine geladenen Beiträge entsprechen den Filtern.', 'No loaded posts match your filters.': 'Keine geladenen Beiträge entsprechen den Filtern.',
'mentioned you in a note': 'hat Sie in einer Notiz erwähnt', 'mentioned you in a note': 'hat Sie in einer Notiz erwähnt',
'quoted your note': 'hat Ihre Notiz zitiert', 'quoted your note': 'hat Ihre Notiz zitiert',

18
src/i18n/locales/en.ts

@ -824,7 +824,13 @@ export default {
'Feed filter': 'Feed filter', 'Feed filter': 'Feed filter',
'Search loaded posts': 'Search loaded posts', 'Search loaded posts': 'Search loaded posts',
'Filter loaded posts placeholder': 'Filter by text in content or tags…', 'Filter loaded posts placeholder': 'Filter by text in content or tags…',
'From me only': 'From me only', 'Feed filter author': 'Author',
'Feed filter author everyone': 'From everyone',
'Feed filter author me': 'Only from me',
'Feed filter author npub': 'From user (npub or hex)',
'Feed filter author npub from prefix': 'from:',
'Feed filter author npub placeholder': 'npub1… or 64-char hex',
'Feed filter author me needs login': 'Log in to filter by your pubkey',
'Within the last': 'Within the last', 'Within the last': 'Within the last',
'Time unit': 'Time unit', 'Time unit': 'Time unit',
Minutes: 'Minutes', Minutes: 'Minutes',
@ -834,6 +840,16 @@ export default {
Years: 'Years', Years: 'Years',
'Feed filter client-side hint': 'Feed filter client-side hint':
'Filters only apply to posts already loaded; relays are not queried again.', 'Filters only apply to posts already loaded; relays are not queried again.',
'Feed full search': 'Perform full search',
'Feed full search running': 'Searching…',
'Feed full search clear': 'Clear',
'Feed full search active hint':
'Showing relay search results. Clear to return to the live feed.',
'Feed full search need constraint':
'Add search text, an author filter, or a time range before searching relays.',
'Feed full search invalid feed': 'This feed cannot run a relay search.',
'Feed full search failed': 'Relay search failed. Try again.',
'Feed full search empty': 'No matching posts were found on the queried relays.',
'No loaded posts match your filters.': 'No loaded posts match your filters.', 'No loaded posts match your filters.': 'No loaded posts match your filters.',
'mentioned you in a note': 'mentioned you in a note', 'mentioned you in a note': 'mentioned you in a note',
'quoted your note': 'quoted your note', 'quoted your note': 'quoted your note',

3
src/lib/event.ts

@ -1,4 +1,5 @@
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import { muteSetHas } from '@/lib/mute-set'
import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns' import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns'
import { cleanUrl } from '@/lib/url' import { cleanUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -83,7 +84,7 @@ export function isProtectedEvent(event: Event) {
export function isMentioningMutedUsers(event: Event, mutePubkeySet: Set<string>) { export function isMentioningMutedUsers(event: Event, mutePubkeySet: Set<string>) {
for (const [tagName, pubkey] of event.tags) { for (const [tagName, pubkey] of event.tags) {
if (tagName === 'p' && mutePubkeySet.has(pubkey)) { if (tagName === 'p' && muteSetHas(mutePubkeySet, pubkey)) {
return true return true
} }
} }

68
src/lib/feed-full-search-relays.ts

@ -0,0 +1,68 @@
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import {
getFavoritesFeedRelayUrls,
mergeRelayUrlLayers
} from '@/lib/favorites-feed-relays'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url'
/**
* Relay stack for search loaded posts **full relay search**: searchable relays, favorites (kind 10012 + defaults),
* logged-in account read/write merge, then {@link buildComprehensiveRelayList} (user NIP-65 + local + profile + fast
* read/write + search + favorites). When {@link filterAuthorHex} differs from the viewer, that authors NIP-65 in/out
* (incl. http) is included via `authorPubkey`.
*/
export async function buildFeedFullSearchRelayUrls(options: {
viewerPubkey: string | null | undefined
filterAuthorHex: string | null | undefined
favoriteRelays: string[]
blockedRelays: string[]
}): Promise<string[]> {
const { viewerPubkey, filterAuthorHex, favoriteRelays, blockedRelays } = options
const blocked = blockedRelays ?? []
const layers: string[][] = []
const searchable = SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
layers.push(searchable)
layers.push(getFavoritesFeedRelayUrls(favoriteRelays ?? [], blocked))
if (viewerPubkey) {
try {
const account = await buildAccountListRelayUrlsForMerge({
accountPubkey: viewerPubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays: blocked
})
layers.push(account)
} catch {
/* continue with other layers */
}
}
const viewerLower = viewerPubkey?.toLowerCase()
const authorLower = filterAuthorHex?.toLowerCase()
const authorForN65 =
filterAuthorHex && authorLower !== viewerLower ? filterAuthorHex : undefined
try {
const comprehensive = await buildComprehensiveRelayList({
userPubkey: viewerPubkey ?? undefined,
authorPubkey: authorForN65,
includeUserOwnRelays: !!viewerPubkey,
includeProfileFetchRelays: true,
includeFastReadRelays: true,
includeFastWriteRelays: true,
includeSearchableRelays: true,
includeLocalRelays: true,
includeFavoriteRelays: !!viewerPubkey,
blockedRelays: blocked
})
layers.push(comprehensive)
} catch {
/* merge without comprehensive */
}
return mergeRelayUrlLayers(layers, blocked)
}

7
src/lib/mute-set.ts

@ -0,0 +1,7 @@
/**
* Mute pubkey sets use lowercase hex so lookups match Nostr events and `p` tags regardless of casing.
*/
export function muteSetHas(mutePubkeySet: Set<string>, pubkey: string | undefined | null): boolean {
if (!pubkey) return false
return mutePubkeySet.has(pubkey.toLowerCase())
}

3
src/lib/notification.ts

@ -2,6 +2,7 @@ import { kinds, NostrEvent } from 'nostr-tools'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { hexPubkeysEqual } from '@/lib/pubkey' import { hexPubkeysEqual } from '@/lib/pubkey'
import { isMentioningMutedUsers } from './event' import { isMentioningMutedUsers } from './event'
import { muteSetHas } from './mute-set'
import { tagNameEquals } from './tag' import { tagNameEquals } from './tag'
export function notificationFilter( export function notificationFilter(
@ -21,7 +22,7 @@ export function notificationFilter(
} }
): boolean { ): boolean {
if ( if (
mutePubkeySet.has(event.pubkey) || muteSetHas(mutePubkeySet, event.pubkey) ||
(hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) || (hideContentMentioningMutedUsers && isMentioningMutedUsers(event, mutePubkeySet)) ||
(hideUntrustedNotifications && !isUserTrusted(event.pubkey)) (hideUntrustedNotifications && !isUserTrusted(event.pubkey))
) { ) {

3
src/lib/thread-response-filter.ts

@ -1,4 +1,5 @@
import { isMentioningMutedUsers } from '@/lib/event' import { isMentioningMutedUsers } from '@/lib/event'
import { muteSetHas } from '@/lib/mute-set'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
@ -18,7 +19,7 @@ export function shouldHideThreadResponseEvent(
mutePubkeySet: Set<string>, mutePubkeySet: Set<string>,
hideContentMentioningMutedUsers: boolean | undefined hideContentMentioningMutedUsers: boolean | undefined
): boolean { ): boolean {
if (mutePubkeySet.has(evt.pubkey)) return true if (muteSetHas(mutePubkeySet, evt.pubkey)) return true
if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true
return false return false
} }

1
src/pages/primary/NoteListPage/FollowingFeed.tsx

@ -90,6 +90,7 @@ const FollowingFeed = forwardRef<
setSubHeader={setSubHeader} setSubHeader={setSubHeader}
onSubHeaderRefresh={onSubHeaderRefresh} onSubHeaderRefresh={onSubHeaderRefresh}
showFeedClientFilter={false} showFeedClientFilter={false}
hostPrimaryPageName="feed"
/> />
) )
}) })

1
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -134,6 +134,7 @@ const RelaysFeed = forwardRef<
allowKindlessRelayExplore={singleRelayKindlessExplore} allowKindlessRelayExplore={singleRelayKindlessExplore}
clientSideKindFilter={singleRelayKindlessExplore} clientSideKindFilter={singleRelayKindlessExplore}
showFeedClientFilter showFeedClientFilter
hostPrimaryPageName="feed"
/> />
) )
}) })

2
src/pages/primary/RelayPage/index.tsx

@ -33,7 +33,7 @@ const RelayPage = forwardRef<TPageRef, { url?: string }>(({ url }, ref) => {
ref={layoutRef} ref={layoutRef}
> >
<div className="min-w-0 pt-2"> <div className="min-w-0 pt-2">
<Relay ref={feedRef} url={normalizedUrl} /> <Relay ref={feedRef} url={normalizedUrl} hostPrimaryPageName="relay" />
</div> </div>
</PrimaryPageLayout> </PrimaryPageLayout>
) )

2
src/pages/primary/SpellsPage/CreateSpellDialog.tsx

@ -18,7 +18,7 @@ import {
dedupeAppendIds, dedupeAppendIds,
resolveSpellListATags resolveSpellListATags
} from '@/lib/spell-list-import' } from '@/lib/spell-list-import'
import { useBookmarks } from '@/providers/BookmarksProvider' import { useBookmarks } from '@/providers/bookmarks-context'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback'

4
src/pages/primary/SpellsPage/index.tsx

@ -27,7 +27,7 @@ import { cn } from '@/lib/utils'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
import { useBookmarks } from '@/providers/BookmarksProvider' import { useBookmarks } from '@/providers/bookmarks-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
@ -1676,6 +1676,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
ref={spellFeedListRef} ref={spellFeedListRef}
subRequests={subRequests} subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey} feedSubscriptionKey={spellFeedSubscriptionKey}
hostPrimaryPageName="spells"
showKinds={showKinds} showKinds={showKinds}
spellFeedInstrumentToken={spellFeedInstrumentToken} spellFeedInstrumentToken={spellFeedInstrumentToken}
onSpellFeedFirstPaint={handleSpellFeedFirstPaint} onSpellFeedFirstPaint={handleSpellFeedFirstPaint}
@ -1724,6 +1725,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
ref={spellFeedListRef} ref={spellFeedListRef}
subRequests={subRequests} subRequests={subRequests}
feedSubscriptionKey={spellFeedSubscriptionKey} feedSubscriptionKey={spellFeedSubscriptionKey}
hostPrimaryPageName="spells"
showKinds={showKinds} showKinds={showKinds}
spellFeedInstrumentToken={spellFeedInstrumentToken} spellFeedInstrumentToken={spellFeedInstrumentToken}
onSpellFeedFirstPaint={handleSpellFeedFirstPaint} onSpellFeedFirstPaint={handleSpellFeedFirstPaint}

24
src/providers/BookmarksProvider.tsx

@ -4,30 +4,12 @@ import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/eve
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import client from '@/services/client.service' import client from '@/services/client.service'
import { kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { Event } from 'nostr-tools' import { useCallback } from 'react'
import { createContext, useCallback, useContext } from 'react' import { BookmarksContext } from '@/providers/bookmarks-context'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useFavoriteRelays } from './FavoriteRelaysProvider'
type TBookmarksContext = {
addBookmark: (event: Event) => Promise<void>
removeBookmark: (event: Event) => Promise<void>
}
const BookmarksContext = createContext<TBookmarksContext | undefined>(undefined)
export const useBookmarks = () => {
const context = useContext(BookmarksContext)
if (!context) {
throw new Error('useBookmarks must be used within a BookmarksProvider')
}
return context
}
/** Returns undefined when outside BookmarksProvider (e.g. embedded notes in createRoot trees). */
export const useBookmarksOptional = () => useContext(BookmarksContext)
export function BookmarksProvider({ children }: { children: React.ReactNode }) { export function BookmarksProvider({ children }: { children: React.ReactNode }) {
const { pubkey: accountPubkey, publish, updateBookmarkListEvent } = useNostr() const { pubkey: accountPubkey, publish, updateBookmarkListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()

60
src/providers/MuteListProvider.tsx

@ -19,6 +19,19 @@ import { z } from 'zod'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useFavoriteRelays } from './FavoriteRelaysProvider'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { muteSetHas } from '@/lib/mute-set'
/**
* Decryption failures are common and usually benign (npub-only session, extension declined NIP-04,
* legacy/other-client ciphertext, corrupted relay copy). Log at most once per event id per load.
*/
const muteListPrivateSectionIssueLogged = new Set<string>()
function logMuteListPrivateIssueOnce(eventId: string, message: string, detail?: Record<string, unknown>) {
if (muteListPrivateSectionIssueLogged.has(eventId)) return
muteListPrivateSectionIssueLogged.add(eventId)
logger.warn(message, { eventId, ...detail })
}
export function MuteListProvider({ children }: { children: ReactNode }) { export function MuteListProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -33,9 +46,12 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [tags, setTags] = useState<string[][]>([]) const [tags, setTags] = useState<string[][]>([])
const [privateTags, setPrivateTags] = useState<string[][]>([]) const [privateTags, setPrivateTags] = useState<string[][]>([])
const publicMutePubkeySet = useMemo(() => new Set(getPubkeysFromPTags(tags)), [tags]) const publicMutePubkeySet = useMemo(
() => new Set(getPubkeysFromPTags(tags).map((p) => p.toLowerCase())),
[tags]
)
const privateMutePubkeySet = useMemo( const privateMutePubkeySet = useMemo(
() => new Set(getPubkeysFromPTags(privateTags)), () => new Set(getPubkeysFromPTags(privateTags).map((p) => p.toLowerCase())),
[privateTags] [privateTags]
) )
const mutePubkeySet = useMemo(() => { const mutePubkeySet = useMemo(() => {
@ -43,6 +59,10 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
}, [publicMutePubkeySet, privateMutePubkeySet]) }, [publicMutePubkeySet, privateMutePubkeySet])
const [changing, setChanging] = useState(false) const [changing, setChanging] = useState(false)
useEffect(() => {
muteListPrivateSectionIssueLogged.clear()
}, [accountPubkey])
const getPrivateTags = async (muteListEvent: Event) => { const getPrivateTags = async (muteListEvent: Event) => {
if (!muteListEvent.content) return [] if (!muteListEvent.content) return []
@ -50,18 +70,42 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
if (storedDecryptedTags) { if (storedDecryptedTags) {
return storedDecryptedTags return storedDecryptedTags
} else { }
let plainText: string
try {
plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
} catch (error) {
logMuteListPrivateIssueOnce(
muteListEvent.id,
'Mute list private section could not be decrypted (public mutes still apply). Use a signing-capable login for private mutes.',
{ cause: error instanceof Error ? error.message : String(error) }
)
return []
}
if (!plainText.trim()) {
logMuteListPrivateIssueOnce(
muteListEvent.id,
'Mute list has ciphertext but decryption returned empty (e.g. read-only / npub-only login). Public mutes still apply.',
undefined
)
return []
}
try { try {
const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText)) const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags) await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags)
return privateTags return privateTags
} catch (error) { } catch (error) {
logger.error('Failed to decrypt mute list content', { error, eventId: muteListEvent.id }) logMuteListPrivateIssueOnce(
muteListEvent.id,
'Mute list decrypted but private payload was not valid JSON (public mutes still apply).',
{ cause: error instanceof Error ? error.message : String(error) }
)
return [] return []
} }
} }
}
useEffect(() => { useEffect(() => {
const updateMuteTags = async () => { const updateMuteTags = async () => {
@ -86,8 +130,8 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
const getMuteType = useCallback( const getMuteType = useCallback(
(pubkey: string): 'public' | 'private' | null => { (pubkey: string): 'public' | 'private' | null => {
if (publicMutePubkeySet.has(pubkey)) return 'public' if (muteSetHas(publicMutePubkeySet, pubkey)) return 'public'
if (privateMutePubkeySet.has(pubkey)) return 'private' if (muteSetHas(privateMutePubkeySet, pubkey)) return 'private'
return null return null
}, },
[publicMutePubkeySet, privateMutePubkeySet] [publicMutePubkeySet, privateMutePubkeySet]

10
src/providers/NostrProvider/index.tsx

@ -1349,11 +1349,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const updateMuteListEvent = async (muteListEvent: Event, privateTags: string[][]) => { const updateMuteListEvent = async (muteListEvent: Event, privateTags: string[][]) => {
const newMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent) const storedWinner = await indexedDb.putReplaceableEvent(muteListEvent)
if (newMuteListEvent.id !== muteListEvent.id) return if (storedWinner.id === muteListEvent.id) {
await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags) await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags)
setMuteListEvent(muteListEvent) setMuteListEvent(muteListEvent)
return
}
// IndexedDB kept a different replaceable winner (e.g. higher created_at). Sync UI to storage
// so feeds do not keep showing notes that should be hidden while state still pointed at the losing event.
setMuteListEvent(storedWinner)
} }
const updateBookmarkListEvent = async (bookmarkListEvent: Event) => { const updateBookmarkListEvent = async (bookmarkListEvent: Event) => {

27
src/providers/bookmarks-context.tsx

@ -0,0 +1,27 @@
/**
* Standalone bookmarks context so lazy routes (e.g. SpellsPage) and the app shell share one
* `createContext()` identity. Without this, Vite can evaluate `BookmarksProvider.tsx` twice across
* chunks and `useBookmarks` sees a different context than `<BookmarksProvider>` provides.
*/
import type { Event } from 'nostr-tools'
import { createContext, useContext } from 'react'
export type TBookmarksContext = {
addBookmark: (event: Event) => Promise<void>
removeBookmark: (event: Event) => Promise<void>
}
export const BookmarksContext = createContext<TBookmarksContext | undefined>(undefined)
export function useBookmarks(): TBookmarksContext {
const context = useContext(BookmarksContext)
if (!context) {
throw new Error('useBookmarks must be used within a BookmarksProvider')
}
return context
}
/** Returns undefined when outside BookmarksProvider (e.g. embedded notes in createRoot trees). */
export function useBookmarksOptional(): TBookmarksContext | undefined {
return useContext(BookmarksContext)
}
Loading…
Cancel
Save