Browse Source

render used relays in feed filter component

restore seen-on component on events
imwald
Silberengel 2 weeks ago
parent
commit
e3ca23a2b3
  1. 33
      src/components/FeedRelaysIconRow/index.tsx
  2. 22
      src/components/NoteList/index.tsx
  3. 80
      src/components/NoteOptions/NoteOptionsMetaHeader.tsx
  4. 1
      src/components/NoteOptions/index.tsx
  5. 43
      src/components/NoteOptions/useMenuActions.tsx
  6. 13
      src/components/NoteStats/SeenOnButton.tsx
  7. 21
      src/lib/feed-relay-urls.test.ts
  8. 67
      src/lib/feed-relay-urls.ts
  9. 11
      src/lib/relay-url-priority.ts
  10. 7
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts

33
src/components/FeedRelaysIconRow/index.tsx

@ -0,0 +1,33 @@
import RelayIcon from '@/components/RelayIcon'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
export function FeedRelaysIconRow({
urls,
className
}: {
urls: readonly string[]
className?: string
}) {
const { t } = useTranslation()
if (urls.length === 0) return null
return (
<div
className={cn('flex min-w-0 flex-wrap items-center gap-1', className)}
role="group"
aria-label={t('Feed relays', { defaultValue: 'Relays in this feed' })}
>
{urls.map((url) => (
<span
key={url}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
title={simplifyUrl(url)}
>
<RelayIcon url={url} className="h-6 w-6" iconSize={12} />
</span>
))}
</div>
)
}

22
src/components/NoteList/index.tsx

@ -1,5 +1,6 @@
import NewNotesButton from '@/components/NewNotesButton' import NewNotesButton from '@/components/NewNotesButton'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import { FeedRelaysIconRow } from '@/components/FeedRelaysIconRow'
import { import {
ExtendedKind, ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
@ -27,6 +28,7 @@ import {
} from '@/lib/spell-feed-request-identity' } from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist' import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist'
import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge' import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
@ -1013,16 +1015,10 @@ const NoteList = forwardRef(
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render // Memoize subRequests serialization to avoid expensive JSON.stringify on every render
const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests]) const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests])
const feedRelayUrls = useMemo(() => { const feedRelayUrls = useMemo(
const urls = new Set<string>() () => uniqueRelayUrlsFromSubRequests(subRequests),
for (const req of subRequests) { [subRequestsKey]
for (const url of req.urls ?? []) { )
const trimmed = url.trim()
if (trimmed) urls.add(trimmed)
}
}
return [...urls]
}, [subRequestsKey])
const feedAttestedSuperchatIds = useFeedAttestedSuperchatIds(feedRelayUrls) const feedAttestedSuperchatIds = useFeedAttestedSuperchatIds(feedRelayUrls)
@ -4519,6 +4515,12 @@ const NoteList = forwardRef(
const feedClientFilterPanel = feedClientFilterOpen ? ( const feedClientFilterPanel = feedClientFilterOpen ? (
<div id="feed-client-filter-panel" className={feedClientFilterPanelSurfaceClass}> <div id="feed-client-filter-panel" className={feedClientFilterPanelSurfaceClass}>
{feedRelayUrls.length > 0 ? (
<div className={feedClientFilterSectionClass}>
<p className="text-sm font-medium">{t('Feed relays', { defaultValue: 'Relays in this feed' })}</p>
<FeedRelaysIconRow urls={feedRelayUrls} />
</div>
) : null}
<div className={feedClientFilterSectionClass}> <div className={feedClientFilterSectionClass}>
<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')}

80
src/components/NoteOptions/NoteOptionsMetaHeader.tsx

@ -1,26 +1,96 @@
import RelayIcon from '@/components/RelayIcon'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { useSeenOnRelays } from '@/hooks/useSeenOnRelays'
import { getKindDescription } from '@/lib/kind-description' import { getKindDescription } from '@/lib/kind-description'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { useSmartRelayNavigation } from '@/PageManager'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function NoteOptionsMetaHeader({ export default function NoteOptionsMetaHeader({
event event,
allowedRelays,
onNavigate,
inDropdown = false
}: { }: {
event: Event event: Event
/** @deprecated Seen-on relays moved to Advanced submenu. */
allowedRelays?: readonly string[] allowedRelays?: readonly string[]
/** @deprecated */
onNavigate?: () => void onNavigate?: () => void
/** @deprecated */
inDropdown?: boolean inDropdown?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
const relays = useSeenOnRelays(event.id, allowedRelays)
const { description } = getKindDescription(event.kind, event) const { description } = getKindDescription(event.kind, event)
const openRelayFeed = useCallback(
(relay: string) => {
onNavigate?.()
setTimeout(() => {
navigateToRelay(toRelay(relay))
}, 0)
},
[navigateToRelay, onNavigate]
)
const relayRows = relays.map((relay) => {
const label = (
<>
<RelayIcon url={relay} className="size-4 shrink-0" />
<span className="min-w-0 truncate">{simplifyUrl(relay)}</span>
</>
)
if (inDropdown) {
return ( return (
<div className="border-b border-border px-3 py-2.5"> <DropdownMenuItem
key={relay}
asChild
onSelect={(e) => e.preventDefault()}
>
<button
type="button"
className="flex min-w-0 w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent focus-visible:bg-accent"
onClick={() => openRelayFeed(relay)}
>
{label}
</button>
</DropdownMenuItem>
)
}
return (
<li key={relay}>
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-md px-1 py-1 text-left text-sm text-foreground hover:bg-muted"
onClick={() => openRelayFeed(relay)}
>
{label}
</button>
</li>
)
})
return (
<div className="space-y-2 border-b border-border px-3 py-2.5">
<p className="text-xs leading-snug text-muted-foreground/80" data-note-kind-label> <p className="text-xs leading-snug text-muted-foreground/80" data-note-kind-label>
{t('Note kind label line', { kind: event.kind, description })} {t('Note kind label line', { kind: event.kind, description })}
</p> </p>
{relays.length > 0 ? (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t('Seen on')}
</p>
{inDropdown ? (
<div className="space-y-0.5">{relayRows}</div>
) : (
<ul className="max-h-32 space-y-0.5 overflow-y-auto overscroll-y-contain">{relayRows}</ul>
)}
</div>
) : null}
</div> </div>
) )
} }

1
src/components/NoteOptions/index.tsx

@ -109,7 +109,6 @@ export default function NoteOptions({
setIsRawEventDialogOpen, setIsRawEventDialogOpen,
setIsReportDialogOpen, setIsReportDialogOpen,
isSmallScreen, isSmallScreen,
seenOnAllowlist,
onOpenPublicMessage, onOpenPublicMessage,
onOpenCallInvite, onOpenCallInvite,
onOpenEditOrClone: (mode) => { onOpenEditOrClone: (mode) => {

43
src/components/NoteOptions/useMenuActions.tsx

@ -14,8 +14,7 @@ import { buildHiveTalkJoinUrl } from '@/lib/hivetalk'
import { import {
toAlexandria, toAlexandria,
encodeArticleLikePublicationNaddr, encodeArticleLikePublicationNaddr,
openAlexandriaPublicationFromNaddr, openAlexandriaPublicationFromNaddr
toRelay
} from '@/lib/link' } from '@/lib/link'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { pubkeyToNpub } from '@/lib/pubkey' import { pubkeyToNpub } from '@/lib/pubkey'
@ -90,7 +89,6 @@ import { useMemo, useState, useEffect, useRef, useContext, useSyncExternalStore
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
import { useSeenOnRelays } from '@/hooks/useSeenOnRelays'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { PrimaryPageContext } from '@/contexts/primary-page-context' import { PrimaryPageContext } from '@/contexts/primary-page-context'
import { showPublishingFeedback, toastPublishPromise } from '@/lib/publishing-feedback' import { showPublishingFeedback, toastPublishPromise } from '@/lib/publishing-feedback'
@ -137,8 +135,6 @@ interface UseMenuActionsProps {
pinned?: boolean pinned?: boolean
/** Opens JSON viewer for the kind 9741 attestation of this payment or zap receipt. */ /** Opens JSON viewer for the kind 9741 attestation of this payment or zap receipt. */
onViewAttestation?: () => void onViewAttestation?: () => void
/** When set (home favorites feed), "Seen on" in Advanced matches the feed allowlist. */
seenOnAllowlist?: readonly string[]
} }
export function useMenuActions({ export function useMenuActions({
@ -152,12 +148,10 @@ export function useMenuActions({
onOpenCallInvite, onOpenCallInvite,
onOpenEditOrClone, onOpenEditOrClone,
pinned: _pinnedInFeed = false, pinned: _pinnedInFeed = false,
onViewAttestation, onViewAttestation
seenOnAllowlist
}: UseMenuActionsProps) { }: UseMenuActionsProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const seenOnRelays = useSeenOnRelays(event.id, seenOnAllowlist)
// Use useContext directly to avoid error if provider is not available // Use useContext directly to avoid error if provider is not available
const primaryPageContext = useContext(PrimaryPageContext) const primaryPageContext = useContext(PrimaryPageContext)
const currentPrimaryPage = primaryPageContext?.current ?? null const currentPrimaryPage = primaryPageContext?.current ?? null
@ -1262,38 +1256,6 @@ export function useMenuActions({
} }
} }
if (seenOnRelays.length > 0) {
advancedSubMenu.push({
label: (
<div
className="flex flex-wrap gap-2 py-0.5"
role="group"
aria-label={t('Seen on')}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{seenOnRelays.map((relay) => (
<button
key={relay}
type="button"
title={simplifyUrl(relay)}
className="rounded-md p-1 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(e) => {
e.stopPropagation()
closeDrawer()
push(toRelay(relay))
}}
>
<RelayIcon url={relay} className="size-8 shrink-0" />
</button>
))}
</div>
),
onClick: () => {},
separator: true
})
}
const actions: MenuAction[] = [] const actions: MenuAction[] = []
if (READ_ALOUD_KINDS.includes(event.kind)) { if (READ_ALOUD_KINDS.includes(event.kind)) {
@ -1529,7 +1491,6 @@ export function useMenuActions({
noteTranslationFromMenu, noteTranslationFromMenu,
translateMenuOptions, translateMenuOptions,
onViewAttestation, onViewAttestation,
seenOnRelays,
push, push,
currentPrimaryPage, currentPrimaryPage,
isReplyToDiscussion, isReplyToDiscussion,

13
src/components/NoteStats/SeenOnButton.tsx

@ -1,4 +1,4 @@
import { useSecondaryPage } from '@/PageManager' import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
drawerMenuButtonClassName, drawerMenuButtonClassName,
@ -34,7 +34,7 @@ export default function SeenOnButton({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage() const { navigateToRelay } = useSmartRelayNavigation()
const relays = useSeenOnRelays(event.id, allowedRelays) const relays = useSeenOnRelays(event.id, allowedRelays)
const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false)
@ -73,7 +73,7 @@ export default function SeenOnButton({
onClick={() => { onClick={() => {
setIsDrawerOpen(false) setIsDrawerOpen(false)
setTimeout(() => { setTimeout(() => {
push(toRelay(relay)) navigateToRelay(toRelay(relay))
}, 50) }, 50)
}} }}
> >
@ -94,7 +94,12 @@ export default function SeenOnButton({
<DropdownMenuLabel>{t('Seen on')}</DropdownMenuLabel> <DropdownMenuLabel>{t('Seen on')}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{relays.map((relay) => ( {relays.map((relay) => (
<DropdownMenuItem key={relay} onClick={() => push(toRelay(relay))} className="min-w-52"> <DropdownMenuItem
key={relay}
onSelect={(e) => e.preventDefault()}
onClick={() => navigateToRelay(toRelay(relay))}
className="min-w-52"
>
<RelayIcon url={relay} /> <RelayIcon url={relay} />
{simplifyUrl(relay)} {simplifyUrl(relay)}
</DropdownMenuItem> </DropdownMenuItem>

21
src/lib/feed-relay-urls.test.ts

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { pinHttpIndexRelaysInRelayCap, uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls'
describe('feed-relay-urls', () => {
it('collects deduped relay URLs from subrequests', () => {
expect(
uniqueRelayUrlsFromSubRequests([
{ urls: ['wss://a.example/', 'wss://b.example/'], filter: { limit: 1 } },
{ urls: ['wss://a.example/', 'wss://c.example/'], filter: { limit: 1 } }
])
).toEqual(['wss://a.example/', 'wss://b.example/', 'wss://c.example/'])
})
it('pins kind-10243 HTTP read relays into a capped faux spell stack', () => {
const ws = Array.from({ length: 10 }, (_, i) => `wss://relay-${i}.example/`)
const http = 'https://index.example.com/'
const capped = pinHttpIndexRelaysInRelayCap(ws, [...ws, http], 10)
expect(capped.some((u) => u.includes('index.example.com'))).toBe(true)
expect(capped.length).toBe(10)
})
})

67
src/lib/feed-relay-urls.ts

@ -0,0 +1,67 @@
import { normalizeHttpRelayUrl, normalizeRelayUrlByScheme, isHttpOrHttpsScheme } from '@/lib/url'
import type { TFeedSubRequest } from '@/types'
function relayDedupeKey(url: string): string {
return (normalizeRelayUrlByScheme(url) || url.trim()).toLowerCase()
}
/** Deduped relay URLs from all timeline subrequests (REQ order preserved). */
export function uniqueRelayUrlsFromSubRequests(requests: readonly TFeedSubRequest[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const req of requests) {
for (const raw of req.urls) {
const n = normalizeRelayUrlByScheme(raw) || raw.trim()
if (!n) continue
const key = relayDedupeKey(n)
if (seen.has(key)) continue
seen.add(key)
out.push(n)
}
}
return out
}
/**
* Keep viewer kind-10243 HTTP index relays in a capped feed stack (they are easy to drop when
* favorites + NIP-65 WS fill {@link FAUX_SPELL_MAX_RELAYS}).
*/
export function pinHttpIndexRelaysInRelayCap(
capped: readonly string[],
sourceUrls: readonly string[],
maxRelays: number
): string[] {
const httpSources = sourceUrls
.map((u) => normalizeHttpRelayUrl(u) || (isHttpOrHttpsScheme(u.trim()) ? u.trim() : ''))
.filter(Boolean)
if (httpSources.length === 0) return [...capped]
const httpKeySet = new Set(httpSources.map((u) => u.toLowerCase()))
const out = [...capped]
const outKeys = new Set(out.map(relayDedupeKey))
for (const http of httpSources) {
const key = http.toLowerCase()
if (outKeys.has(key)) continue
while (out.length >= maxRelays) {
let dropped = false
for (let i = out.length - 1; i >= 0; i--) {
const candidate = out[i]!
const ck = relayDedupeKey(candidate)
if (httpKeySet.has(ck) || isHttpOrHttpsScheme(candidate.trim())) continue
out.splice(i, 1)
outKeys.delete(ck)
dropped = true
break
}
if (!dropped) break
}
if (out.length >= maxRelays) continue
out.push(http)
outKeys.add(key)
}
return out.slice(0, maxRelays)
}

11
src/lib/relay-url-priority.ts

@ -5,7 +5,12 @@ import {
MAX_REQ_RELAY_URLS MAX_REQ_RELAY_URLS
} from '@/constants' } from '@/constants'
import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeRelayUrlByScheme, normalizeUrl } from '@/lib/url' import {
isLocalNetworkUrl,
normalizeAnyRelayUrl,
normalizeRelayUrlByScheme,
normalizeUrl
} from '@/lib/url'
export { MAX_REQ_RELAY_URLS } export { MAX_REQ_RELAY_URLS }
@ -110,8 +115,8 @@ export function buildPrioritizedReadRelayUrls(opts: {
const applySocial = opts.applySocialKindBlockedFilter !== false const applySocial = opts.applySocialKindBlockedFilter !== false
const exemptFromSocial = new Set<string>() const exemptFromSocial = new Set<string>()
for (const u of opts.userReadRelays ?? []) { for (const u of opts.userReadRelays ?? []) {
const n = normalizeAnyRelayUrl(u) || u.trim() const n = normalizeRelayUrlByScheme(u) || u.trim()
if (n) exemptFromSocial.add(n) if (n) exemptFromSocial.add(n.toLowerCase())
} }
const layers = buildReadRelayPriorityLayers({ const layers = buildReadRelayPriorityLayers({
userReadRelays: opts.userReadRelays, userReadRelays: opts.userReadRelays,

7
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -29,6 +29,7 @@ import {
parseThreadWatchListRefs parseThreadWatchListRefs
} from '@/lib/notification-thread-watch' } from '@/lib/notification-thread-watch'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
import { pinHttpIndexRelaysInRelayCap } from '@/lib/feed-relay-urls'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
import { type Event, type Filter } from 'nostr-tools' import { type Event, type Filter } from 'nostr-tools'
@ -88,6 +89,7 @@ const INTERESTS_MAX_TOPIC_TAG_VALUES = INTERESTS_MAX_TOPICS * 4
* relays live faux feeds (media, etc.) stayed empty while the console showed only connection refused. * relays live faux feeds (media, etc.) stayed empty while the console showed only connection refused.
*/ */
export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string[] { export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string[] {
const sourceUrls = dedupeNormalizeRelayUrlsOrdered(urls)
const fast = dedupeNormalizeRelayUrlsOrdered( const fast = dedupeNormalizeRelayUrlsOrdered(
FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
) )
@ -96,7 +98,7 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string
const n = normalizeAnyRelayUrl(u) || u.trim() const n = normalizeAnyRelayUrl(u) || u.trim()
if (n) fastNormSet.add(n) if (n) fastNormSet.add(n)
} }
const out = feedRelayPolicyUrls([{ source: 'fallback', urls: dedupeNormalizeRelayUrlsOrdered(urls) }], { const out = feedRelayPolicyUrls([{ source: 'fallback', urls: sourceUrls }], {
operation: 'read', operation: 'read',
maxRelays: FAUX_SPELL_MAX_RELAYS, maxRelays: FAUX_SPELL_MAX_RELAYS,
applySocialKindBlockedFilter: false, applySocialKindBlockedFilter: false,
@ -133,12 +135,13 @@ export function ensureFauxSpellRelayStackTouchesFastRead(urls: string[]): string
} }
if (!addedOne) break if (!addedOne) break
} }
return feedRelayPolicyUrls([{ source: 'fallback', urls: dedupeNormalizeRelayUrlsOrdered(out) }], { const capped = feedRelayPolicyUrls([{ source: 'fallback', urls: dedupeNormalizeRelayUrlsOrdered(out) }], {
operation: 'read', operation: 'read',
maxRelays: FAUX_SPELL_MAX_RELAYS, maxRelays: FAUX_SPELL_MAX_RELAYS,
applySocialKindBlockedFilter: false, applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true allowThirdPartyLocalRelays: true
}) })
return pinHttpIndexRelaysInRelayCap(capped, sourceUrls, FAUX_SPELL_MAX_RELAYS)
} }
/** Dedupe curated read relays and drop user-blocked URLs (no {@link READ_ONLY_RELAY_URLS} prepend). */ /** Dedupe curated read relays and drop user-blocked URLs (no {@link READ_ONLY_RELAY_URLS} prepend). */

Loading…
Cancel
Save