diff --git a/__mocks__/contacts-rdflib.ts b/__mocks__/contacts-rdflib.ts new file mode 100644 index 000000000..7bc3274bd --- /dev/null +++ b/__mocks__/contacts-rdflib.ts @@ -0,0 +1,32 @@ +type AddressBookContact = { + uri: string + name: string +} + +type AddressBook = { + contacts: AddressBookContact[] +} + +type AddressBookList = { + publicUris: string[] + privateUris: string[] +} + +export default class ContactsModuleRdfLib { + constructor (_options: unknown) {} + + async listAddressBooks (_webId: string): Promise { + return { + publicUris: [], + privateUris: [] + } + } + + async readAddressBook (_addressBookUri: string): Promise { + return { + contacts: [] + } + } +} + +export type { AddressBook } diff --git a/jest.config.mjs b/jest.config.mjs index 9c38c0198..99182a807 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -11,8 +11,11 @@ export default { '^.+\\.(mjs|[tj]sx?)$': ['babel-jest', { configFile: './babel.config.mjs' }], }, transformIgnorePatterns: [ - '/node_modules/(?!(lit-html|@noble/curves|@noble/hashes|@exodus/bytes|uuid|jsdom|parse5|@asamuzakjp/css-color|@csstools)/)', + '/node_modules/(?!(lit-html|@noble/curves|@noble/hashes|@exodus/bytes|uuid|jsdom|parse5|@asamuzakjp/css-color|@csstools|@solid-data-modules/contacts-rdflib)/)', ], + moduleNameMapper: { + '^@solid-data-modules/contacts-rdflib$': '/__mocks__/contacts-rdflib.ts', + }, setupFilesAfterEnv: ['./test/helpers/setup.ts'], testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], roots: ['/src', '/test', '/__mocks__'], diff --git a/package-lock.json b/package-lock.json index 245b7cfeb..cce2073d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", + "@solid-data-modules/contacts-rdflib": "^0.7.1", "escape-html": "^1.0.3", "mime-types": "^3.0.2", "pane-registry": "^3.0.2", @@ -4344,6 +4345,30 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@solid-data-modules/contacts-rdflib": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@solid-data-modules/contacts-rdflib/-/contacts-rdflib-0.7.1.tgz", + "integrity": "sha512-jjSVCyXjOdMlPEdTysboLg1Tc8E3jDFlbEIv7mjnNkFK61UdI/BfnNPT5XnNSUSiZYBZklUwsniJhclFhoZmBw==", + "license": "MIT", + "dependencies": { + "@solid-data-modules/rdflib-utils": "^0.2.0" + }, + "peerDependencies": { + "rdflib": "2.x" + } + }, + "node_modules/@solid-data-modules/rdflib-utils": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@solid-data-modules/rdflib-utils/-/rdflib-utils-0.2.0.tgz", + "integrity": "sha512-WXpyiMmgmeeTHUz/jFGGBy02GxClxT2uew3eUWh/XOQQQeOxlzYFRO0tOa3Nv9/3Y1qcAyS7tSaW5x42Q8WPLQ==", + "license": "MIT", + "dependencies": { + "short-unique-id": "^5.2.0" + }, + "peerDependencies": { + "rdflib": "2.x" + } + }, "node_modules/@storybook/addon-actions": { "version": "8.6.15", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.15.tgz", @@ -15852,6 +15877,16 @@ "node": ">=8" } }, + "node_modules/short-unique-id": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/short-unique-id/-/short-unique-id-5.3.2.tgz", + "integrity": "sha512-KRT/hufMSxXKEDSQujfVE0Faa/kZ51ihUcZQAcmP04t00DvPj7Ox5anHke1sJYUtzSuiT/Y5uyzg/W7bBEGhCg==", + "license": "Apache-2.0", + "bin": { + "short-unique-id": "bin/short-unique-id", + "suid": "bin/short-unique-id" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", diff --git a/package.json b/package.json index b412ec9eb..03c85ba30 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", + "@solid-data-modules/contacts-rdflib": "^0.7.1", "escape-html": "^1.0.3", "mime-types": "^3.0.2", "pane-registry": "^3.0.2", diff --git a/src/widgets/index.js b/src/widgets/index.js index 3b6f8e51d..5469c2615 100644 --- a/src/widgets/index.js +++ b/src/widgets/index.js @@ -18,6 +18,7 @@ // export widgets with the same name) export * from './peoplePicker' +export * from './peopleSearch' export * from './dragAndDrop' export * from './buttons' export * from './buttons/iconLinks' diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts new file mode 100644 index 000000000..c23ba9629 --- /dev/null +++ b/src/widgets/peopleSearch.ts @@ -0,0 +1,714 @@ +/** + * + * People Search Widget + * + * This widget offers a mechanism for selecting a set of individuals to take some action on. + * It discovers people from the user's FOAF profile (predicate: foaf:knows (friends)) and + * linked profiles, as well as from their address books, and allows searching through them by name. + * It currently traverses the FOAF graph up to 3 degrees of separation (friends of friends of friends) + * to find people, and also loads contacts from any linked address books. The search is performed + * client-side on the discovered set of people, allowing for fast filtering as the user types. + * Below the name of each person, a label indicates whether they are a direct friend, a contact + * from an address book, or just a person discovered through the FOAF graph. Contacts take precedence + * over friends, and friends take precedence over people when determining the label. + * Configurable options include a click handler for when a person is selected; otherwise, it + * opens their profile in a new tab or window. + * + * Assumptions + * - Assumes that the user has a type index entry for vcard:AddressBook. If this assumption is not met, no address book contacts will be discovered. + * + */ +import { NamedNode, type LiveStore } from 'rdflib' +import ContactsModuleRdfLib, { type AddressBook } from '@solid-data-modules/contacts-rdflib' +import * as debug from '../debug' +import { ns } from '..' + +const PEOPLE_SEARCH_CONCURRENCY = 6 +const CONTACT_CARD_CONCURRENCY = 8 +const MAX_FOAF_DISTANCE = 3 +let peopleSearchInstanceCounter = 0 +const addressBookListCache = new Map>() +const addressBookCache = new Map>() +const contactWebIdCache = new Map>() + +type PersonEntry = { + name: string, + webId: string, + relationshipLabel: 'Friend' | 'People' | 'Contact' +} + +export const createPeopleSearch = function ( + dom: HTMLDocument, + kb: LiveStore, + me: NamedNode | null, + onClickHandler?: (person: PersonEntry) => void +): HTMLFormElement { + peopleSearchInstanceCounter += 1 + const instanceId = `people-search-${peopleSearchInstanceCounter}` + const inputId = `${instanceId}-input` + const labelId = `${instanceId}-label` + const listboxId = `${instanceId}-listbox` + + const contactsModule = new ContactsModuleRdfLib({ + store: kb, + fetcher: kb.fetcher, + updater: kb.updater + }) + + // Add responsive styles for people search + const styleId = 'people-search-styles' + if (!dom.getElementById(styleId)) { + const style = dom.createElement('style') + style.id = styleId + style.textContent = ` + .people-search-input { + padding: 10px; + font-size: 16px; + box-sizing: border-box; + width: max(28%, 280px); + max-width: 80%; + } + .people-search-dropdown { + width: max(28%, 280px); + max-width: 80%; + } + .people-search-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + @media (max-width: 600px) { + .people-search-input, + .people-search-dropdown { + width: 80%; + } + } + ` + const styleContainer = dom.head || dom.documentElement || dom.body + styleContainer?.appendChild(style) + } + + const searchForm = dom.createElement('form') + const searchLabel = searchForm.appendChild(dom.createElement('label')) + searchLabel.id = labelId + searchLabel.htmlFor = inputId + searchLabel.className = 'people-search-sr-only' + searchLabel.textContent = 'Search for people' + + const searchInput = searchForm.appendChild(dom.createElement('input')) + searchInput.id = inputId + searchInput.type = 'text' + searchInput.placeholder = 'Search for people...' + searchInput.className = 'people-search-input' + searchInput.setAttribute('role', 'combobox') + searchInput.setAttribute('aria-autocomplete', 'list') + searchInput.setAttribute('aria-haspopup', 'listbox') + searchInput.setAttribute('aria-expanded', 'false') + searchInput.setAttribute('aria-labelledby', labelId) + searchInput.setAttribute('aria-controls', listboxId) + + const searchDiv = searchForm.appendChild(dom.createElement('div')) + searchDiv.id = listboxId + searchDiv.className = 'people-search-dropdown' + searchDiv.setAttribute('role', 'listbox') + searchDiv.setAttribute('aria-label', 'People search results') + searchDiv.style.display = 'none' + searchDiv.style.border = '1px solid #ccc' + searchDiv.style.marginTop = '5px' + searchDiv.style.padding = '5px' + searchDiv.style.boxSizing = 'border-box' + searchDiv.style.maxHeight = '15em' + searchDiv.style.overflowY = 'auto' + + const warmupHint = searchForm.appendChild(dom.createElement('div')) + warmupHint.style.display = 'none' + warmupHint.style.marginTop = '5px' + warmupHint.style.fontSize = '0.85em' + warmupHint.style.color = '#666' + warmupHint.textContent = 'Warming up contacts…' + + const liveStatus = searchForm.appendChild(dom.createElement('div')) + liveStatus.className = 'people-search-sr-only' + liveStatus.setAttribute('role', 'status') + liveStatus.setAttribute('aria-live', 'polite') + + const discoveredPeople = new Map() + const personRows = new Map() + const status = searchDiv.appendChild(dom.createElement('p')) + status.style.margin = '5px 0' + status.style.color = '#666' + + const setStatusText = function (text: string) { + status.textContent = text + liveStatus.textContent = text + } + + let activeRow: HTMLDivElement | null = null + + const setDropdownOpen = function (isOpen: boolean) { + searchDiv.style.display = isOpen ? 'block' : 'none' + searchInput.setAttribute('aria-expanded', isOpen ? 'true' : 'false') + } + + const getVisibleRows = function (): HTMLDivElement[] { + return Array.from(personRows.values()).filter(row => row.style.display !== 'none') + } + + const setActiveRow = function (row: HTMLDivElement | null) { + if (activeRow) { + activeRow.style.backgroundColor = 'white' + activeRow.setAttribute('aria-selected', 'false') + } + + activeRow = row + + if (activeRow) { + activeRow.style.backgroundColor = '#f0f0f0' + activeRow.setAttribute('aria-selected', 'true') + if (typeof activeRow.scrollIntoView === 'function') { + activeRow.scrollIntoView({ block: 'nearest' }) + } + if (activeRow.id) { + searchInput.setAttribute('aria-activedescendant', activeRow.id) + } + } else { + searchInput.removeAttribute('aria-activedescendant') + } + } + + const ensureActiveRowIsVisible = function () { + if (!activeRow) return + if (activeRow.style.display === 'none') { + setActiveRow(null) + } + } + + const selectPerson = function (person: PersonEntry) { + if (onClickHandler) { + onClickHandler(person) + } else { + const newWindow = window.open(person.webId, '_blank', 'noopener,noreferrer') + if (newWindow) { + newWindow.opener = null + } + } + setActiveRow(null) + setDropdownOpen(false) + } + + const addPersonRow = function (person: PersonEntry) { + const existingRow = personRows.get(person.webId) + if (existingRow) { + const nameElement = existingRow.firstChild as HTMLDivElement | null + const labelElement = existingRow.lastChild as HTMLDivElement | null + if (nameElement) { + nameElement.textContent = person.name + } + if (labelElement) { + labelElement.textContent = person.relationshipLabel + } + existingRow.title = person.webId + return existingRow + } + + const personElement = dom.createElement('div') + const optionIdSafeWebId = person.webId.replace(/[^a-zA-Z0-9_-]/g, '_') + const nameElement = personElement.appendChild(dom.createElement('div')) + const labelElement = personElement.appendChild(dom.createElement('div')) + + nameElement.textContent = person.name + labelElement.textContent = person.relationshipLabel + + personElement.title = person.webId + personElement.id = `${instanceId}-option-${optionIdSafeWebId}` + personElement.setAttribute('role', 'option') + personElement.setAttribute('aria-selected', 'false') + personElement.style.cursor = 'pointer' + personElement.style.margin = '5px 0' + personElement.style.padding = '2px 4px' + labelElement.style.fontSize = '0.75em' + labelElement.style.color = '#666' + + personElement.addEventListener('click', function () { + selectPerson(person) + }) + personElement.addEventListener('mouseover', function () { + setActiveRow(personElement) + }) + personElement.addEventListener('mouseout', function () { + if (activeRow !== personElement) { + personElement.style.backgroundColor = 'white' + } + }) + searchDiv.appendChild(personElement) + personRows.set(person.webId, personElement) + return personElement + } + + const sortVisibleRows = function () { + const visiblePeople = Array.from(discoveredPeople.values()) + .filter(person => { + const row = personRows.get(person.webId) + return row && row.style.display !== 'none' + }) + .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })) + + visiblePeople.forEach(person => { + const row = personRows.get(person.webId) + if (row) { + searchDiv.appendChild(row) + } + }) + } + + let sortQueued = false + const scheduleSortVisibleRows = function () { + if (sortQueued) return + sortQueued = true + + const flushSort = function () { + sortQueued = false + sortVisibleRows() + } + + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(flushSort) + return + } + + setTimeout(flushSort, 0) + } + + const updateVisibleRows = function (query: string): number { + let visibleCount = 0 + for (const [webId, person] of discoveredPeople.entries()) { + const row = personRows.get(webId) || addPersonRow(person) + const isVisible = matchesNameWords(person.name, query) + row.style.display = isVisible ? 'block' : 'none' + if (isVisible) { + visibleCount += 1 + } + } + scheduleSortVisibleRows() + ensureActiveRowIsVisible() + return visibleCount + } + + const updateRowVisibility = function (person: PersonEntry, query: string): boolean { + const row = personRows.get(person.webId) || addPersonRow(person) + const isVisible = matchesNameWords(person.name, query) + row.style.display = isVisible ? 'block' : 'none' + scheduleSortVisibleRows() + ensureActiveRowIsVisible() + return isVisible + } + + const tokenize = function (query: string): string[] { + return query + .toLowerCase() + .trim() + .split(/\s+/) + .filter(Boolean) + } + + const matchesNameWords = function (name: string, query: string): boolean { + const q = tokenize(query) + if (q.length === 0) return true + const nameWords = tokenize(name) + return q.every(word => nameWords.some(nameWord => nameWord.includes(word))) + } + + const nameFor = function (person: NamedNode): string | null { + const nameNode: { value: string } | null | undefined = + kb.any(person, ns.foaf('name')) || kb.any(person, ns.vcard('fn')) + return nameNode?.value || null + } + + const bestLabel = function ( + current: PersonEntry['relationshipLabel'] | undefined, + incoming: PersonEntry['relationshipLabel'] + ): PersonEntry['relationshipLabel'] { + if (current === 'Contact' || incoming === 'Contact') return 'Contact' + if (current === 'Friend' || incoming === 'Friend') return 'Friend' + return 'People' + } + + const mergePerson = function (person: PersonEntry) { + const existing = discoveredPeople.get(person.webId) + if (existing) { + discoveredPeople.set(person.webId, { + ...existing, + name: existing.name || person.name, + relationshipLabel: bestLabel(existing.relationshipLabel, person.relationshipLabel) + }) + return discoveredPeople.get(person.webId)! + } + discoveredPeople.set(person.webId, person) + return person + } + + const discoverPeople = async function (onPerson: (person: PersonEntry) => void | Promise) { + if (!me || !kb) return + + const visited = new Set() + const emitted = new Set() + const loadedDocs = new Set() + let queue: Array<{ person: NamedNode, depth: number }> = [{ person: me, depth: 0 }] + visited.add(me.value) + + const processPerson = async function ( + currentEntry: { person: NamedNode, depth: number } + ): Promise> { + const { person: current, depth } = currentEntry + const currentDoc = current.doc().value + if (!loadedDocs.has(currentDoc)) { + loadedDocs.add(currentDoc) + try { + await kb.fetcher.load(current.doc()) + } catch (_e) { /* skip inaccessible profiles */ } + } + + if (current.value !== me.value) { + const personName = nameFor(current) + if (personName && !emitted.has(current.value)) { + emitted.add(current.value) + const person: PersonEntry = { + name: personName, + webId: current.value, + relationshipLabel: depth === 1 ? 'Friend' : 'People' + } + await onPerson(person) + } + } + + const nextContacts: Array<{ person: NamedNode, depth: number }> = [] + if (depth >= MAX_FOAF_DISTANCE) { + return nextContacts + } + + const contacts = kb.each(current, ns.foaf('knows')) + for (const contact of contacts) { + if (contact.termType !== 'NamedNode') { + continue + } + const namedContact = contact as NamedNode + const contactName = nameFor(namedContact) + if (namedContact.value !== me.value && contactName && !emitted.has(namedContact.value)) { + emitted.add(namedContact.value) + await onPerson({ + name: contactName, + webId: namedContact.value, + relationshipLabel: depth === 0 ? 'Friend' : 'People' + }) + } + + if (!visited.has(namedContact.value)) { + visited.add(namedContact.value) + nextContacts.push({ person: namedContact, depth: depth + 1 }) + } + } + + return nextContacts + } + + while (queue.length > 0) { + const nextQueue: Array<{ person: NamedNode, depth: number }> = [] + + for (let index = 0; index < queue.length; index += PEOPLE_SEARCH_CONCURRENCY) { + const batch = queue.slice(index, index + PEOPLE_SEARCH_CONCURRENCY) + const batchContacts = await Promise.all(batch.map(processPerson)) + for (const contacts of batchContacts) { + nextQueue.push(...contacts) + } + } + + queue = nextQueue + } + } + + const loadAddressBooks = async function (): Promise { + if (!me || !kb) return [] + + const cachedAddressBooks = addressBookListCache.get(me.value) + if (cachedAddressBooks) { + return cachedAddressBooks + } + + const addressBooksPromise = contactsModule.listAddressBooks(me.value) + .then(addressBooks => [...addressBooks.publicUris, ...addressBooks.privateUris]) + .catch(error => { + addressBookListCache.delete(me.value) + throw error + }) + + addressBookListCache.set(me.value, addressBooksPromise) + return addressBooksPromise + } + + const webIdForAddressBookContact = async function (contactUri: string): Promise { + const cachedWebId = contactWebIdCache.get(contactUri) + if (cachedWebId) { + return cachedWebId + } + + const contactNode = new NamedNode(contactUri) + const webIdPromise = kb.fetcher.load(contactNode.doc()) + .then(function () { + const webIdNode = kb.any(contactNode, ns.vcard('url'), undefined, contactNode.doc()) as NamedNode | null + if (!webIdNode) return null + + return kb.anyValue(webIdNode, ns.vcard('value'), undefined, contactNode.doc()) || null + }) + .catch(function () { + return null + }) + + contactWebIdCache.set(contactUri, webIdPromise) + return webIdPromise + } + + const readAddressBookCached = async function (addressBookUri: string): Promise { + const cachedAddressBook = addressBookCache.get(addressBookUri) + if (cachedAddressBook) { + return cachedAddressBook + } + + const addressBookPromise = contactsModule.readAddressBook(addressBookUri) + .catch(error => { + addressBookCache.delete(addressBookUri) + throw error + }) + + addressBookCache.set(addressBookUri, addressBookPromise) + return addressBookPromise + } + + const discoverAddressBookContacts = async function ( + onPerson: (person: PersonEntry) => void | Promise + ) { + if (!me || !kb) return + + const addressBooks = await loadAddressBooks() + + for (const book of addressBooks) { + let addressBook: AddressBook + + try { + addressBook = await readAddressBookCached(book) + } catch (_e) { + continue + } + + for (let index = 0; index < addressBook.contacts.length; index += CONTACT_CARD_CONCURRENCY) { + const batch = addressBook.contacts.slice(index, index + CONTACT_CARD_CONCURRENCY) + const people = await Promise.all(batch.map(async function (contact) { + const contactWebId = await webIdForAddressBookContact(contact.uri) + if (!contactWebId) { + return null + } + + return { + name: contact.name, + webId: contactWebId, + relationshipLabel: 'Contact' as const + } + })) + + for (const person of people) { + if (!person) continue + await onPerson(person) + } + } + } + } + + let activeSearchId = 0 + let discoveryStarted = false + let discoveryPromise: Promise | null = null + + const ensureDiscovery = function () { + if (discoveryPromise) { + return discoveryPromise + } + + discoveryStarted = true + searchDiv.setAttribute('aria-busy', 'true') + setStatusText('Searching...') + warmupHint.style.display = 'block' + + discoveryPromise = (async function () { + const renderPerson = function (person: PersonEntry) { + try { + const merged = mergePerson(person) + addPersonRow(merged) + updateRowVisibility(merged, searchInput.value.trim()) + } catch (error) { + debug.error('[FOAF] Error rendering person:', error, person) + } + } + + const contactsPromise = discoverAddressBookContacts(function (person) { + try { + renderPerson(person) + } catch (error) { + debug.error('[Discovery] Error in contacts callback:', error) + } + }) + + const peoplePromise = discoverPeople(function (person) { + try { + renderPerson(person) + } catch (error) { + debug.error('[Discovery] Error in people callback:', error) + } + }) + + const results = await Promise.allSettled([contactsPromise, peoplePromise]) + if (results.every(result => result.status === 'rejected')) { + throw new Error('Unable to load contacts.') + } + })() + .catch(() => { + setStatusText('Unable to load contacts.') + }) + .finally(() => { + discoveryStarted = false + searchDiv.setAttribute('aria-busy', 'false') + warmupHint.style.display = 'none' + if (discoveredPeople.size === 0) { + setStatusText(me ? 'No contacts found.' : 'Sign in to search contacts.') + } else { + setStatusText('') + } + }) + + return discoveryPromise + } + + const runSearch = async function (query: string) { + const searchId = ++activeSearchId + setDropdownOpen(true) + + const visibleCount = updateVisibleRows(query.trim()) + if (!me) { + setStatusText('Sign in to search contacts.') + return + } + + if (!discoveryPromise) { + void ensureDiscovery() + } + + if (searchId !== activeSearchId) return + + if (visibleCount > 0) { + setStatusText(discoveryStarted ? 'Searching...' : '') + return + } + + setStatusText(discoveryStarted + ? 'Searching...' + : 'No contacts match that name.') + } + + let inputSearchQueued = false + const onInputHandler = function () { + if (inputSearchQueued) { + return + } + inputSearchQueued = true + + const flushInputSearch = function () { + inputSearchQueued = false + void runSearch(searchInput.value) + } + setTimeout(flushInputSearch, 0) + } + + const onFocusHandler = function () { + void runSearch(searchInput.value) + } + + const onBlurHandler = function () { + setTimeout(() => { + setActiveRow(null) + setDropdownOpen(false) + }, 200) + } + + const onKeyDownHandler = function (event: KeyboardEvent) { + const visibleRows = getVisibleRows() + + if (event.key === 'Tab') { + setActiveRow(null) + setDropdownOpen(false) + return + } + + if (event.key === 'Escape') { + setActiveRow(null) + setDropdownOpen(false) + return + } + + if (event.key === 'Home' || event.key === 'End') { + if (visibleRows.length === 0) { + return + } + event.preventDefault() + if (searchDiv.style.display === 'none') { + setDropdownOpen(true) + } + const targetIndex = event.key === 'Home' ? 0 : visibleRows.length - 1 + setActiveRow(visibleRows[targetIndex]) + return + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + if (searchDiv.style.display === 'none') { + setDropdownOpen(true) + } + if (visibleRows.length === 0) { + return + } + const currentIndex = activeRow ? visibleRows.indexOf(activeRow) : -1 + const nextIndex = event.key === 'ArrowDown' + ? Math.min(currentIndex + 1, visibleRows.length - 1) + : (currentIndex <= 0 ? visibleRows.length - 1 : currentIndex - 1) + setActiveRow(visibleRows[nextIndex]) + return + } + + if (event.key === 'Enter' && activeRow) { + event.preventDefault() + const selectedPerson = discoveredPeople.get(activeRow.title) + if (selectedPerson) { + selectPerson(selectedPerson) + } + } + } + + searchInput.addEventListener('input', onInputHandler) + searchInput.addEventListener('focus', onFocusHandler) + searchInput.addEventListener('blur', onBlurHandler) + searchInput.addEventListener('keydown', onKeyDownHandler) + + searchForm.addEventListener('submit', function (event) { + event.preventDefault() + void runSearch(searchInput.value) + }) + + if (me) { + void ensureDiscovery() + } + + return searchForm +} + diff --git a/test/unit/widgets/peopleSearch.test.ts b/test/unit/widgets/peopleSearch.test.ts new file mode 100644 index 000000000..0dfc333e2 --- /dev/null +++ b/test/unit/widgets/peopleSearch.test.ts @@ -0,0 +1,498 @@ +import { NamedNode } from 'rdflib' +import { silenceDebugMessages } from '../helpers/debugger' +import { createPeopleSearch } from '../../../src/widgets/peopleSearch' + +const mockListAddressBooks = jest.fn() +const mockReadAddressBook = jest.fn() +let bookCounter = 0 + +jest.mock('@solid-data-modules/contacts-rdflib', () => ({ + __esModule: true, + default: class ContactsModuleRdfLib { + listAddressBooks = mockListAddressBooks + readAddressBook = mockReadAddressBook + } +})) + +silenceDebugMessages() + +const flushAsyncWork = async function () { + await Promise.resolve() + await new Promise(resolve => setTimeout(resolve, 0)) + await Promise.resolve() +} + +const flushDiscovery = async function () { + await flushAsyncWork() + await flushAsyncWork() + await flushAsyncWork() +} + +type KbOptions = { + namesByWebId?: Record + contactWebIdsByCardUri?: Record + knowsByWebId?: Record> +} + +const makeKb = function (options: KbOptions = {}) { + const namesByWebId = options.namesByWebId || {} + const contactWebIdsByCardUri = options.contactWebIdsByCardUri || { + 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me' + } + const knowsByWebId = options.knowsByWebId || {} + + return { + fetcher: { + load: jest.fn().mockResolvedValue(undefined) + }, + updater: {}, + any: jest.fn((subject, predicate) => { + const subjectValue = subject?.value + const predicateValue = predicate?.value || '' + + if (!subjectValue) { + return null + } + + if (predicateValue.includes('foaf/0.1/name') || predicateValue.endsWith('#name')) { + const personName = namesByWebId[subjectValue] + return personName ? { value: personName } : null + } + + if (predicateValue.includes('/2006/vcard/ns#fn')) { + const personName = namesByWebId[subjectValue] + return personName ? { value: personName } : null + } + + if (predicateValue.includes('/2006/vcard/ns#url') && subjectValue in contactWebIdsByCardUri) { + return new NamedNode(subjectValue + '-url') + } + + return null + }), + anyValue: jest.fn((subject, predicate) => { + const subjectValue = subject?.value + const predicateValue = predicate?.value || '' + + if (!subjectValue || !predicateValue.includes('/2006/vcard/ns#value')) { + return null + } + + if (!subjectValue.endsWith('-url')) { + return null + } + + const cardUri = subjectValue.slice(0, -4) + return contactWebIdsByCardUri[cardUri] || null + }), + each: jest.fn((subject, predicate) => { + const subjectValue = subject?.value + const predicateValue = predicate?.value || '' + + if (!subjectValue || !predicateValue.includes('foaf/0.1/knows')) { + return [] + } + + return knowsByWebId[subjectValue] || [] + }) + } +} + +const openDropdown = async function (form: HTMLFormElement) { + const input = form.querySelector('input') as HTMLInputElement + input.dispatchEvent(new Event('focus')) + await flushDiscovery() +} + +const setSearchQuery = async function (form: HTMLFormElement, query: string) { + const input = form.querySelector('input') as HTMLInputElement + input.value = query + input.dispatchEvent(new Event('input')) + await flushDiscovery() +} + +const keyDown = function (element: HTMLElement, key: string) { + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) +} + +const rowFor = function (form: HTMLFormElement, webId: string) { + return form.querySelector(`div[title="${webId}"]`) as HTMLDivElement | null +} + +const rowLabel = function (row: HTMLDivElement | null) { + if (!row) return null + return (row.lastElementChild as HTMLDivElement | null)?.textContent || null +} + +describe('createPeopleSearch', () => { + beforeEach(() => { + document.body.innerHTML = '' + jest.clearAllMocks() + bookCounter += 1 + const defaultBookUri = `https://pod.example/address-book-${bookCounter}.ttl` + + mockListAddressBooks.mockResolvedValue({ + publicUris: [defaultBookUri], + privateUris: [] + }) + + mockReadAddressBook.mockResolvedValue({ + contacts: [ + { + uri: 'https://pod.example/contacts/1#this', + name: 'Alice Example' + } + ] + }) + }) + + it('renders a search input and hidden dropdown', () => { + const kb = makeKb() + const me = new NamedNode('https://user-1.example/profile/card#me') + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + const input = form.querySelector('input') as HTMLInputElement | null + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement | null + + expect(input).not.toBeNull() + expect(input?.placeholder).toBe('Search for people...') + expect(dropdown).not.toBeNull() + expect(dropdown?.style.display).toBe('none') + }) + + it('uses onClickHandler when provided and hides dropdown', async () => { + const kb = makeKb() + const me = new NamedNode('https://user-2.example/profile/card#me') + const onClickHandler = jest.fn() + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null) + + const form = createPeopleSearch(document, kb as any, me, onClickHandler) + document.body.appendChild(form) + + await flushDiscovery() + + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + + await openDropdown(form) + + const personRow = rowFor(form, 'https://alice.example/profile/card#me') + expect(personRow).not.toBeNull() + + personRow?.dispatchEvent(new Event('click')) + + expect(onClickHandler).toHaveBeenCalledTimes(1) + expect(onClickHandler).toHaveBeenCalledWith({ + name: 'Alice Example', + webId: 'https://alice.example/profile/card#me', + relationshipLabel: 'Contact' + }) + expect(openSpy).not.toHaveBeenCalled() + expect(dropdown.style.display).toBe('none') + + openSpy.mockRestore() + }) + + it('falls back to opening webId when onClickHandler is not provided', async () => { + const kb = makeKb() + const me = new NamedNode('https://user-3.example/profile/card#me') + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null) + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + await flushDiscovery() + + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + + await openDropdown(form) + + const personRow = rowFor(form, 'https://alice.example/profile/card#me') + expect(personRow).not.toBeNull() + + personRow?.dispatchEvent(new Event('click')) + + expect(openSpy).toHaveBeenCalledTimes(1) + expect(openSpy).toHaveBeenCalledWith('https://alice.example/profile/card#me', '_blank', 'noopener,noreferrer') + expect(dropdown.style.display).toBe('none') + + openSpy.mockRestore() + }) + + it('shows sign-in message when me is null', async () => { + const kb = makeKb() + + const form = createPeopleSearch(document, kb as any, null) + document.body.appendChild(form) + + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + + await openDropdown(form) + + expect(dropdown.style.display).toBe('block') + expect(dropdown.textContent).toContain('Sign in to search contacts.') + }) + + it('applies combobox/listbox accessibility attributes', async () => { + const kb = makeKb() + const me = new NamedNode('https://user-8.example/profile/card#me') + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + const input = form.querySelector('input') as HTMLInputElement + const label = form.querySelector('label') as HTMLLabelElement + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + const liveRegion = form.querySelector('div[role="status"]') as HTMLDivElement + + expect(label).not.toBeNull() + expect(label.textContent).toBe('Search for people') + expect(input.getAttribute('role')).toBe('combobox') + expect(input.getAttribute('aria-autocomplete')).toBe('list') + expect(input.getAttribute('aria-haspopup')).toBe('listbox') + expect(input.getAttribute('aria-labelledby')).toBe(label.id) + expect(input.getAttribute('aria-controls')).toBe(dropdown.id) + expect(input.getAttribute('aria-expanded')).toBe('false') + expect(liveRegion).not.toBeNull() + expect(typeof liveRegion.textContent).toBe('string') + + await openDropdown(form) + + const personRow = rowFor(form, 'https://alice.example/profile/card#me') + expect(dropdown.getAttribute('role')).toBe('listbox') + expect(dropdown.getAttribute('aria-busy')).toBe('false') + expect(input.getAttribute('aria-expanded')).toBe('true') + expect(personRow?.getAttribute('role')).toBe('option') + expect(personRow?.id).toContain('-option-') + }) + + it('supports keyboard navigation and selection from the input', async () => { + mockReadAddressBook.mockResolvedValue({ + contacts: [ + { + uri: 'https://pod.example/contacts/1#this', + name: 'Alice Example' + }, + { + uri: 'https://pod.example/contacts/2#this', + name: 'Bob Stone' + } + ] + }) + + const kb = makeKb({ + contactWebIdsByCardUri: { + 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me', + 'https://pod.example/contacts/2#this': 'https://bob.example/profile/card#me' + } + }) + const me = new NamedNode('https://user-9.example/profile/card#me') + const onClickHandler = jest.fn() + + const form = createPeopleSearch(document, kb as any, me, onClickHandler) + document.body.appendChild(form) + + await openDropdown(form) + + const input = form.querySelector('input') as HTMLInputElement + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') as HTMLDivElement + const bobRow = rowFor(form, 'https://bob.example/profile/card#me') as HTMLDivElement + + keyDown(input, 'ArrowDown') + expect(input.getAttribute('aria-activedescendant')).toBe(aliceRow.id) + expect(aliceRow.getAttribute('aria-selected')).toBe('true') + + keyDown(input, 'ArrowUp') + expect(input.getAttribute('aria-activedescendant')).toBe(bobRow.id) + expect(bobRow.getAttribute('aria-selected')).toBe('true') + + keyDown(input, 'Enter') + expect(onClickHandler).toHaveBeenCalledTimes(1) + expect(onClickHandler).toHaveBeenCalledWith({ + name: 'Bob Stone', + webId: 'https://bob.example/profile/card#me', + relationshipLabel: 'Contact' + }) + expect(dropdown.style.display).toBe('none') + expect(input.getAttribute('aria-expanded')).toBe('false') + + await openDropdown(form) + keyDown(input, 'Escape') + expect(dropdown.style.display).toBe('none') + expect(input.getAttribute('aria-expanded')).toBe('false') + }) + + it('supports Home/End navigation and closes on Tab', async () => { + mockReadAddressBook.mockResolvedValue({ + contacts: [ + { + uri: 'https://pod.example/contacts/1#this', + name: 'Alice Example' + }, + { + uri: 'https://pod.example/contacts/2#this', + name: 'Bob Stone' + } + ] + }) + + const kb = makeKb({ + contactWebIdsByCardUri: { + 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me', + 'https://pod.example/contacts/2#this': 'https://bob.example/profile/card#me' + } + }) + const me = new NamedNode('https://user-11.example/profile/card#me') + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + await openDropdown(form) + + const input = form.querySelector('input') as HTMLInputElement + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') as HTMLDivElement + const bobRow = rowFor(form, 'https://bob.example/profile/card#me') as HTMLDivElement + + keyDown(input, 'End') + expect(input.getAttribute('aria-activedescendant')).toBe(bobRow.id) + + keyDown(input, 'Home') + expect(input.getAttribute('aria-activedescendant')).toBe(aliceRow.id) + + keyDown(input, 'Tab') + expect(dropdown.style.display).toBe('none') + expect(input.getAttribute('aria-expanded')).toBe('false') + expect(input.getAttribute('aria-activedescendant')).toBeNull() + }) + + it('matches names by tokenized, case-insensitive words', async () => { + mockReadAddressBook.mockResolvedValue({ + contacts: [ + { + uri: 'https://pod.example/contacts/1#this', + name: 'Alice Example' + }, + { + uri: 'https://pod.example/contacts/2#this', + name: 'Bob Stone' + } + ] + }) + + const kb = makeKb({ + contactWebIdsByCardUri: { + 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me', + 'https://pod.example/contacts/2#this': 'https://bob.example/profile/card#me' + } + }) + const me = new NamedNode('https://user-4.example/profile/card#me') + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + await openDropdown(form) + await setSearchQuery(form, 'EXA ali') + + const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') + const bobRow = rowFor(form, 'https://bob.example/profile/card#me') + + expect(aliceRow).not.toBeNull() + expect(aliceRow?.style.display).toBe('block') + expect(bobRow).not.toBeNull() + expect(bobRow?.style.display).toBe('none') + }) + + it('skips non-NamedNode foaf:knows values during traversal', async () => { + mockListAddressBooks.mockResolvedValue({ publicUris: [], privateUris: [] }) + + const me = new NamedNode('https://user-5.example/profile/card#me') + const friend = new NamedNode('https://friend.example/profile/card#me') + const kb = makeKb({ + namesByWebId: { + [friend.value]: 'Frank Friend' + }, + knowsByWebId: { + [me.value]: [{ value: 'https://not-a-named-node.example/#it' }, friend] + } + }) + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + await openDropdown(form) + + const friendRow = rowFor(form, friend.value) + const bogusRow = rowFor(form, 'https://not-a-named-node.example/#it') + + expect(friendRow).not.toBeNull() + expect(rowLabel(friendRow)).toBe('Friend') + expect(bogusRow).toBeNull() + }) + + it('merges duplicate people and prefers Contact label over Friend', async () => { + const sharedWebId = 'https://alice.example/profile/card#me' + mockReadAddressBook.mockResolvedValue({ + contacts: [ + { + uri: 'https://pod.example/contacts/shared#this', + name: 'Alice Contact' + } + ] + }) + + const me = new NamedNode('https://user-6.example/profile/card#me') + const friend = new NamedNode(sharedWebId) + const kb = makeKb({ + contactWebIdsByCardUri: { + 'https://pod.example/contacts/shared#this': sharedWebId + }, + namesByWebId: { + [sharedWebId]: 'Alice Friend' + }, + knowsByWebId: { + [me.value]: [friend] + } + }) + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + await openDropdown(form) + + const mergedRow = rowFor(form, sharedWebId) + expect(mergedRow).not.toBeNull() + expect(rowLabel(mergedRow)).toBe('Contact') + }) + + it('shows no-match status after discovery when query has no results', async () => { + const kb = makeKb() + const me = new NamedNode('https://user-7.example/profile/card#me') + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + + await openDropdown(form) + await setSearchQuery(form, 'thiswillnotmatch') + + expect(dropdown.textContent).toContain('No contacts match that name.') + }) + + it('updates hidden live status text for no-match state', async () => { + const kb = makeKb() + const me = new NamedNode('https://user-10.example/profile/card#me') + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + const liveRegion = form.querySelector('div[role="status"]') as HTMLDivElement + + await openDropdown(form) + await setSearchQuery(form, 'no-person-will-match-this') + + expect(liveRegion.textContent).toContain('No contacts match that name.') + }) +})