diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f57b68..846c23a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 78352c8..9927ee7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 9bb3603..2dc15ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bsv/identity-react", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bsv/identity-react", - "version": "2.0.0", + "version": "2.0.1", "license": "Open BSV License", "dependencies": { "@bsv/sdk": "^3.2.0", diff --git a/package.json b/package.json index 426fcbf..780013b 100755 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/components/IdentitySearchField.tsx b/src/components/IdentitySearchField.tsx index 10209cc..1b743d3 100644 --- a/src/components/IdentitySearchField.tsx +++ b/src/components/IdentitySearchField.tsx @@ -185,7 +185,7 @@ const IdentitySearchField: React.FC = ({ retrySearch, handleInputChange, handleSelect - } = useIdentitySearch({ onIdentitySelected }) + } = useIdentitySearch({ onIdentitySelected, deduplicate }) const [mncMissing, setMncMissing] = useState(false) const [autocompleteOpen, setAutocompleteOpen] = useState(false) @@ -215,7 +215,7 @@ const IdentitySearchField: React.FC = ({ [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 @@ -223,18 +223,8 @@ const IdentitySearchField: React.FC = ({ return [] } - let uniqueOptions = identities - if (deduplicate) { - const seen = new Set() - 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) diff --git a/src/hooks/useIdentitySearch.ts b/src/hooks/useIdentitySearch.ts index 9aa02c0..950514f 100644 --- a/src/hooks/useIdentitySearch.ts +++ b/src/hooks/useIdentitySearch.ts @@ -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 originator?: OriginatorDomainNameStringUnder250Bytes + /** Return one result per identity key, preferring the certificate that best matches the query. */ + deduplicate?: boolean } class SearchCache { @@ -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. @@ -90,7 +94,7 @@ export const useIdentitySearch = ({ const requestId = useRef(0) const controller = useRef(null) const justSelected = useRef(null) - const context = useRef({ wallet, options, originator }) + const context = useRef({ wallet, options, originator, deduplicate }) const invalidate = useCallback(() => { requestId.current++ @@ -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) @@ -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 @@ -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( () => () => { diff --git a/src/utils/dedupeIdentities.ts b/src/utils/dedupeIdentities.ts new file mode 100644 index 0000000..25a3124 --- /dev/null +++ b/src/utils/dedupeIdentities.ts @@ -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() + + 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 +} diff --git a/tests/IdentitySearchField.test.tsx b/tests/IdentitySearchField.test.tsx index 19d5fa8..59cb0e7 100644 --- a/tests/IdentitySearchField.test.tsx +++ b/tests/IdentitySearchField.test.tsx @@ -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() + 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() + 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')) diff --git a/tests/dedupeIdentities.test.ts b/tests/dedupeIdentities.test.ts new file mode 100644 index 0000000..cf9bde7 --- /dev/null +++ b/tests/dedupeIdentities.test.ts @@ -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]) + }) +}) diff --git a/tests/useIdentitySearch.test.tsx b/tests/useIdentitySearch.test.tsx index 5ecc534..6d9fb24 100644 --- a/tests/useIdentitySearch.test.tsx +++ b/tests/useIdentitySearch.test.tsx @@ -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({ diff --git a/vitest.config.ts b/vitest.config.ts index 1161042..620ccb1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ include: [ 'src/hooks/useIdentitySearch.ts', 'src/utils/identityUtils.ts', + 'src/utils/dedupeIdentities.ts', 'src/components/IdentitySearchField.tsx' ] }