Skip to content
Closed
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 2.0.1

- Deduplicate search results by identity key by default in both the search field
and headless hook. Keep distinct identities that happen to share a name.
- When several certificates resolve to one key, display the certificate whose
name and field type best match the query. Preserve the first result on ties.
- Keep the existing `deduplicate={false}` search-field option and expose the
same opt-out to headless hook consumers.

## 2.0.0

- Adopt SDK 3.2.0 identity search recovery; require Node >=22 tooling and React18.
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ edited through a different client. Pass a stable wallet instance; equivalent
routing-option objects are compared by their scalar values. Later answers from
superseded queries are discarded, including when the new query is a cache hit.

Search results contain one entry per identity key by default, including in the
headless hook. When multiple certificates belong to the same key, the displayed
entry favors the name and certificate field type matching the query (for example,
an email certificate for an email search); equal matches keep the first result.
Different keys with the same display name remain separate. Pass `deduplicate={false}`
to `IdentitySearchField` or `deduplicate: false` to `useIdentitySearch` to inspect
every returned certificate.

## Caching and Performance

- **Identity Search**: Complete successful results use a cache per hook/context with LRU eviction (max 100 entries) and 5-minute expiry
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bsv/identity-react",
"version": "2.0.0",
"version": "2.0.1",
"description": "React components for resolving identity information on the MetaNet.",
"repository": {
"type": "git",
Expand Down Expand Up @@ -37,7 +37,7 @@
},
"scripts": {
"build": "tsc",
"lint:ci": "prettier --check src/hooks/useIdentitySearch.ts src/utils/identityUtils.ts src/components/IdentitySearchField.tsx tests vitest.config.ts .prettierrc.json scripts/check-bundled-consumer.mjs",
"lint:ci": "prettier --check src/hooks/useIdentitySearch.ts src/utils/identityUtils.ts src/utils/dedupeIdentities.ts src/components/IdentitySearchField.tsx tests vitest.config.ts .prettierrc.json scripts/check-bundled-consumer.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"test:coverage": "vitest run --coverage",
Expand Down
18 changes: 4 additions & 14 deletions src/components/IdentitySearchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ const IdentitySearchField: React.FC<IdentitySearchFieldProps> = ({
retrySearch,
handleInputChange,
handleSelect
} = useIdentitySearch({ onIdentitySelected })
} = useIdentitySearch({ onIdentitySelected, deduplicate })

const [mncMissing, setMncMissing] = useState(false)
const [autocompleteOpen, setAutocompleteOpen] = useState(false)
Expand Down Expand Up @@ -215,26 +215,16 @@ const IdentitySearchField: React.FC<IdentitySearchFieldProps> = ({
[isLoading, searchError]
)

/** Filter and deduplicate search results */
/** Show search results or a raw-key fallback. */
const filteredIdentities = useMemo(() => {
// Show results even when input is empty (clearOnBlur=false preserves results)
// Only hide on initial render when no search has been performed
if (!inputValue.trim() && identities.length === 0) {
return []
}

let uniqueOptions = identities
if (deduplicate) {
const seen = new Set<string>()
uniqueOptions = identities.filter((identity) => {
if (seen.has(identity.identityKey)) return false
seen.add(identity.identityKey)
return true
})
}

return filterOptions(uniqueOptions, { inputValue })
}, [identities, deduplicate, filterOptions, inputValue])
return filterOptions(identities, { inputValue })
}, [identities, filterOptions, inputValue])

const handleFocus = useCallback(() => {
// Open dropdown if we have results (clearOnBlur=false preserves them)
Expand Down
20 changes: 13 additions & 7 deletions src/hooks/useIdentitySearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import {
} from '@bsv/sdk'
import type { AutocompleteInputChangeReason } from '@mui/material/Autocomplete'
import { fetchIdentities } from '../utils/identityUtils'
import { dedupeIdentitiesByKey } from '../utils/dedupeIdentities'

export interface UseIdentitySearchProps {
onIdentitySelected?: (selectedIdentity: DisplayableIdentity) => void
wallet?: WalletInterface
options?: Partial<IdentityClientOptions>
originator?: OriginatorDomainNameStringUnder250Bytes
/** Return one result per identity key, preferring the certificate that best matches the query. */
deduplicate?: boolean
}

class SearchCache {
Expand Down Expand Up @@ -51,7 +54,8 @@ export const useIdentitySearch = ({
onIdentitySelected,
wallet,
options: providedOptions,
originator
originator,
deduplicate = true
}: UseIdentitySearchProps = {}) => {
// Routing options are a protocol tuple and scalars. Equivalent fresh objects
// must not restart this effect on every state update.
Expand Down Expand Up @@ -90,7 +94,7 @@ export const useIdentitySearch = ({
const requestId = useRef(0)
const controller = useRef<AbortController | null>(null)
const justSelected = useRef<string | null>(null)
const context = useRef({ wallet, options, originator })
const context = useRef({ wallet, options, originator, deduplicate })

const invalidate = useCallback(() => {
requestId.current++
Expand All @@ -103,10 +107,11 @@ export const useIdentitySearch = ({
if (
context.current.wallet !== wallet ||
context.current.options !== options ||
context.current.originator !== originator
context.current.originator !== originator ||
context.current.deduplicate !== deduplicate
) {
cache.current.clear()
context.current = { wallet, options, originator }
context.current = { wallet, options, originator, deduplicate }
}
setSearchError(null)
setContactWarning(null)
Expand Down Expand Up @@ -145,8 +150,9 @@ export const useIdentitySearch = ({
})
.then((result) => {
if (!current()) return
setIdentities(result)
if (!partial) cache.current.set(query, result)
const presented = deduplicate ? dedupeIdentitiesByKey(result, query) : result
setIdentities(presented)
if (!partial) cache.current.set(query, presented)
})
.catch(() => {
if (!current()) return
Expand All @@ -163,7 +169,7 @@ export const useIdentitySearch = ({
clearTimeout(timeout)
abort.abort()
}
}, [inputValue, wallet, options, originator, retryVersion, invalidate])
}, [inputValue, wallet, options, originator, deduplicate, retryVersion, invalidate])

useEffect(
() => () => {
Expand Down
43 changes: 43 additions & 0 deletions src/utils/dedupeIdentities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { DisplayableIdentity } from '@bsv/sdk'

const scoreIdentity = (identity: DisplayableIdentity, query: string): number => {
const name = identity.name?.trim().toLowerCase() ?? ''
const badge = identity.badgeLabel?.toLowerCase() ?? ''
let score = 0

if (identity.identityKey.toLowerCase() === query) score += 1000
if (query.includes('@') && badge.startsWith('email')) score += 100
if (/^\+?[\d\s().-]{5,}$/.test(query) && badge.startsWith('phone')) score += 100
if (name === query) score += 50
else if (name.startsWith(query)) score += 30
else if (name.includes(query)) score += 10

return score
}

/** Keep the best matching certificate presentation for each identity key. */
export const dedupeIdentitiesByKey = (
identities: DisplayableIdentity[],
query: string
): DisplayableIdentity[] => {
const normalizedQuery = query.trim().toLowerCase()
const results: DisplayableIdentity[] = []
const indexByKey = new Map<string, number>()

for (const identity of identities) {
const key = identity.identityKey.trim().toLowerCase()
if (!key) continue
const existingIndex = indexByKey.get(key)
if (existingIndex === undefined) {
indexByKey.set(key, results.length)
results.push(identity)
} else if (
scoreIdentity(identity, normalizedQuery) >
scoreIdentity(results[existingIndex], normalizedQuery)
) {
results[existingIndex] = identity
}
}

return results
}
33 changes: 33 additions & 0 deletions tests/IdentitySearchField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,39 @@ afterEach(() => {
})

describe('visible search feedback', () => {
it('shows one option per key while keeping people with the same email on different keys', async () => {
const otherKey = `03${'a'.repeat(64)}`
fetch.mockResolvedValue([
{ ...identity, name: 'Bob', badgeLabel: 'Entity certified by Example' },
{ ...identity, name: 'bob@projectbabbage.com', badgeLabel: 'Email certified by Example' },
{
...identity,
identityKey: otherKey,
name: 'bob@projectbabbage.com',
badgeLabel: 'Email certified by Example'
}
])
render(<IdentitySearchField />)
await search('bob@proj')

const options = screen.getAllByRole('option')
expect(options).toHaveLength(2)
expect(options[0].textContent).toContain('bob@projectbabbage.com')
expect(options[0].textContent).toContain(key.slice(0, 10))
expect(options[1].textContent).toContain(otherKey.slice(0, 10))
})

it('can show every certificate when deduplication is explicitly disabled', async () => {
fetch.mockResolvedValue([
{ ...identity, name: 'bob@projectbabbage.com' },
{ ...identity, name: 'bob@projectbabbage.com' }
])
render(<IdentitySearchField deduplicate={false} />)
await search('bob@proj')

expect(screen.getAllByRole('option')).toHaveLength(2)
})

it('renders a public lookup failure and retry without presenting a raw key as a discovered identity', async () => {
fetch
.mockRejectedValueOnce(new Error('sensitive wallet detail'))
Expand Down
64 changes: 64 additions & 0 deletions tests/dedupeIdentities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { defaultIdentity } from '@bsv/sdk'
import { dedupeIdentitiesByKey } from '../src/utils/dedupeIdentities'

const keyA = `03${'a'.repeat(64)}`
const keyB = `02${'b'.repeat(64)}`

describe('dedupeIdentitiesByKey', () => {
it('keeps separate keys with the same name and one result for case-variant duplicate keys', () => {
const first = { ...defaultIdentity, identityKey: keyA, name: 'bob@projectbabbage.com' }
const second = { ...defaultIdentity, identityKey: keyB, name: first.name }
const duplicate = { ...second, identityKey: keyB.toUpperCase() }

expect(dedupeIdentitiesByKey([first, second, duplicate], 'bob@proj')).toEqual([first, second])
})

it('prefers the certificate field type that matches an email search', () => {
const generic = {
...defaultIdentity,
identityKey: keyB,
name: 'Bob',
badgeLabel: 'Entity certified by Example'
}
const email = {
...defaultIdentity,
identityKey: keyB,
name: 'bob@projectbabbage.com',
badgeLabel: 'Email certified by Example'
}

expect(dedupeIdentitiesByKey([generic, email], 'bob@proj')).toEqual([email])
})

it('keeps the first certificate when both presentations match equally', () => {
const first = { ...defaultIdentity, identityKey: keyA, name: 'Brayden' }
const second = { ...first, avatarURL: 'different-avatar' }

expect(dedupeIdentitiesByKey([first, second], 'brayden')).toEqual([first])
})

it('prefers a phone certificate for a phone-number query', () => {
const generic = {
...defaultIdentity,
identityKey: keyA,
name: 'Contact',
badgeLabel: 'Entity certified by Example'
}
const phone = {
...defaultIdentity,
identityKey: keyA,
name: '+12025550123',
badgeLabel: 'Phone certified by Example'
}

expect(dedupeIdentitiesByKey([generic, phone], '+1202555')).toEqual([phone])
})

it('drops entries without an identity key', () => {
const empty = { ...defaultIdentity, identityKey: '', name: 'Unknown' }
const valid = { ...defaultIdentity, identityKey: keyA, name: 'Alice' }

expect(dedupeIdentitiesByKey([empty, valid], 'alice')).toEqual([valid])
})
})
48 changes: 48 additions & 0 deletions tests/useIdentitySearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,54 @@ afterEach(() => {
})

describe('search results and failure state', () => {
it('returns and caches one best matching presentation per identity key', async () => {
const key = `02${'b'.repeat(64)}`
const generic = {
...identity('Bob'),
identityKey: key,
badgeLabel: 'Entity certified by Example'
}
const email = {
...identity('bob@projectbabbage.com'),
identityKey: key,
badgeLabel: 'Email certified by Example'
}
fetch.mockResolvedValue([generic, email])
const hook = renderHook(() => useIdentitySearch())
input(hook, 'bob@proj')
await settle()
expect(hook.result.current.identities).toEqual([email])
input(hook, 'other')
await settle()
input(hook, 'bob@proj')
await settle()
expect(hook.result.current.identities).toEqual([email])
expect(fetch).toHaveBeenCalledTimes(2)
})

it('preserves the opt-out for consumers that need every certificate', async () => {
fetch.mockResolvedValue([identity('Alice'), identity('Alice')])
const hook = renderHook(() => useIdentitySearch({ deduplicate: false }))
input(hook, 'alice')
await settle()
expect(hook.result.current.identities).toHaveLength(2)
})

it('refreshes cached results when a consumer changes the deduplication setting', async () => {
fetch.mockResolvedValue([identity('Alice'), identity('Alice')])
const hook = renderHook((props: UseIdentitySearchProps) => useIdentitySearch(props), {
initialProps: { deduplicate: true }
})
input(hook, 'alice')
await settle()
expect(hook.result.current.identities).toHaveLength(1)

hook.rerender({ deduplicate: false })
await settle()
expect(hook.result.current.identities).toHaveLength(2)
expect(fetch).toHaveBeenCalledTimes(2)
})

it('does not restart a lookup for equivalent freshly allocated routing options', async () => {
const hook = renderHook(() =>
useIdentitySearch({
Expand Down
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineConfig({
include: [
'src/hooks/useIdentitySearch.ts',
'src/utils/identityUtils.ts',
'src/utils/dedupeIdentities.ts',
'src/components/IdentitySearchField.tsx'
]
}
Expand Down
Loading