Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions src/components/elements/User/UserNIP05.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Text } from '@/components/ui/Text/Text'
import { useNip05 } from '@/hooks/query/useNIP05'
import { useUserState } from '@/hooks/state/useUser'
import { palette } from '@/themes/palette.stylex'
import { IconAt, IconExclamationCircle } from '@tabler/icons-react'
import { IconAt, IconExclamationCircle, IconShieldCheck } from '@tabler/icons-react'
import { memo } from 'react'
import { css } from 'react-strict-dom'

Expand All @@ -14,22 +14,35 @@ type Props = Omit<TextProps, 'children'> & {
export const UserNIP05 = memo(function UserNIP05(props: Props) {
const { pubkey, ...rest } = props
const user = useUserState(pubkey)
const validNIP05 = useNip05(pubkey, user.metadata?.nip05).data
const nip05Result = useNip05(pubkey, user.metadata?.nip05).data
const nip05 = user.metadata?.nip05?.replace(/^_@/, '')
if (!nip05) {
return
}

const isValid = nip05Result?.valid
const isNamecoin = nip05Result?.isNamecoin

return (
<Text variant='label' size='sm' {...rest} sx={[styles.root, validNIP05 === false && styles.root$invalid, rest.sx]}>
{validNIP05 !== false ? (
<Text
variant='label'
size='sm'
{...rest}
sx={[styles.root, isValid === false && styles.root$invalid, isNamecoin && styles.root$namecoin, rest.sx]}>
{isValid === false ? (
<>
{!nip05.includes('@') && <IconAt size={12} {...css.props(styles.icon)} />}
<IconExclamationCircle size={12} strokeWidth={2.2} {...css.props(styles.icon)} />
{nip05.replace(/^_@/, '')}
</>
) : isNamecoin ? (
<>
<IconShieldCheck size={12} strokeWidth={2.2} {...css.props(styles.icon$namecoin)} />
{nip05}
</>
) : (
<>
<IconExclamationCircle size={12} strokeWidth={2.2} {...css.props(styles.icon)} />
{nip05.replace(/^_@/, '')}
{!nip05.includes('@') && <IconAt size={12} {...css.props(styles.icon)} />}
{nip05}
</>
)}
</Text>
Expand All @@ -41,6 +54,11 @@ const styles = css.create({
display: 'inline-block',
verticalAlign: 'text-bottom',
},
icon$namecoin: {
display: 'inline-block',
verticalAlign: 'text-bottom',
color: '#009688', // teal — distinct from standard NIP-05
},
root: {
whiteSpace: 'nowrap',
maxWidth: 250,
Expand All @@ -50,4 +68,7 @@ const styles = css.create({
root$invalid: {
color: palette.error,
},
root$namecoin: {
color: '#009688',
},
})
28 changes: 27 additions & 1 deletion src/components/modules/Search/SearchContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { SxProps } from '@/components/ui/types'
import { palette } from '@/themes/palette.stylex'
import { shape } from '@/themes/shape.stylex'
import { spacing } from '@/themes/spacing.stylex'
import { IconSearch, IconServerBolt } from '@tabler/icons-react'
import { IconSearch, IconServerBolt, IconShieldCheck } from '@tabler/icons-react'
import type { ReactNode, Ref } from 'react'
import React, { memo, useCallback, useImperativeHandle, useState } from 'react'
import { css, html } from 'react-strict-dom'
Expand Down Expand Up @@ -162,6 +162,23 @@ export const SearchContent = memo(function SearchContent(props: Props) {
</ListItem>
</React.Fragment>
)
case 'user_namecoin': {
return (
<ListItem
{...listItemProps}
key={item.type + item.pubkey}
supportingText={
<Text variant='label' size='sm' sx={styles.namecoinLabel}>
<IconShieldCheck size={12} strokeWidth={2.2} {...css.props(styles.namecoinIcon)} />
{item.address}
</Text>
}
onClick={() => handleSelect(item)}
leadingIcon={<UserAvatar size='sm' pubkey={item.pubkey} />}>
<UserName pubkey={item.pubkey} />
</ListItem>
)
}
case 'user': {
return (
<React.Fragment key={item.type + item.pubkey}>
Expand Down Expand Up @@ -221,4 +238,13 @@ const styles = css.create({
paddingBottom: spacing.padding1,
paddingLeft: spacing.padding1,
},
namecoinLabel: {
color: '#009688',
},
namecoinIcon: {
display: 'inline-block',
verticalAlign: 'text-bottom',
color: '#009688',
marginRight: 4,
},
})
7 changes: 6 additions & 1 deletion src/components/modules/Search/SearchDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,19 @@ export const SearchDialog = memo(function SearchDialog() {
})
break
}
case 'user_namecoin': {
const nmcRelays = ('relays' in item && item.relays?.slice(0, 4)) || []
const nostrNmc = nip19.nprofileEncode({ pubkey: item.pubkey, relays: nmcRelays })
navigate({ to: '/$nostr', params: { nostr: nostrNmc } })
break
}
case 'user_relay':
case 'user': {
const relays = getUserRelaysFromCache(item.pubkey, WRITE).map((x) => x.relay).slice(0, 4)
const nostr = nip19.nprofileEncode({ pubkey: item.pubkey, relays })
navigate({
to: '/$nostr',
params: { nostr },
// params: { nostr: user?.nprofile ? user.nprofile : nip19.nprofileEncode({ pubkey: item.pubkey }) },
})
break
}
Expand Down
16 changes: 15 additions & 1 deletion src/components/modules/Search/SearchRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Stack } from '@/components/ui/Stack/Stack'
import { useSearchFeed } from '@/hooks/state/useSearchFeed'
import { useResetScroll } from '@/hooks/useResetScroll'
import { isNamecoinIdentifier, resolveNamecoin } from '@/services/namecoin'
import { searchRoute } from '@/Router'
import { spacing } from '@/themes/spacing.stylex'
import { useNavigate } from '@tanstack/react-router'
import { memo } from 'react'
import { nip19 } from 'nostr-tools'
import { memo, useEffect } from 'react'
import { css } from 'react-strict-dom'
import { FeedRoute } from '../Feed/FeedRoute'
import { SearchHeader } from './SearchHeader'
Expand All @@ -15,6 +17,18 @@ export const SearchRoute = memo(function SearchRoute() {
const { q } = searchRoute.useSearch()
const navigate = useNavigate()
const feed = useSearchFeed(q)

// Resolve Namecoin identifiers and redirect to user profile
useEffect(() => {
if (!q || !isNamecoinIdentifier(q)) return
let cancelled = false
resolveNamecoin(q).then((result) => {
if (cancelled || !result?.pubkey) return
const nostr = nip19.nprofileEncode({ pubkey: result.pubkey, relays: result.relays?.slice(0, 4) })
navigate({ to: '/$nostr', params: { nostr }, replace: true })
})
return () => { cancelled = true }
}, [q, navigate])
return (
<FeedRoute
feed={feed}
Expand Down
32 changes: 28 additions & 4 deletions src/components/modules/Search/hooks/useSearchSuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Kind } from '@/constants/kinds'
import { SEARCH_RELAYS } from '@/constants/relays'
import { createEventQueryOptions } from '@/hooks/query/useQueryBase'
import { dbSqlite } from '@/nostr/db'
import { isNamecoinIdentifier, resolveNamecoin } from '@/services/namecoin'
import { useQuery } from '@tanstack/react-query'

type SearchOptions = {
Expand All @@ -14,6 +15,7 @@ type SearchOptions = {
export type SearchItem =
| { type: 'user'; pubkey: string }
| { type: 'user_relay'; pubkey: string }
| { type: 'user_namecoin'; pubkey: string; address: string; relays?: string[] }
| { type: 'query'; query: string }
| { type: 'relay'; relay: string }

Expand Down Expand Up @@ -44,20 +46,42 @@ function useSearchLocalUsers(options: SearchOptions) {
})
}

/** Resolve .bit/d//id/ identifiers via Namecoin blockchain */
function useSearchNamecoin(query: string) {
const isNmc = isNamecoinIdentifier(query)
return useQuery({
queryKey: ['search-namecoin', query],
enabled: isNmc,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
const result = await resolveNamecoin(query)
if (!result) return null
return { pubkey: result.pubkey, address: query, relays: result.relays }
},
})
}

export function useSearchSuggestions(options: SearchOptions) {
const usersRelay = useSearchOnRelays(options)
const usersLocal = useSearchLocalUsers(options)
const namecoinResult = useSearchNamecoin(options.query)
const querySuggestion =
options.suggestQuery !== false && options.query ? { type: 'query', query: options.query } : undefined
const relaySuggestion =
options.suggestRelays !== false && options.query
? {
type: 'relay',
relay:
(options.query.startsWith('wss://') || options.query.startsWith('ws://') ? '' : 'wss://') + options.query,
}
type: 'relay',
relay:
(options.query.startsWith('wss://') || options.query.startsWith('ws://') ? '' : 'wss://') + options.query,
}
: undefined

const namecoinSuggestion = namecoinResult.data
? { type: 'user_namecoin' as const, pubkey: namecoinResult.data.pubkey, address: namecoinResult.data.address, relays: namecoinResult.data.relays }
: undefined

return [
namecoinSuggestion,
querySuggestion,
relaySuggestion,
...(usersLocal.data?.map((user) => ({ type: 'user' as const, pubkey: user.pubkey })) || []),
Expand Down
67 changes: 58 additions & 9 deletions src/hooks/query/useNIP05.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Nip05DB } from '@/db/types'
import { dbSqlite } from '@/nostr/db'
import { isNamecoinIdentifier, resolveNamecoin } from '@/services/namecoin'
import type { UseQueryOptions } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'

Expand All @@ -8,7 +9,42 @@ export type Nip05Response = {
relays?: Record<string, string[]>
}

export function nip05QueryOptions(nip05: string = ''): UseQueryOptions<Nip05DB | null> {
/** Result type extended with Namecoin flag */
export type Nip05Result = Nip05DB & {
isNamecoin?: boolean
}

function nip05QueryOptionsNamecoin(nip05: string): UseQueryOptions<Nip05Result | null> {
return {
queryKey: ['nip05', 'namecoin', nip05] as const,
enabled: !!nip05,
staleTime: 5 * 60 * 1000, // 5 min — Namecoin names change rarely
queryFn: async () => {
const result = await resolveNamecoin(nip05)
if (!result) return null

const nip05Record: Nip05Result = {
nip05,
pubkey: result.pubkey,
relays: result.relays || [],
timestamp: Date.now(),
isNamecoin: true,
}

// Store in DB for offline access
dbSqlite.insertNip05({
nip05,
pubkey: result.pubkey,
relays: result.relays || [],
timestamp: Date.now(),
})

return nip05Record
},
}
}

function nip05QueryOptionsStandard(nip05: string): UseQueryOptions<Nip05Result | null> {
const [name, host] = nip05.split('@')
return {
queryKey: ['nip05', nip05] as const,
Expand All @@ -23,36 +59,49 @@ export function nip05QueryOptions(nip05: string = ''): UseQueryOptions<Nip05DB |
if (!res.ok) {
throw new Error('Failed to fetch NIP-05 record')
}
const nip05 = (await res.json()) as Nip05Response
const nip05Response = (await res.json()) as Nip05Response
// Insert all names found
Object.entries(nip05.names || {}).forEach(([username, pubkey]) => {
Object.entries(nip05Response.names || {}).forEach(([username, pubkey]) => {
dbSqlite.insertNip05({
nip05: `${username}@${host}`,
pubkey,
relays: nip05.relays?.[pubkey] || [],
relays: nip05Response.relays?.[pubkey] || [],
timestamp: Date.now(),
})
})
const pubkey = nip05.names?.[name]
const relays = pubkey ? nip05.relays?.[pubkey] || [] : []
const pubkey = nip05Response.names?.[name]
const relays = pubkey ? nip05Response.relays?.[pubkey] || [] : []
if (pubkey) {
return {
nip05,
pubkey,
relays,
timestamp: Date.now(),
} as Nip05DB
} as Nip05Result
}
return null
},
}
}

export function nip05QueryOptions(nip05: string = ''): UseQueryOptions<Nip05Result | null> {
if (isNamecoinIdentifier(nip05)) {
return nip05QueryOptionsNamecoin(nip05)
}
return nip05QueryOptionsStandard(nip05)
}

export function useNip05(pubkey: string, nip05: string | undefined) {
const isNmc = nip05 ? isNamecoinIdentifier(nip05) : false
const queryOpts = nip05QueryOptions(nip05 || '')
return useQuery({
...nip05QueryOptions(nip05 || ''),
...queryOpts,
select: (res) => {
return res ? res.pubkey === pubkey : undefined
if (!res) return undefined
return {
valid: res.pubkey === pubkey,
isNamecoin: isNmc || res.isNamecoin === true,
}
},
})
}
37 changes: 37 additions & 0 deletions src/services/namecoin/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/** Namecoin protocol constants */

/**
* Default ElectrumX WebSocket servers for Namecoin.
*
* The browser connects directly via ws:// or wss:// — no backend proxy needed.
* On HTTPS pages, prefer wss:// first to avoid mixed-content blocks.
*/
export interface ElectrumxWsServer {
/** WebSocket URL (ws:// or wss://) */
url: string
/** Human-readable label */
label: string
}

export const DEFAULT_ELECTRUMX_SERVERS: ElectrumxWsServer[] =
typeof window !== 'undefined' && window.location?.protocol === 'https:'
? [
{ url: 'wss://electrumx.testls.space:50004', label: 'testls.space' },
{ url: 'ws://electrumx.testls.space:50003', label: 'testls.space (plain)' },
]
: [
{ url: 'ws://electrumx.testls.space:50003', label: 'testls.space (plain)' },
{ url: 'wss://electrumx.testls.space:50004', label: 'testls.space' },
]

/** Namecoin names expire after this many blocks without renewal (~250 days) */
export const NAME_EXPIRE_DEPTH = 36000

/** Cache TTL in ms (5 minutes) */
export const DEFAULT_CACHE_TTL = 5 * 60 * 1000

/** OP codes for Namecoin name scripts */
export const OP_NAME_UPDATE = 0x53
export const OP_2DROP = 0x6d
export const OP_DROP = 0x75
export const OP_RETURN = 0x6a
Loading