From af09237eb0f1ee6b751d5326d221d1923047c441 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 17 May 2026 16:18:47 +0200 Subject: [PATCH] add nostr.watch and nostr.archive links --- src/components/Profile/index.tsx | 10 +++++++++- src/components/ProfileOptions/index.tsx | 9 +++++++++ src/components/RelayInfo/index.tsx | 24 +++++++++++++++++++++++- src/i18n/locales/de.ts | 3 +++ src/i18n/locales/en.ts | 3 +++ src/lib/link-external-sites.test.ts | 21 +++++++++++++++++++++ src/lib/link.ts | 20 ++++++++++++++++++++ 7 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 src/lib/link-external-sites.test.ts diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index b655e010..818db316 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -16,7 +16,7 @@ import { kinds, type NostrEvent } from 'nostr-tools' import { createReactionDraftEvent } from '@/lib/draft-event' import { getPaymentInfoFromEvent } from '@/lib/event-metadata' import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback' -import { toProfileEditor } from '@/lib/link' +import { getNostrArchivesProfileUrl, openExternalUrl, toProfileEditor } from '@/lib/link' import { encodeProfileInteractionsSpellId } from '@/pages/primary/SpellsPage/fauxSpellConfig' import { generateImageByPubkey } from '@/lib/pubkey' import { isVideo, normalizeAnyRelayUrl } from '@/lib/url' @@ -35,6 +35,7 @@ import { import { Copy, Ellipsis, + ExternalLink, Calendar, MapPin, Pencil, @@ -502,6 +503,7 @@ export default function Profile({ if (!profile) return null // TypeScript guard - should never reach here but satisfies type checker const { banner, username, about, avatar, pubkey, website, websiteList, nip05List, isBot } = profile + const nostrArchivesProfileUrl = getNostrArchivesProfileUrl(pubkey) return ( <> @@ -623,6 +625,12 @@ export default function Profile({ {t('Interactions map')} + {nostrArchivesProfileUrl ? ( + openExternalUrl(nostrArchivesProfileUrl)}> + + {t('View on Nostr.Archives')} + + ) : null} push(toProfileEditor())}> {t('Edit')} diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx index 4be4c807..5fd2617c 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -8,6 +8,7 @@ import { } from '@/components/ui/dropdown-menu' import { usePrimaryPage } from '@/contexts/primary-page-context' import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk' +import { getNostrArchivesProfileUrl, openExternalUrl } from '@/lib/link' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { useMuteList } from '@/contexts/mute-list-context' import { muteSetHas } from '@/lib/mute-set' @@ -26,6 +27,7 @@ import { Code, Copy, Ellipsis, + ExternalLink, ThumbsUp, MessageCircle, Network, @@ -92,6 +94,7 @@ export default function ProfileOptions({ const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey]) const displayName = profile?.username ?? (accountPubkey ? formatPubkey(accountPubkey) : 'jumble') + const nostrArchivesProfileUrl = useMemo(() => getNostrArchivesProfileUrl(pubkey), [pubkey]) /** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */ const allAvailableRelayUrls = useMemo(() => { @@ -255,6 +258,12 @@ export default function ProfileOptions({ {t('Interactions map')} + {nostrArchivesProfileUrl && ( + openExternalUrl(nostrArchivesProfileUrl)}> + + {t('View on Nostr.Archives')} + + )} {kind0ForRelay && ( <> diff --git a/src/components/RelayInfo/index.tsx b/src/components/RelayInfo/index.tsx index eb354cb3..f141363d 100644 --- a/src/components/RelayInfo/index.tsx +++ b/src/components/RelayInfo/index.tsx @@ -1,13 +1,20 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' import { useFetchRelayInfo } from '@/hooks' +import { getNostrWatchRelayUrl, openExternalUrl } from '@/lib/link' import { normalizeHttpUrl } from '@/lib/url' import client from '@/services/client.service' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { nip66Service } from '@/services/nip66.service' -import { Check, Copy, GitBranch, Link, Mail, SquareCode, Activity } from 'lucide-react' +import { Activity, Check, Copy, Ellipsis, ExternalLink, GitBranch, Link, Mail, SquareCode } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -263,8 +270,10 @@ function RelayLivelinessSection({ discovery }: { discovery: TNip66RelayDiscovery } function RelayControls({ url }: { url: string }) { + const { t } = useTranslation() const [copiedUrl, setCopiedUrl] = useState(false) const [copiedShareableUrl, setCopiedShareableUrl] = useState(false) + const nostrWatchUrl = useMemo(() => getNostrWatchRelayUrl(url), [url]) const handleCopyUrl = () => { navigator.clipboard.writeText(url) @@ -287,6 +296,19 @@ function RelayControls({ url }: { url: string }) { + + + + + + openExternalUrl(nostrWatchUrl)}> + + {t('View on Nostr.Watch')} + + + ) diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index dda218c4..1d4fbefe 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1967,6 +1967,9 @@ export default { "View on Alexandria": "View on Alexandria", "View on DecentNewsroom": "View on DecentNewsroom", "View on Wikistr": "View on Wikistr", + "View on Nostr.Watch": "Auf Nostr.Watch ansehen", + "View on Nostr.Archives": "Auf Nostr.Archives ansehen", + "Relay options": "Relay-Optionen", "View recent console logs for debugging": "View recent console logs for debugging", "Voice Comment": "Voice Comment", "Voice Note": "Voice Note", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1e4290fc..679cc04f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -2038,6 +2038,9 @@ export default { "View on Alexandria": "View on Alexandria", "View on DecentNewsroom": "View on DecentNewsroom", "View on Wikistr": "View on Wikistr", + "View on Nostr.Watch": "View on Nostr.Watch", + "View on Nostr.Archives": "View on Nostr.Archives", + "Relay options": "Relay options", "View recent console logs for debugging": "View recent console logs for debugging", "Voice Comment": "Voice Comment", "Voice Note": "Voice Note", diff --git a/src/lib/link-external-sites.test.ts b/src/lib/link-external-sites.test.ts new file mode 100644 index 00000000..3c1f6cad --- /dev/null +++ b/src/lib/link-external-sites.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { getNostrArchivesProfileUrl, getNostrWatchRelayUrl } from './link' + +describe('getNostrWatchRelayUrl', () => { + it('maps wss relay URL to nostr.watch path', () => { + expect(getNostrWatchRelayUrl('wss://relay.noswhere.com')).toBe( + 'https://nostr.watch/relays/wss/relay.noswhere.com' + ) + }) +}) + +describe('getNostrArchivesProfileUrl', () => { + it('maps hex pubkey to profile URL', () => { + const hex = '63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed' + expect(getNostrArchivesProfileUrl(hex)).toBe(`https://nostrarchives.com/profiles/${hex}`) + }) + + it('returns null for invalid pubkey', () => { + expect(getNostrArchivesProfileUrl('not-a-pubkey')).toBeNull() + }) +}) diff --git a/src/lib/link.ts b/src/lib/link.ts index eb009652..4ebfeb3b 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -1,7 +1,9 @@ import { Event, kinds, nip19 } from 'nostr-tools' import { ExtendedKind } from '@/constants' import { getNoteBech32Id, isReplaceableEvent } from './event' +import { isValidPubkey, normalizeHexPubkey } from './pubkey' import { TSearchParams } from '@/types' +import { normalizeAnyRelayUrl } from './url' /** Same kinds as {@link useMenuActions} `isArticleType` for naddr + Alexandria publication URLs. */ const ALEXANDRIA_PUBLICATION_NADDR_KINDS = new Set([ @@ -146,3 +148,21 @@ export const toChachiChat = (relay: string, d: string) => { return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}` } export const toAlexandria = (id: string) => `https://next-alexandria.gitcitadel.eu/events?id=${encodeURIComponent(id)}` + +/** {@link https://nostr.watch/relays/wss/relay.example.com} path slug from a relay WebSocket/HTTP URL. */ +export function getNostrWatchRelayUrl(relayUrl: string): string { + const normalized = (normalizeAnyRelayUrl(relayUrl) || relayUrl).trim().replace(/\/+$/, '') + const slug = normalized.replace(/^([a-z][a-z0-9+.-]*):\/\//i, '$1/') + return `https://nostr.watch/relays/${slug}` +} + +/** Profile page on nostrarchives.com (hex pubkey). */ +export function getNostrArchivesProfileUrl(pubkey: string): string | null { + const hex = normalizeHexPubkey(pubkey) + if (!isValidPubkey(hex)) return null + return `https://nostrarchives.com/profiles/${hex}` +} + +export function openExternalUrl(url: string): void { + window.open(url, '_blank', 'noopener,noreferrer') +}