From 9d6c5243037546ed3f3bb03366ea617b403d9d83 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 24 Feb 2026 11:14:09 +0100 Subject: [PATCH] feat: add contact search to command bar (Spotlight-style) Transform the command bar from a simple phone dialer into a full contact search bar. Users can now type a name to search the phonebook and local operators, navigate results with arrow keys, and call with Enter. - Search phonebook via API with 250ms debounce and stale-request cancellation - Local operator matching (name + extension) displayed first, deduped against phonebook results - Operator results show avatar and presence indicator - Keyboard navigation: ArrowUp/Down to select, Enter to call, Escape to close - Dynamic window resize via new COMMAND_BAR_RESIZE IPC event with full setBounds rect (avoids DPI issues on Windows) - Cache operators/avatars with 30s TTL to avoid redundant API calls - Memoize expensive computations (operator filtering, result merging) - Fix IPC listener leak: proper cleanup on effect re-run - Non-mutating mapContact for safer data handling --- public/locales/en/translations.json | 2 +- public/locales/it/translations.json | 2 +- .../controllers/CommandBarController.ts | 37 +- src/main/lib/ipcEvents.ts | 8 + .../public/locales/en/translations.json | 2 +- .../public/locales/it/translations.json | 2 +- src/renderer/src/pages/CommandBarPage.tsx | 490 +++++++++++++++--- src/shared/constants.ts | 1 + 8 files changed, 453 insertions(+), 91 deletions(-) diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 03cacd38..29f293c3 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -936,7 +936,7 @@ "download": "Download the update" }, "CommandBar": { - "Placeholder": "Enter a phone number...", + "Placeholder": "Search contact or dial number...", "Call": "Call" }, "Errors": { diff --git a/public/locales/it/translations.json b/public/locales/it/translations.json index 6a803402..95167154 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -936,7 +936,7 @@ "download": "Scarica l'aggiornamento" }, "CommandBar": { - "Placeholder": "Inserisci un numero di telefono...", + "Placeholder": "Cerca contatto o componi numero...", "Call": "Chiama" }, "Errors": { diff --git a/src/main/classes/controllers/CommandBarController.ts b/src/main/classes/controllers/CommandBarController.ts index fabf69d1..b66f18d8 100644 --- a/src/main/classes/controllers/CommandBarController.ts +++ b/src/main/classes/controllers/CommandBarController.ts @@ -40,21 +40,20 @@ export class CommandBarController { // Start grace period to ignore blur events during focus transition this.isShowingInProgress = true - // Restore original size if it was reset to [0,0] - window.setBounds({ - width: this.originalSize.width, - height: this.originalSize.height - }) - const cursorPoint = screen.getCursorScreenPoint() const currentDisplay = screen.getDisplayNearestPoint(cursorPoint) const { x, y, width, height } = currentDisplay.workArea - const windowBounds = window.getBounds() - const centerX = x + Math.round((width - windowBounds.width) / 2) + const centerX = x + Math.round((width - this.originalSize.width) / 2) const centerY = y + Math.round(height * 0.3) - window.setBounds({ x: centerX, y: centerY }) + // Always pass the full rect to setBounds to avoid DPI issues on Windows + window.setBounds({ + x: centerX, + y: centerY, + width: this.originalSize.width, + height: this.originalSize.height + }) const isWindows = process.platform === 'win32' @@ -102,8 +101,6 @@ export class CommandBarController { const window = this.window.getWindow() if (window && this.isVisible) { this.isVisible = false - // Reset size to [0,0] to avoid slowness/inconsistent state - same as PhoneIsland pattern - window.setBounds({ width: 0, height: 0 }) if (isMac) { window.hide() @@ -124,6 +121,24 @@ export class CommandBarController { } } + resize(size: { width: number, height: number }) { + try { + const window = this.window.getWindow() + if (window && this.isVisible) { + const bounds = window.getBounds() + // Always pass the full rect to avoid DPI issues on Windows + window.setBounds({ + x: bounds.x, + y: bounds.y, + width: size.width, + height: size.height + }) + } + } catch (e) { + Log.warning('error during resizing CommandBarWindow:', e) + } + } + toggle() { // Throttle toggle calls to prevent rapid open/close cycles const now = Date.now() diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index 46dcccfa..afea3fff 100644 --- a/src/main/lib/ipcEvents.ts +++ b/src/main/lib/ipcEvents.ts @@ -550,6 +550,14 @@ export function registerIpcEvents() { } }) + ipcMain.on(IPC_EVENTS.COMMAND_BAR_RESIZE, (_, size: { width: number, height: number }) => { + try { + CommandBarController.instance?.resize(size) + } catch (e) { + Log.error('COMMAND_BAR_RESIZE error', e) + } + }) + ipcMain.on(IPC_EVENTS.CHANGE_COMMAND_BAR_SHORTCUT, async (_, combo) => { if (!isUserLoggedIn()) { disableCommandBarShortcuts() diff --git a/src/renderer/public/locales/en/translations.json b/src/renderer/public/locales/en/translations.json index 82137cb1..5e0deabe 100644 --- a/src/renderer/public/locales/en/translations.json +++ b/src/renderer/public/locales/en/translations.json @@ -937,7 +937,7 @@ "download": "Download the update" }, "CommandBar": { - "Placeholder": "Enter a phone number...", + "Placeholder": "Search contact or dial number...", "Call": "Call" }, "Errors": { diff --git a/src/renderer/public/locales/it/translations.json b/src/renderer/public/locales/it/translations.json index e398b953..95462246 100644 --- a/src/renderer/public/locales/it/translations.json +++ b/src/renderer/public/locales/it/translations.json @@ -937,7 +937,7 @@ "download": "Scarica l'aggiornamento" }, "CommandBar": { - "Placeholder": "Inserisci un numero di telefono...", + "Placeholder": "Cerca contatto o componi numero...", "Call": "Chiama" }, "Errors": { diff --git a/src/renderer/src/pages/CommandBarPage.tsx b/src/renderer/src/pages/CommandBarPage.tsx index 0678b3c4..a947d555 100644 --- a/src/renderer/src/pages/CommandBarPage.tsx +++ b/src/renderer/src/pages/CommandBarPage.tsx @@ -1,126 +1,464 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { IPC_EVENTS } from '@shared/constants' import { useSharedState } from '@renderer/store' import { useTranslation } from 'react-i18next' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faPhone, faSearch, faXmark } from '@fortawesome/free-solid-svg-icons' +import { faPhone, faSearch, faXmark, faUser, faBuilding } from '@fortawesome/free-solid-svg-icons' import classNames from 'classnames' import { parseThemeToClassName } from '@renderer/utils' -import { TextInput } from '@renderer/components/Nethesis' +import { useLoggedNethVoiceAPI } from '@renderer/hooks/useLoggedNethVoiceAPI' +import { AvatarType, OperatorsType, SearchCallData, SearchData, StatusTypes } from '@shared/types' +import { cleanRegex, getIsPhoneNumber, sortByProperty } from '@renderer/lib/utils' +import { debouncer } from '@shared/utils/utils' +import { Avatar } from '@renderer/components/Nethesis' + +// Window = 500x80 initial. Border is faked via outer bg + p-[1px]. +// Outer div: bg = border color, p-[1px], rounded-xl → 500x80 +// Inner div: bg = content color, rounded-[11px] → 498x78 content area +// The 1px gap between outer and inner IS the visible border. +const INPUT_HEIGHT = 80 +const INPUT_ROW_HEIGHT = 78 // 80 - 2px for the fake border +const RESULT_ROW_HEIGHT = 56 +const DROPDOWN_PADDING = 8 +const SEPARATOR_HEIGHT = 1 +const MAX_VISIBLE_RESULTS = 5 +const WINDOW_WIDTH = 500 +const MIN_SEARCH_LENGTH = 3 +const DEBOUNCE_MS = 250 +const CACHE_TTL_MS = 30_000 // Re-fetch operators/avatars only if older than 30s + +function mapContact(contact: SearchData): SearchData { + const hasName = contact?.name && contact?.name !== '-' + return { + ...contact, + kind: hasName ? 'person' : 'company', + displayName: hasName ? contact.name : contact?.company, + contacts: contact.contacts && typeof contact.contacts === 'string' + ? JSON.parse(contact.contacts) + : contact.contacts, + } +} + +function getPrimaryNumber(contact: SearchData): string { + const keys: (keyof SearchData)[] = ['extension', 'cellphone', 'homephone', 'workphone'] + for (const key of keys) { + if (contact[key]) return contact[key] as string + } + return '' +} export function CommandBarPage() { const { t } = useTranslation() const [theme] = useSharedState('theme') - const [phoneNumber, setPhoneNumber] = useState('') + const { NethVoiceAPI } = useLoggedNethVoiceAPI() + const [searchText, setSearchText] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(-1) + const [isLoading, setIsLoading] = useState(false) const inputRef = useRef(null) + const resultsRef = useRef(null) + const searchIdRef = useRef(0) + const operatorsRef = useRef(null) + const avatarsRef = useRef(null) + const lastFetchRef = useRef(0) // Timestamp of last successful operators/avatars fetch + const [operatorVersion, setOperatorVersion] = useState(0) // Bumped when operators data changes, triggers useMemo + + const isPhoneNumber = searchText.trim().length > 0 && getIsPhoneNumber(searchText.trim()) + + // Local operator search — same logic as SearchNumberBox.getFoundedOperators() + // operatorsRef is fetched once on SHOW_COMMAND_BAR via User.all_endpoints() + // operatorVersion triggers re-computation when fresh data arrives + const matchingOperators = useMemo(() => { + const trimmed = searchText.trim() + if (trimmed.length < MIN_SEARCH_LENGTH || !operatorsRef.current) return [] + const cleanQuery = trimmed.replace(cleanRegex, '') + if (!cleanQuery) return [] + + const queryRegex = new RegExp(cleanQuery, 'i') + const results = Object.values(operatorsRef.current).filter((op: any) => { + return ( + (op.name && queryRegex.test(op.name.replace(cleanRegex, ''))) || + (op.endpoints?.mainextension?.[0]?.id && + queryRegex.test(op.endpoints.mainextension[0].id)) + ) + }) + results.sort(sortByProperty('name')) + + return results.map((op: any) => ({ + type: 'contact' as const, + contact: { + displayName: op.name, + kind: 'person' as const, + extension: op.endpoints?.mainextension?.[0]?.id || '', + isOperator: true, + } as SearchData, + username: op.username as string, + mainPresence: op.mainPresence as StatusTypes, + })) + }, [searchText, operatorVersion]) + + // Merge: operators first (deduped by extension), then phonebook results + const allItems = useMemo(() => { + const operatorExtensions = new Set(matchingOperators.map((o) => o.contact.extension)) + const filteredPhonebook = searchResults.filter((c) => { + if (c.extension && operatorExtensions.has(c.extension)) return false + return true + }) + + const items: { type: 'call' | 'contact'; contact?: SearchData; number?: string; username?: string; mainPresence?: StatusTypes }[] = [] + + if (isPhoneNumber && searchText.trim().length > 0) { + items.push({ type: 'call', number: searchText.trim() }) + } + matchingOperators.forEach((op) => items.push(op)) + filteredPhonebook.forEach((contact) => { + items.push({ type: 'contact', contact }) + }) + + return items + }, [matchingOperators, searchResults, isPhoneNumber, searchText]) + + const showDropdown = allItems.length > 0 + + const resizeWindow = useCallback((itemCount: number) => { + const visibleCount = Math.min(itemCount, MAX_VISIBLE_RESULTS) + const height = itemCount > 0 + ? INPUT_HEIGHT + SEPARATOR_HEIGHT + visibleCount * RESULT_ROW_HEIGHT + DROPDOWN_PADDING + : INPUT_HEIGHT + window.electron.send(IPC_EVENTS.COMMAND_BAR_RESIZE, { width: WINDOW_WIDTH, height }) + }, []) + + useEffect(() => { + resizeWindow(allItems.length) + }, [allItems.length, resizeWindow]) + + const doSearch = useCallback(async (query: string) => { + const currentId = ++searchIdRef.current + const trimmed = query.trim() + + if (trimmed.length < MIN_SEARCH_LENGTH) { + setSearchResults([]) + // Compute locally to avoid stale closure over render-scoped isPhoneNumber + const isPhone = trimmed.length > 0 && getIsPhoneNumber(trimmed) + setSelectedIndex(isPhone ? 0 : -1) + return + } + + setIsLoading(true) + try { + const result: SearchCallData = await NethVoiceAPI.Phonebook.search(trimmed) + if (currentId !== searchIdRef.current) return + + const mapped = result.rows + .map((c) => mapContact(c)) + .filter((c) => c.displayName && c.displayName !== '') + .sort(sortByProperty('displayName')) + + setSearchResults(mapped) + setSelectedIndex(0) + } catch { + if (currentId === searchIdRef.current) { + setSearchResults([]) + } + } finally { + if (currentId === searchIdRef.current) { + setIsLoading(false) + } + } + }, [NethVoiceAPI]) useEffect(() => { window.electron.receive(IPC_EVENTS.SHOW_COMMAND_BAR, () => { - setPhoneNumber('') + setSearchText('') + setSearchResults([]) + setSelectedIndex(-1) + setIsLoading(false) + searchIdRef.current++ + + // Fetch operators and avatars only if cache is stale (older than CACHE_TTL_MS) + const now = Date.now() + if (now - lastFetchRef.current > CACHE_TTL_MS) { + const fetchId = now + lastFetchRef.current = now + + Promise.all([ + NethVoiceAPI.User.all_endpoints(), + NethVoiceAPI.User.all_avatars(), + ]) + .then(([endpoints, avatars]: [OperatorsType, AvatarType]) => { + // Discard if a newer fetch was started + if (lastFetchRef.current !== fetchId) return + operatorsRef.current = endpoints + avatarsRef.current = avatars + setOperatorVersion((v) => v + 1) + }) + .catch(() => {}) + } - // Focus with retry mechanism to handle race conditions const focusInput = (attempt = 0) => { inputRef.current?.focus() - // Verify focus was successful, retry if not (up to 3 attempts) if (attempt < 3 && document.activeElement !== inputRef.current) { setTimeout(() => focusInput(attempt + 1), 50) } } - setTimeout(() => focusInput(), 50) }) - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - window.electron.send(IPC_EVENTS.HIDE_COMMAND_BAR) + return () => { + window.electron.removeAllListeners(IPC_EVENTS.SHOW_COMMAND_BAR) + } + }, [NethVoiceAPI]) + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value + setSearchText(value) + setSelectedIndex(-1) + + debouncer('command-bar-search', () => { + doSearch(value) + }, DEBOUNCE_MS) + } + + const handleCall = useCallback((number?: string) => { + const callNumber = number || searchText.trim() + if (!callNumber) return + + const prefixMatch = callNumber.match(/^[*#+]+/) + const prefix = prefixMatch ? prefixMatch[0] : '' + const sanitized = callNumber.replace(/[^\d]/g, '') + const finalNumber = prefix + sanitized + + if (/^([*#+]?)(\d{2,})$/.test(finalNumber)) { + window.electron.send(IPC_EVENTS.EMIT_START_CALL, finalNumber) + window.electron.send(IPC_EVENTS.HIDE_COMMAND_BAR) + } + }, [searchText]) + + const handleCallSelected = useCallback(() => { + if (selectedIndex >= 0 && selectedIndex < allItems.length) { + const item = allItems[selectedIndex] + if (item.type === 'call') { + handleCall(item.number) + } else if (item.type === 'contact' && item.contact) { + const number = getPrimaryNumber(item.contact) + if (number) { + handleCall(number) + } } + } else if (isPhoneNumber) { + handleCall(searchText.trim()) } + }, [selectedIndex, allItems, handleCall, isPhoneNumber, searchText]) - window.addEventListener('keydown', handleKeyDown) - return () => { - window.removeEventListener('keydown', handleKeyDown) + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + window.electron.send(IPC_EVENTS.HIDE_COMMAND_BAR) + return } - }, []) - const handleCall = () => { - const trimmedNumber = phoneNumber.trim() - if (trimmedNumber) { - const prefixMatch = trimmedNumber.match(/^[*#+]+/) - const prefix = prefixMatch ? prefixMatch[0] : '' - const sanitized = trimmedNumber.replace(/[^\d]/g, '') - const number = prefix + sanitized - - const isValidNumber = /^([*#+]?)(\d{2,})$/.test(number) - if (isValidNumber) { - window.electron.send(IPC_EVENTS.EMIT_START_CALL, number) - window.electron.send(IPC_EVENTS.HIDE_COMMAND_BAR) + if (e.key === 'ArrowDown') { + e.preventDefault() + if (allItems.length > 0) { + setSelectedIndex((prev) => (prev + 1) % allItems.length) } + return + } + + if (e.key === 'ArrowUp') { + e.preventDefault() + if (allItems.length > 0) { + setSelectedIndex((prev) => (prev - 1 + allItems.length) % allItems.length) + } + return } - } - const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { - handleCall() + e.preventDefault() + handleCallSelected() + return } } const handleClear = () => { - setPhoneNumber('') + setSearchText('') + setSearchResults([]) + setSelectedIndex(-1) + searchIdRef.current++ inputRef.current?.focus() } + useEffect(() => { + if (selectedIndex >= 0 && resultsRef.current) { + const el = resultsRef.current.children[selectedIndex] as HTMLElement + if (el) { + el.scrollIntoView({ block: 'nearest' }) + } + } + }, [selectedIndex]) + const themeClass = parseThemeToClassName(theme) + // Call button enabled when there's any text (same as original behavior) + const hasText = searchText.trim().length > 0 + return ( -
-
- setPhoneNumber(e.target.value)} - onKeyDown={handleKeyPress} - placeholder={t('CommandBar.Placeholder') || ''} - className="flex-1 dark:text-titleDark text-titleLight [&_input]:focus:ring-0 [&_input]:focus:border-gray-300 dark:[&_input]:focus:border-gray-600" - autoFocus - /> - - {phoneNumber && ( - - )} - -
+ + {searchText && ( + + )} + + +
+ + {/* Dropdown results */} + {showDropdown && ( + <> +
+
+ {allItems.map((item, index) => { + const isSelected = index === selectedIndex + if (item.type === 'call') { + return ( +
handleCall(item.number)} + onMouseEnter={() => setSelectedIndex(index)} + > + +
+ + {t('CommandBar.Call')} {item.number} + +
+
+ ) + } + + const contact = item.contact! + const number = getPrimaryNumber(contact) + const isOperator = !!contact.isOperator + const avatarSrc = isOperator && item.username + ? avatarsRef.current?.[item.username] || '' + : '' + return ( +
{ + if (number) handleCall(number) + }} + onMouseEnter={() => setSelectedIndex(index)} + > + {isOperator ? ( + + ) : ( + + )} +
+ + {contact.displayName} + + {number && ( + + {number} + + )} +
+
+ ) + })} +
+ )} - > - - {t('CommandBar.Call')} - +
) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 8474b79f..734baf03 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -109,6 +109,7 @@ export enum IPC_EVENTS { CHANGE_COMMAND_BAR_SHORTCUT = "CHANGE_COMMAND_BAR_SHORTCUT", INTRUDE_CALL = "INTRUDE_CALL", LISTEN_CALL = "LISTEN_CALL", + COMMAND_BAR_RESIZE = "COMMAND_BAR_RESIZE", } //PHONE ISLAND EVENTS