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')
+}