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

7
src/components/ContentPreview/FollowPackPreview.tsx

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

3
src/components/ContentPreview/index.tsx

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

5
src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx

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

9
src/components/FavoriteRelaysActiveStrip/index.tsx

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

3
src/components/FollowButton/index.tsx

@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button' @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useFollowListOptional } from '@/providers/FollowListProvider'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -28,7 +29,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) { @@ -28,7 +29,7 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
const followings = followList?.followings ?? []
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

3
src/components/LatestFromFollowsSection/index.tsx

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

3
src/components/MuteButton/index.tsx

@ -8,6 +8,7 @@ import { @@ -8,6 +8,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { BellOff } from 'lucide-react'
@ -22,7 +23,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) { @@ -22,7 +23,7 @@ export default function MuteButton({ pubkey }: { pubkey: string }) {
const { mutePubkeySet, changing, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } =
useMuteList()
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

83
src/components/NormalFeed/index.tsx

@ -4,8 +4,10 @@ import Tabs, { TabDefinition } from '@/components/Tabs' @@ -4,8 +4,10 @@ import Tabs, { TabDefinition } from '@/components/Tabs'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
import storage from '@/services/local-storage.service'
import type { TPrimaryPageName } from '@/PageManager'
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'
const NormalFeed = forwardRef<TNoteListRef, {
@ -34,6 +36,8 @@ 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.
*/
showFeedClientFilter?: boolean
/** When set, {@link NoteList} clears 🔍 filters when another primary tab is shown (mounted-but-hidden pages). */
hostPrimaryPageName?: TPrimaryPageName
}>(function NormalFeed(
{
subRequests,
@ -48,7 +52,8 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -48,7 +52,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
useFilterAsIs = false,
clientSideKindFilter = false,
allowKindlessRelayExplore = false,
showFeedClientFilter: showFeedClientFilterProp
showFeedClientFilter: showFeedClientFilterProp,
hostPrimaryPageName
},
ref
) {
@ -67,6 +72,10 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -67,6 +72,10 @@ const NormalFeed = forwardRef<TNoteListRef, {
})
const internalNoteListRef = useRef<TNoteListRef>(null)
const noteListRef = ref || internalNoteListRef
const [feedFilterTabRowHost, setFeedFilterTabRowHost] = useState<HTMLDivElement | null>(null)
const onFeedFilterTabRowSlotRef = useCallback((node: HTMLDivElement | null) => {
setFeedFilterTabRowHost(node)
}, [])
const tabs = useMemo(
(): TabDefinition[] => [
@ -76,7 +85,8 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -76,7 +85,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
[]
)
const handleListModeChange = (mode: TNoteListMode | string) => {
const handleListModeChange = useCallback(
(mode: TNoteListMode | string) => {
const noteListMode = mode as TNoteListMode
setListMode(noteListMode)
if (isMainFeed) {
@ -86,13 +96,15 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -86,13 +96,15 @@ const NormalFeed = forwardRef<TNoteListRef, {
if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.scrollToTop('smooth')
}
}
},
[isMainFeed, noteListRef]
)
const handleShowKindsChange = (_newShowKinds: number[]) => {
const handleShowKindsChange = useCallback((_newShowKinds: number[]) => {
if (noteListRef && typeof noteListRef !== 'function') {
noteListRef.current?.scrollToTop()
}
}
}, [noteListRef])
const showKindsKey = useMemo(() => JSON.stringify(showKinds), [showKinds])
@ -107,11 +119,12 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -107,11 +119,12 @@ const NormalFeed = forwardRef<TNoteListRef, {
/** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */
const subHeaderFilterDepsKey = `${allowKindlessRelayExplore ? 'kle' : 'std'}|${showKindsKey}|${feedKindFilterBypass}`
const tabsElement = (
const tabsElement = useMemo(
() => (
<Tabs
value={listMode}
tabs={tabs}
onTabChange={(tab) => handleListModeChange(tab)}
onTabChange={handleListModeChange}
options={
<div className="flex items-center gap-1">
{onSubHeaderRefresh != null && <RefreshButton onClick={onSubHeaderRefresh} />}
@ -119,12 +132,38 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -119,12 +132,38 @@ const NormalFeed = forwardRef<TNoteListRef, {
</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). */
useLayoutEffect(() => {
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)
}
return () => setSubHeader(null)
}, [
isMainFeed,
@ -132,15 +171,31 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -132,15 +171,31 @@ const NormalFeed = forwardRef<TNoteListRef, {
listMode,
subHeaderFilterDepsKey,
onSubHeaderRefresh,
allowKindlessRelayExplore
allowKindlessRelayExplore,
mergeFilterWithTabsRow,
tabsElement,
onFeedFilterTabRowSlotRef
])
const renderTabsInFeed = !(isMainFeed && setSubHeader) && !allowKindlessRelayExplore
return (
<>
{renderTabsInFeed && tabsElement}
<div className="min-w-0 pt-2">
{renderTabsInFeed &&
(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
ref={noteListRef}
showKinds={showKinds}
@ -160,6 +215,8 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -160,6 +215,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
clientSideKindFilter={clientSideKindFilter}
allowKindlessRelayExplore={allowKindlessRelayExplore}
showFeedClientFilter={showFeedClientFilter}
hostPrimaryPageName={hostPrimaryPageName}
feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined}
/>
</div>
</>

3
src/components/Note/index.tsx

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

3
src/components/NoteCard/RepostNoteCard.tsx

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

3
src/components/NoteCard/index.tsx

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

456
src/components/NoteList/index.tsx

@ -20,6 +20,7 @@ import { isTouchDevice } from '@/lib/utils' @@ -20,6 +20,7 @@ import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
import { useZap } from '@/providers/ZapProvider'
@ -52,14 +53,19 @@ import { CircleAlert } from 'lucide-react' @@ -52,14 +53,19 @@ import { CircleAlert } from 'lucide-react'
import { useLongPressAction } from '@/hooks/use-long-press-action'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import { createPortal } from 'react-dom'
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 { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { buildFeedFullSearchRelayUrls } from '@/lib/feed-full-search-relays'
import type { TProfile } from '@/types'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
Select,
SelectContent,
@ -89,9 +95,14 @@ if (import.meta.env.DEV && import.meta.hot) { @@ -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
/** Hard cap after merging parallel one-shot fetches (e.g. interests = one REQ per topic). */
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). */
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. */
const FEED_PROFILE_BATCH_DEBOUNCE_MS = 50
/** Larger chunks + parallel fetches below — sequential 36-pubkey rounds made notification avatars lag. */
@ -122,6 +133,71 @@ function timelineFilterHasNonKindScope(f: Filter): boolean { @@ -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(
(
{
@ -201,7 +277,17 @@ const NoteList = forwardRef( @@ -201,7 +277,17 @@ const NoteList = forwardRef(
* 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).
*/
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[]
showKinds: number[]
@ -241,6 +327,8 @@ const NoteList = forwardRef( @@ -241,6 +327,8 @@ const NoteList = forwardRef(
oneShotEoseTimeoutMs?: number
oneShotFirstRelayGraceMs?: number | false
showFeedClientFilter?: boolean
hostPrimaryPageName?: TPrimaryPageName
feedClientFilterTabRowHost?: HTMLElement | null
},
ref
) => {
@ -251,8 +339,13 @@ const NoteList = forwardRef( @@ -251,8 +339,13 @@ const NoteList = forwardRef(
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [events, setEvents] = useState<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 [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState(true)
@ -261,10 +354,21 @@ const NoteList = forwardRef( @@ -261,10 +354,21 @@ const NoteList = forwardRef(
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [feedClientFilterOpen, setFeedClientFilterOpen] = useState(false)
const [feedClientSearch, setFeedClientSearch] = useState('')
const [feedClientFromMeOnly, setFeedClientFromMeOnly] = useState(false)
const [feedClientAuthorMode, setFeedClientAuthorMode] = useState<TFeedClientAuthorMode>('everyone')
const [feedClientAuthorNpubInput, setFeedClientAuthorNpubInput] = useState('')
const [feedClientTimeAmount, setFeedClientTimeAmount] = useState('')
const [feedClientTimeUnit, setFeedClientTimeUnit] = useState<TFeedClientTimeUnit>('day')
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 topRef = useRef<HTMLDivElement | null>(null)
const spellFeedFirstPaintLoggedKeyRef = useRef('')
@ -330,6 +434,52 @@ const NoteList = forwardRef( @@ -330,6 +434,52 @@ const NoteList = forwardRef(
)
}, [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 prevSubRequestsKeyForTimelineRef = useRef<string | null>(null)
const feedTimelineScopePrevRef = useRef<string | undefined>(undefined)
@ -367,7 +517,7 @@ const NoteList = forwardRef( @@ -367,7 +517,7 @@ const NoteList = forwardRef(
}
}
}
for (const e of events) {
for (const e of timelineEventsForFilter) {
addPk(e.pubkey)
addPkFromEventTags(e)
}
@ -388,7 +538,7 @@ const NoteList = forwardRef( @@ -388,7 +538,7 @@ const NoteList = forwardRef(
if (!changed) return prev
return { ...prev, pending, version: prev.version + 1 }
})
}, [events, newEvents])
}, [timelineEventsForFilter, newEvents])
const subRequestsRef = useRef(subRequests)
subRequestsRef.current = subRequests
@ -475,7 +625,7 @@ const NoteList = forwardRef( @@ -475,7 +625,7 @@ const NoteList = forwardRef(
if (isEventDeleted(evt)) return true
if (hideReplies && isReplyNoteEvent(evt)) 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 (
filterMutedNotes &&
hideContentMentioningMutedUsers &&
@ -501,8 +651,10 @@ const NoteList = forwardRef( @@ -501,8 +651,10 @@ const NoteList = forwardRef(
return false
},
[
filterMutedNotes,
hideReplies,
hideUntrustedNotes,
hideContentMentioningMutedUsers,
mutePubkeySet,
pinnedEventIds,
isEventDeleted,
@ -519,7 +671,7 @@ const NoteList = forwardRef( @@ -519,7 +671,7 @@ const NoteList = forwardRef(
const filteredEvents = useMemo(() => {
const idSet = new Set<string>()
return events.slice(0, showCount).filter((evt) => {
return timelineEventsForFilter.slice(0, showCount).filter((evt) => {
if (applyKindPickerInUi) {
if (!showKinds.includes(evt.kind)) return false
// Kind 1: show only OPs if showKind1OPs, only replies if showKind1Replies
@ -543,7 +695,7 @@ const NoteList = forwardRef( @@ -543,7 +695,7 @@ const NoteList = forwardRef(
return true
})
}, [
events,
timelineEventsForFilter,
showCount,
shouldHideEvent,
showKinds,
@ -593,6 +745,8 @@ const NoteList = forwardRef( @@ -593,6 +745,8 @@ const NoteList = forwardRef(
])
const filteredNewEvents = useMemo(() => {
if (feedFullSearchEvents !== null) return []
const idSet = new Set<string>()
return newEvents.filter((event: Event) => {
@ -618,6 +772,7 @@ const NoteList = forwardRef( @@ -618,6 +772,7 @@ const NoteList = forwardRef(
return true
})
}, [
feedFullSearchEvents,
newEvents,
shouldHideEvent,
showKinds,
@ -634,12 +789,31 @@ const NoteList = forwardRef( @@ -634,12 +789,31 @@ const NoteList = forwardRef(
return dayjs().subtract(n, feedClientTimeUnit).unix()
}, [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(
(evts: Event[]) => {
let rows = evts
if (feedClientFromMeOnly && pubkey) {
if (feedClientAuthorMode === 'me' && pubkey) {
const p = pubkey.toLowerCase()
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) {
rows = rows.filter((e) => e.created_at >= feedClientMinCreatedAt)
@ -658,7 +832,13 @@ const NoteList = forwardRef( @@ -658,7 +832,13 @@ const NoteList = forwardRef(
}
return rows
},
[feedClientFromMeOnly, pubkey, feedClientMinCreatedAt, feedClientSearch]
[
feedClientAuthorMode,
feedClientAuthorNpubInput,
pubkey,
feedClientMinCreatedAt,
feedClientSearch
]
)
const clientFilteredEvents = useMemo(
@ -678,10 +858,18 @@ const NoteList = forwardRef( @@ -678,10 +858,18 @@ const NoteList = forwardRef(
!!(
showFeedClientFilter &&
(feedClientSearch.trim() ||
feedClientFromMeOnly ||
(feedClientAuthorMode === 'me' && !!pubkey) ||
(feedClientAuthorMode === 'npub' && feedClientAuthorNpubInput.trim() !== '') ||
feedClientMinCreatedAt !== null)
),
[showFeedClientFilter, feedClientSearch, feedClientFromMeOnly, feedClientMinCreatedAt]
[
showFeedClientFilter,
feedClientSearch,
feedClientAuthorMode,
feedClientAuthorNpubInput,
pubkey,
feedClientMinCreatedAt
]
)
useLayoutEffect(() => {
@ -723,7 +911,7 @@ const NoteList = forwardRef( @@ -723,7 +911,7 @@ const NoteList = forwardRef(
}
}
}
for (const e of events) {
for (const e of timelineEventsForFilter) {
addPk(e.pubkey)
addPkFromEventTags(e)
}
@ -792,7 +980,7 @@ const NoteList = forwardRef( @@ -792,7 +980,7 @@ const NoteList = forwardRef(
})()
}, FEED_PROFILE_BATCH_DEBOUNCE_MS)
return () => window.clearTimeout(handle)
}, [events, newEvents])
}, [timelineEventsForFilter, newEvents])
const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => {
setTimeout(() => {
@ -807,6 +995,117 @@ const NoteList = forwardRef( @@ -807,6 +995,117 @@ const NoteList = forwardRef(
}, 500)
}, [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)
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh])
@ -1513,7 +1812,7 @@ const NoteList = forwardRef( @@ -1513,7 +1812,7 @@ const NoteList = forwardRef(
}
const loadMore = async (): Promise<void> => {
const currentEvents = eventsRef.current
const currentEvents = displayTimelineSourceRef.current
const currentShowCount = showCountRef.current
const currentLoading = loadingRef.current
const currentHasMore = hasMoreRef.current
@ -1542,6 +1841,8 @@ const NoteList = forwardRef( @@ -1542,6 +1841,8 @@ const NoteList = forwardRef(
}
}
if (feedFullSearchEventsRef.current !== null) return
const canLoadFromTimeline = !!currentTimelineKey && currentHasMore
if (currentLoading || (!canLoadFromTimeline && currentShowCount >= currentEvents.length)) return
@ -1867,8 +2168,16 @@ const NoteList = forwardRef( @@ -1867,8 +2168,16 @@ const NoteList = forwardRef(
}, 0)
}
const feedClientFilterBar = (
<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">
const useFeedFilterTabRowPortal =
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">
<Button
type="button"
@ -1879,13 +2188,13 @@ const NoteList = forwardRef( @@ -1879,13 +2188,13 @@ const NoteList = forwardRef(
aria-controls="feed-client-filter-panel"
aria-label={t('Feed filter')}
title={t('Feed filter')}
onClick={() => setFeedClientFilterOpen((o) => !o)}
onClick={onToggleFeedClientFilterPanel}
>
<span aria-hidden>🔍</span>
</Button>
</div>
{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">
<Label htmlFor="feed-client-search" className="text-sm font-medium">
{t('Search loaded posts')}
@ -1899,15 +2208,53 @@ const NoteList = forwardRef( @@ -1899,15 +2208,53 @@ const NoteList = forwardRef(
className="w-full"
/>
</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">
<Checkbox
checked={feedClientFromMeOnly}
onCheckedChange={(v) => setFeedClientFromMeOnly(v === true)}
/>
{t('From me only')}
<RadioGroupItem value="everyone" id="feed-client-author-everyone" />
<span>{t('Feed filter author everyone')}</span>
</label>
<label
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>
<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}
</div>
</RadioGroup>
</div>
<div className="flex flex-wrap items-end gap-2">
<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">
@ -1948,11 +2295,49 @@ const NoteList = forwardRef( @@ -1948,11 +2295,49 @@ const NoteList = forwardRef(
</div>
</div>
<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>
) : 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>
)
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 = (
<div className="min-h-screen">
{feedClientFilterActive && filteredEvents.length > 0 && clientFilteredEvents.length === 0 ? (
@ -1960,6 +2345,11 @@ const NoteList = forwardRef( @@ -1960,6 +2345,11 @@ const NoteList = forwardRef(
{t('No loaded posts match your filters.')}
</div>
) : 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) => (
<NoteCard
key={event.id}
@ -1968,7 +2358,8 @@ const NoteList = forwardRef( @@ -1968,7 +2358,8 @@ const NoteList = forwardRef(
filterMutedNotes={filterMutedNotes}
/>
))}
{events.length === 0 &&
{listSourceEvents.length === 0 &&
!feedFullSearchActive &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
<div
ref={bottomRef}
@ -1981,7 +2372,8 @@ const NoteList = forwardRef( @@ -1981,7 +2372,8 @@ const NoteList = forwardRef(
<NoteCardLoadingSkeleton key={i} />
))}
</div>
) : events.length > 0 && hasMore ? (
) : listSourceEvents.length > 0 &&
(feedFullSearchActive ? showCount < listSourceEvents.length : hasMore) ? (
<div
ref={bottomRef}
className={
@ -1994,9 +2386,13 @@ const NoteList = forwardRef( @@ -1994,9 +2386,13 @@ const NoteList = forwardRef(
>
{loading ? <NoteCardLoadingSkeleton /> : null}
</div>
) : events.length > 0 ? (
) : listSourceEvents.length > 0 ? (
<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
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"

3
src/components/NoteOptions/useMenuActions.tsx

@ -16,6 +16,7 @@ import { generateBech32IdFromATag } from '@/lib/tag' @@ -16,6 +16,7 @@ import { generateBech32IdFromATag } from '@/lib/tag'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service'
@ -128,7 +129,7 @@ export function useMenuActions({ @@ -128,7 +129,7 @@ export function useMenuActions({
})
}, [])
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
const [isPinned, setIsPinned] = useState(false)

3
src/components/PostEditor/Mentions.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
import { eventService } from '@/services/client.service'
import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns'
@ -46,7 +47,7 @@ export default function Mentions({ @@ -46,7 +47,7 @@ export default function Mentions({
pubkeys
.filter((p) => potentialMentions.includes(p))
.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 @@ -123,6 +123,7 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey
ref={ref}
subRequests={subRequests}
feedSubscriptionKey={feedSubscriptionKey}
hostPrimaryPageName="profile"
showKinds={showKinds}
useFilterAsIs
/**

3
src/components/ProfileOptions/index.tsx

@ -10,6 +10,7 @@ import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk' @@ -10,6 +10,7 @@ import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
@ -78,7 +79,7 @@ export default function ProfileOptions({ @@ -78,7 +79,7 @@ export default function ProfileOptions({
fetchEvent()
}, [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')
/** 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' @@ -3,16 +3,17 @@ import type { TNoteListRef } from '@/components/NoteList'
import RelayInfo from '@/components/RelayInfo'
import SearchInput from '@/components/SearchInput'
import { useFetchRelayInfo } from '@/hooks'
import type { TPrimaryPageName } from '@/PageManager'
import { normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NotFound from '../NotFound'
const Relay = forwardRef<TNoteListRef, { url?: string; className?: string }>(function Relay(
{ url, className },
ref
) {
const Relay = forwardRef<
TNoteListRef,
{ url?: string; className?: string; hostPrimaryPageName?: TPrimaryPageName }
>(function Relay({ url, className, hostPrimaryPageName }, ref) {
const { t } = useTranslation()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url])
@ -85,6 +86,7 @@ const Relay = forwardRef<TNoteListRef, { url?: string; className?: string }>(fun @@ -85,6 +86,7 @@ const Relay = forwardRef<TNoteListRef, { url?: string; className?: string }>(fun
useFilterAsIs
allowKindlessRelayExplore
showFeedClientFilter
hostPrimaryPageName={hostPrimaryPageName}
/>
</div>
)

3
src/components/RelayInfo/RelayReviewsPreview.tsx

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

3
src/components/ReplyNote/index.tsx

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

18
src/i18n/locales/de.ts

@ -780,7 +780,13 @@ export default { @@ -780,7 +780,13 @@ export default {
'Feed filter': 'Feed-Filter',
'Search loaded posts': 'Geladene Beiträge durchsuchen',
'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',
'Time unit': 'Zeiteinheit',
Minutes: 'Minuten',
@ -790,6 +796,16 @@ export default { @@ -790,6 +796,16 @@ export default {
Years: 'Jahre',
'Feed filter client-side hint':
'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.',
'mentioned you in a note': 'hat Sie in einer Notiz erwähnt',
'quoted your note': 'hat Ihre Notiz zitiert',

18
src/i18n/locales/en.ts

@ -824,7 +824,13 @@ export default { @@ -824,7 +824,13 @@ export default {
'Feed filter': 'Feed filter',
'Search loaded posts': 'Search loaded posts',
'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',
'Time unit': 'Time unit',
Minutes: 'Minutes',
@ -834,6 +840,16 @@ export default { @@ -834,6 +840,16 @@ export default {
Years: 'Years',
'Feed filter client-side hint':
'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.',
'mentioned you in a note': 'mentioned you in a note',
'quoted your note': 'quoted your note',

3
src/lib/event.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
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 { cleanUrl } from '@/lib/url'
import client from '@/services/client.service'
@ -83,7 +84,7 @@ export function isProtectedEvent(event: Event) { @@ -83,7 +84,7 @@ export function isProtectedEvent(event: Event) {
export function isMentioningMutedUsers(event: Event, mutePubkeySet: Set<string>) {
for (const [tagName, pubkey] of event.tags) {
if (tagName === 'p' && mutePubkeySet.has(pubkey)) {
if (tagName === 'p' && muteSetHas(mutePubkeySet, pubkey)) {
return true
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

24
src/providers/BookmarksProvider.tsx

@ -4,30 +4,12 @@ import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/eve @@ -4,30 +4,12 @@ import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/eve
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import logger from '@/lib/logger'
import client from '@/services/client.service'
import { kinds } from 'nostr-tools'
import { Event } from 'nostr-tools'
import { createContext, useCallback, useContext } from 'react'
import { Event, kinds } from 'nostr-tools'
import { useCallback } from 'react'
import { BookmarksContext } from '@/providers/bookmarks-context'
import { useNostr } from './NostrProvider'
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 }) {
const { pubkey: accountPubkey, publish, updateBookmarkListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()

60
src/providers/MuteListProvider.tsx

@ -19,6 +19,19 @@ import { z } from 'zod' @@ -19,6 +19,19 @@ import { z } from 'zod'
import { useNostr } from './NostrProvider'
import { useFavoriteRelays } from './FavoriteRelaysProvider'
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 }) {
const { t } = useTranslation()
@ -33,9 +46,12 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -33,9 +46,12 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [tags, setTags] = 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(
() => new Set(getPubkeysFromPTags(privateTags)),
() => new Set(getPubkeysFromPTags(privateTags).map((p) => p.toLowerCase())),
[privateTags]
)
const mutePubkeySet = useMemo(() => {
@ -43,6 +59,10 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -43,6 +59,10 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
}, [publicMutePubkeySet, privateMutePubkeySet])
const [changing, setChanging] = useState(false)
useEffect(() => {
muteListPrivateSectionIssueLogged.clear()
}, [accountPubkey])
const getPrivateTags = async (muteListEvent: Event) => {
if (!muteListEvent.content) return []
@ -50,18 +70,42 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -50,18 +70,42 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
if (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 {
const plainText = await nip04Decrypt(muteListEvent.pubkey, muteListEvent.content)
const privateTags = z.array(z.array(z.string())).parse(JSON.parse(plainText))
await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags)
return privateTags
} 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 []
}
}
}
useEffect(() => {
const updateMuteTags = async () => {
@ -86,8 +130,8 @@ export function MuteListProvider({ children }: { children: ReactNode }) { @@ -86,8 +130,8 @@ export function MuteListProvider({ children }: { children: ReactNode }) {
const getMuteType = useCallback(
(pubkey: string): 'public' | 'private' | null => {
if (publicMutePubkeySet.has(pubkey)) return 'public'
if (privateMutePubkeySet.has(pubkey)) return 'private'
if (muteSetHas(publicMutePubkeySet, pubkey)) return 'public'
if (muteSetHas(privateMutePubkeySet, pubkey)) return 'private'
return null
},
[publicMutePubkeySet, privateMutePubkeySet]

10
src/providers/NostrProvider/index.tsx

@ -1349,11 +1349,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1349,11 +1349,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const updateMuteListEvent = async (muteListEvent: Event, privateTags: string[][]) => {
const newMuteListEvent = await indexedDb.putReplaceableEvent(muteListEvent)
if (newMuteListEvent.id !== muteListEvent.id) return
const storedWinner = await indexedDb.putReplaceableEvent(muteListEvent)
if (storedWinner.id === muteListEvent.id) {
await indexedDb.putMuteDecryptedTags(muteListEvent.id, privateTags)
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) => {

27
src/providers/bookmarks-context.tsx

@ -0,0 +1,27 @@ @@ -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