10 changed files with 232 additions and 66 deletions
@ -0,0 +1,33 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -1,26 +1,96 @@
@@ -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 { toRelay } from '@/lib/link' |
||||
import { simplifyUrl } from '@/lib/url' |
||||
import { useSmartRelayNavigation } from '@/PageManager' |
||||
import type { Event } from 'nostr-tools' |
||||
import { useCallback } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
export default function NoteOptionsMetaHeader({ |
||||
event |
||||
event, |
||||
allowedRelays, |
||||
onNavigate, |
||||
inDropdown = false |
||||
}: { |
||||
event: Event |
||||
/** @deprecated Seen-on relays moved to Advanced submenu. */ |
||||
allowedRelays?: readonly string[] |
||||
/** @deprecated */ |
||||
onNavigate?: () => void |
||||
/** @deprecated */ |
||||
inDropdown?: boolean |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { navigateToRelay } = useSmartRelayNavigation() |
||||
const relays = useSeenOnRelays(event.id, allowedRelays) |
||||
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 ( |
||||
<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="border-b border-border px-3 py-2.5"> |
||||
<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> |
||||
{t('Note kind label line', { kind: event.kind, description })} |
||||
</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> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,21 @@
@@ -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) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,67 @@
@@ -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) |
||||
} |
||||
Loading…
Reference in new issue