10 changed files with 232 additions and 66 deletions
@ -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 @@ |
|||||||
|
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 ( |
||||||
|
<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 ( |
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> |
<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> |
||||||
) |
) |
||||||
} |
} |
||||||
|
|||||||
@ -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 @@ |
|||||||
|
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