diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 34c623b20..1b95344fc 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -3619,48 +3619,66 @@ function getCallHistoryGroupDataSync( ): unknown { return db.transaction(() => { const { limit, offset } = pagination; - const { status, conversationIds } = filter; - - // TODO: DESKTOP-6827 Search Calls Tab for adhoc calls - if (conversationIds != null) { - strictAssert(conversationIds.length > 0, "can't filter by empty array"); + const { status, conversationIds, callLinkRoomIds } = filter; + const isUsingTempTable = conversationIds != null || callLinkRoomIds != null; + if (isUsingTempTable) { const [createTempTable] = sql` - CREATE TEMP TABLE temp_callHistory_filtered_conversations ( - id TEXT, + CREATE TEMP TABLE temp_callHistory_filtered_peers ( + conversationId TEXT, serviceId TEXT, - groupId TEXT + groupId TEXT, + callLinkRoomId TEXT ); `; db.exec(createTempTable); + if (conversationIds != null) { + strictAssert(conversationIds.length > 0, "can't filter by empty array"); - batchMultiVarQuery(db, conversationIds, ids => { - const idList = sqlJoin(ids.map(id => sqlFragment`${id}`)); + batchMultiVarQuery(db, conversationIds, ids => { + const idList = sqlJoin(ids.map(id => sqlFragment`${id}`)); - const [insertQuery, insertParams] = sql` - INSERT INTO temp_callHistory_filtered_conversations - (id, serviceId, groupId) - SELECT id, serviceId, groupId - FROM conversations - WHERE conversations.id IN (${idList}); - `; + const [insertQuery, insertParams] = sql` + INSERT INTO temp_callHistory_filtered_peers + (conversationId, serviceId, groupId) + SELECT id, serviceId, groupId + FROM conversations + WHERE conversations.id IN (${idList}); + `; - db.prepare(insertQuery).run(insertParams); - }); + db.prepare(insertQuery).run(insertParams); + }); + } + + if (callLinkRoomIds != null) { + strictAssert(callLinkRoomIds.length > 0, "can't filter by empty array"); + + batchMultiVarQuery(db, callLinkRoomIds, ids => { + const idList = sqlJoin(ids.map(id => sqlFragment`(${id})`)); + + const [insertQuery, insertParams] = sql` + INSERT INTO temp_callHistory_filtered_peers + (callLinkRoomId) + VALUES ${idList}; + `; + + db.prepare(insertQuery).run(insertParams); + }); + } } - const innerJoin = - conversationIds != null - ? // peerId can be a conversation id (legacy), a serviceId, or a groupId - sqlFragment` - INNER JOIN temp_callHistory_filtered_conversations ON ( - temp_callHistory_filtered_conversations.id IS c.peerId - OR temp_callHistory_filtered_conversations.serviceId IS c.peerId - OR temp_callHistory_filtered_conversations.groupId IS c.peerId - ) - ` - : sqlFragment``; + // peerId can be a conversation id (legacy), a serviceId, groupId, or call link roomId + const innerJoin = isUsingTempTable + ? sqlFragment` + INNER JOIN temp_callHistory_filtered_peers ON ( + temp_callHistory_filtered_peers.conversationId IS c.peerId + OR temp_callHistory_filtered_peers.serviceId IS c.peerId + OR temp_callHistory_filtered_peers.groupId IS c.peerId + OR temp_callHistory_filtered_peers.callLinkRoomId IS c.peerId + ) + ` + : sqlFragment``; const filterClause = status === CallHistoryFilterStatus.All @@ -3795,9 +3813,9 @@ function getCallHistoryGroupDataSync( ? db.prepare(query).pluck(true).get(params) : db.prepare(query).all(params); - if (conversationIds != null) { + if (isUsingTempTable) { const [dropTempTableQuery] = sql` - DROP TABLE temp_callHistory_filtered_conversations; + DROP TABLE temp_callHistory_filtered_peers; `; db.exec(dropTempTableQuery); @@ -3819,6 +3837,11 @@ async function getCallHistoryGroupsCount( limit: 0, offset: 0, }); + + if (result == null) { + return 0; + } + return countSchema.parse(result); } @@ -5975,6 +5998,7 @@ async function removeAll(): Promise { DELETE FROM attachment_downloads; DELETE FROM badgeImageFiles; DELETE FROM badges; + DELETE FROM callLinks; DELETE FROM callsHistory; DELETE FROM conversations; DELETE FROM emojis; diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 45c94201d..c52af97e5 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -31,7 +31,7 @@ import type { PresentedSource, PresentableSource, } from '../../types/Calling'; -import type { CallLinkStateType } from '../../types/CallLink'; +import type { CallLinkStateType, CallLinkType } from '../../types/CallLink'; import { CALLING_REACTIONS_LIFETIME, MAX_CALLING_REACTIONS, @@ -174,15 +174,8 @@ export type AdhocCallsType = { [roomId: string]: GroupCallStateType; }; -export type CallLinksByRoomIdStateType = ReadonlyDeep< - CallLinkStateType & { - rootKey: string; - adminKey: string | null; - } ->; - export type CallLinksByRoomIdType = ReadonlyDeep<{ - [roomId: string]: CallLinksByRoomIdStateType; + [roomId: string]: CallLinkType; }>; // eslint-disable-next-line local-rules/type-alias-readonlydeep @@ -244,8 +237,7 @@ type GroupCallStateChangeActionPayloadType = }; type HandleCallLinkUpdateActionPayloadType = ReadonlyDeep<{ - roomId: string; - callLinkDetails: CallLinksByRoomIdStateType; + callLink: CallLinkType; }>; type HangUpActionPayloadType = ReadonlyDeep<{ @@ -384,6 +376,7 @@ type StartCallLinkLobbyPayloadType = { peekInfo?: GroupCallPeekInfoType; remoteParticipants: Array; callLinkState: CallLinkStateType; + callLinkRoomId: string; callLinkRootKey: string; }; @@ -1333,10 +1326,11 @@ function handleCallLinkUpdate( 'revoked', ]); - const callLinkDetails: CallLinksByRoomIdStateType = { + const callLink: CallLinkType = { ...CALL_LINK_DEFAULT_STATE, ...existingCallLinkState, ...freshCallLinkState, + roomId, rootKey, adminKey, }; @@ -1352,19 +1346,13 @@ function handleCallLinkUpdate( log.info(`${logId}: Updated existing call link state`); } } else { - await dataInterface.insertCallLink({ - roomId, - ...callLinkDetails, - }); + await dataInterface.insertCallLink(callLink); log.info(`${logId}: Saved new call link`); } dispatch({ type: HANDLE_CALL_LINK_UPDATE, - payload: { - roomId, - callLinkDetails, - }, + payload: { callLink }, }); }; } @@ -2006,6 +1994,7 @@ const _startCallLinkLobby = async ({ payload: { ...callLobbyData, callLinkState, + callLinkRoomId: roomId, callLinkRootKey: rootKey, conversationId: roomId, isConversationTooBigToRing: false, @@ -2415,6 +2404,9 @@ export function reducer( ...callLinks, [conversationId]: { ...action.payload.callLinkState, + roomId: + callLinks[conversationId]?.roomId ?? + action.payload.callLinkRoomId, rootKey: callLinks[conversationId]?.rootKey ?? action.payload.callLinkRootKey, @@ -3355,13 +3347,14 @@ export function reducer( if (action.type === HANDLE_CALL_LINK_UPDATE) { const { callLinks } = state; - const { roomId, callLinkDetails } = action.payload; + const { callLink } = action.payload; + const { roomId } = callLink; return { ...state, callLinks: { ...callLinks, - [roomId]: callLinkDetails, + [roomId]: callLink, }, }; } diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index 57074088c..5d7438374 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -79,17 +79,13 @@ export type CallLinkSelectorType = (roomId: string) => CallLinkType | undefined; export const getCallLinkSelector = createSelector( getCallLinksByRoomId, (callLinksByRoomId: CallLinksByRoomIdType): CallLinkSelectorType => - (roomId: string): CallLinkType | undefined => { - const callLinkState = getOwn(callLinksByRoomId, roomId); - if (!callLinkState) { - return; - } + (roomId: string): CallLinkType | undefined => + getOwn(callLinksByRoomId, roomId) +); - return { - roomId, - ...callLinkState, - }; - } +export const getAllCallLinks = createSelector( + getCallLinksByRoomId, + (lookup): Array => Object.values(lookup) ); export type CallSelectorType = ( diff --git a/ts/state/smart/CallsTab.tsx b/ts/state/smart/CallsTab.tsx index 81ebbc208..ffbb8baa1 100644 --- a/ts/state/smart/CallsTab.tsx +++ b/ts/state/smart/CallsTab.tsx @@ -28,6 +28,7 @@ import { useCallingActions } from '../ducks/calling'; import { getActiveCallState, getAdhocCallSelector, + getAllCallLinks, getCallSelector, getCallLinkSelector, } from '../selectors/calling'; @@ -36,41 +37,67 @@ import { getCallHistoryEdition } from '../selectors/callHistory'; import { getHasPendingUpdate } from '../selectors/updates'; import { getHasAnyFailedStorySends } from '../selectors/stories'; import { getOtherTabsUnreadStats } from '../selectors/nav'; +import type { CallLinkType } from '../../types/CallLink'; +import { filterCallLinks } from '../../util/filterCallLinks'; -function getCallHistoryFilter( - allConversations: Array, - regionCode: string | undefined, - options: CallHistoryFilterOptions -): CallHistoryFilter | null { +function getCallHistoryFilter({ + allCallLinks, + allConversations, + regionCode, + options, +}: { + allConversations: Array; + allCallLinks: Array; + regionCode: string | undefined; + options: CallHistoryFilterOptions; +}): CallHistoryFilter | null { + const { status } = options; const query = options.query.normalize().trim(); - if (query !== '') { - const currentConversations = allConversations.filter(conversation => { - return conversation.removalStage == null; - }); - - const filteredConversations = filterAndSortConversations( - currentConversations, - query, - regionCode - ); - - // If there are no matching conversations, then no calls will match. - if (filteredConversations.length === 0) { - return null; - } - + if (query === '') { return { - status: options.status, - conversationIds: filteredConversations.map(conversation => { - return conversation.id; - }), + status, + callLinkRoomIds: null, + conversationIds: null, }; } + let callLinkRoomIds = null; + let conversationIds = null; + + const currentConversations = allConversations.filter(conversation => { + return conversation.removalStage == null; + }); + + const filteredConversations = filterAndSortConversations( + currentConversations, + query, + regionCode + ); + + if (filteredConversations.length > 0) { + conversationIds = filteredConversations.map(conversation => { + return conversation.id; + }); + } + + const filteredCallLinks = filterCallLinks(allCallLinks, query); + if (filteredCallLinks.length > 0) { + callLinkRoomIds = filteredCallLinks.map(callLink => { + return callLink.roomId; + }); + } + + // If the search query resulted in no matching call links or conversations, then + // no calls will match. + if (callLinkRoomIds == null && conversationIds == null) { + return null; + } + return { - status: options.status, - conversationIds: null, + status, + callLinkRoomIds, + conversationIds, }; } @@ -99,6 +126,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { const { savePreferredLeftPaneWidth, toggleNavTabsCollapse } = useItemsActions(); + const allCallLinks = useSelector(getAllCallLinks); const allConversations = useSelector(getAllConversations); const regionCode = useSelector(getRegionCode); const getConversation = useSelector(getConversationSelector); @@ -129,11 +157,12 @@ export const SmartCallsTab = memo(function SmartCallsTab() { const getCallHistoryGroupsCount = useCallback( async (options: CallHistoryFilterOptions) => { - const callHistoryFilter = getCallHistoryFilter( + const callHistoryFilter = getCallHistoryFilter({ + allCallLinks, allConversations, regionCode, - options - ); + options, + }); if (callHistoryFilter == null) { return 0; } @@ -142,7 +171,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { ); return count; }, - [allConversations, regionCode] + [allCallLinks, allConversations, regionCode] ); const getCallHistoryGroups = useCallback( @@ -150,11 +179,12 @@ export const SmartCallsTab = memo(function SmartCallsTab() { options: CallHistoryFilterOptions, pagination: CallHistoryPagination ) => { - const callHistoryFilter = getCallHistoryFilter( + const callHistoryFilter = getCallHistoryFilter({ + allCallLinks, allConversations, regionCode, - options - ); + options, + }); if (callHistoryFilter == null) { return []; } @@ -164,7 +194,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { ); return results; }, - [allConversations, regionCode] + [allCallLinks, allConversations, regionCode] ); useEffect(() => { diff --git a/ts/test-both/helpers/fakeCallLink.ts b/ts/test-both/helpers/fakeCallLink.ts index 9c6b2dab5..4bc3c0532 100644 --- a/ts/test-both/helpers/fakeCallLink.ts +++ b/ts/test-both/helpers/fakeCallLink.ts @@ -11,15 +11,15 @@ export const FAKE_CALL_LINK: CallLinkType = { name: 'Fun Link', restrictions: CallLinkRestrictions.None, revoked: false, - roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4', - rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg', + roomId: 'd517b48dd118bee24068d4938886c8abe192706d84936d52594a9157189d2759', + rootKey: 'dxbb-xfqz-xkgp-nmrx-bpqn-ptkb-spdt-pdgt', }; // Please set expiration export const FAKE_CALL_LINK_WITH_ADMIN_KEY: CallLinkType = { adminKey: 'xXPI77e6MoVHYREW8iKYmQ==', expiration: Date.now() + MONTH, // set me - name: 'Fun Link', + name: 'Admin Link', restrictions: CallLinkRestrictions.None, revoked: false, roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4', diff --git a/ts/test-electron/sql/getCallHistoryGroups_test.ts b/ts/test-electron/sql/getCallHistoryGroups_test.ts index 85ff8b6f1..0277865fd 100644 --- a/ts/test-electron/sql/getCallHistoryGroups_test.ts +++ b/ts/test-electron/sql/getCallHistoryGroups_test.ts @@ -23,11 +23,16 @@ import { } from '../../types/CallDisposition'; import { strictAssert } from '../../util/assert'; import type { ConversationAttributesType } from '../../model-types'; +import { + FAKE_CALL_LINK, + FAKE_CALL_LINK_WITH_ADMIN_KEY, +} from '../../test-both/helpers/fakeCallLink'; const { removeAll, getCallHistoryGroups, getCallHistoryGroupsCount, + insertCallLink, saveCallHistory, saveConversation, } = dataInterface; @@ -90,7 +95,11 @@ describe('sql/getCallHistoryGroups', () => { await saveCallHistory(call2); const groups = await getCallHistoryGroups( - { status: CallHistoryFilterStatus.All, conversationIds: null }, + { + status: CallHistoryFilterStatus.All, + callLinkRoomIds: null, + conversationIds: null, + }, { offset: 0, limit: 0 } ); @@ -121,7 +130,11 @@ describe('sql/getCallHistoryGroups', () => { await saveCallHistory(call2); const groups = await getCallHistoryGroups( - { status: CallHistoryFilterStatus.All, conversationIds: null }, + { + status: CallHistoryFilterStatus.All, + callLinkRoomIds: null, + conversationIds: null, + }, { offset: 0, limit: 0 } ); @@ -156,7 +169,11 @@ describe('sql/getCallHistoryGroups', () => { await saveCallHistory(call4); const groups = await getCallHistoryGroups( - { status: CallHistoryFilterStatus.All, conversationIds: null }, + { + status: CallHistoryFilterStatus.All, + callLinkRoomIds: null, + conversationIds: null, + }, { offset: 0, limit: 0 } ); @@ -218,6 +235,7 @@ describe('sql/getCallHistoryGroups', () => { const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, + callLinkRoomIds: null, conversationIds: [conversation1.id], }, { offset: 0, limit: 0 } @@ -230,6 +248,7 @@ describe('sql/getCallHistoryGroups', () => { const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, + callLinkRoomIds: null, conversationIds: [conversation2.id], }, { offset: 0, limit: 0 } @@ -268,6 +287,7 @@ describe('sql/getCallHistoryGroups', () => { const groups = await getCallHistoryGroups( { status: CallHistoryFilterStatus.All, + callLinkRoomIds: null, conversationIds: [conversation.id], }, { offset: 0, limit: 0 } @@ -310,7 +330,11 @@ describe('sql/getCallHistoryGroups', () => { await saveCallHistory(call2); const groups = await getCallHistoryGroups( - { status: CallHistoryFilterStatus.Missed, conversationIds: null }, + { + status: CallHistoryFilterStatus.Missed, + callLinkRoomIds: null, + conversationIds: null, + }, { offset: 0, limit: 0 } ); @@ -328,7 +352,7 @@ describe('sql/getCallHistoryGroups', () => { ringerId: null, mode: CallMode.Adhoc, type: CallType.Adhoc, - direction: CallDirection.Outgoing, + direction: CallDirection.Incoming, timestamp, status: AdhocCallStatus.Joined, }; @@ -341,12 +365,127 @@ describe('sql/getCallHistoryGroups', () => { await saveCallHistory(call2); const groups = await getCallHistoryGroups( - { status: CallHistoryFilterStatus.All, conversationIds: null }, + { + status: CallHistoryFilterStatus.All, + callLinkRoomIds: null, + conversationIds: null, + }, { offset: 0, limit: 0 } ); assert.deepEqual(groups, [toAdhocGroup(call2)]); }); + + it('should search call links', async () => { + const now = Date.now(); + + const { roomId: roomId1 } = FAKE_CALL_LINK; + const { roomId: roomId2 } = FAKE_CALL_LINK_WITH_ADMIN_KEY; + + await insertCallLink(FAKE_CALL_LINK); + await insertCallLink(FAKE_CALL_LINK_WITH_ADMIN_KEY); + + const conversation1Uuid = generateAci(); + const conversation2GroupId = 'groupId:2'; + + const conversation1: ConversationAttributesType = { + type: 'private', + version: 0, + id: 'id:1', + serviceId: conversation1Uuid, + }; + + const conversation2: ConversationAttributesType = { + type: 'group', + version: 2, + id: 'id:2', + groupId: conversation2GroupId, + }; + + await saveConversation(conversation1); + await saveConversation(conversation2); + + function toAdhocCall(callId: string, roomId: string, timestamp: number) { + return { + callId, + peerId: roomId, + ringerId: null, + mode: CallMode.Adhoc, + type: CallType.Adhoc, + direction: CallDirection.Outgoing, + timestamp, + status: AdhocCallStatus.Joined, + }; + } + + function toConversationCall( + callId: string, + timestamp: number, + mode: CallMode, + peerId: string | ServiceIdString + ) { + return { + callId, + peerId, + ringerId: null, + mode, + type: CallType.Video, + direction: CallDirection.Incoming, + timestamp, + status: DirectCallStatus.Accepted, + }; + } + + const call1 = toAdhocCall('1', roomId1, now - 30); + const call2 = toAdhocCall('2', roomId2, now - 20); + const call3 = toConversationCall( + '3', + now - 10, + CallMode.Direct, + conversation1Uuid + ); + const call4 = toConversationCall( + '4', + now, + CallMode.Group, + conversation2GroupId + ); + + await saveCallHistory(call1); + await saveCallHistory(call2); + await saveCallHistory(call3); + await saveCallHistory(call4); + + { + const groups = await getCallHistoryGroups( + { + status: CallHistoryFilterStatus.All, + callLinkRoomIds: [roomId1], + conversationIds: null, + }, + { offset: 0, limit: 0 } + ); + + assert.deepEqual(groups, [toAdhocGroup(call1)], 'just call link'); + } + + { + const groups = await getCallHistoryGroups( + { + status: CallHistoryFilterStatus.All, + callLinkRoomIds: [roomId2], + conversationIds: [conversation2.id], + }, + { offset: 0, limit: 0 } + ); + + assert.deepEqual( + groups, + [toGroup([call4]), toAdhocGroup(call2)], + 'call link and conversation' + ); + } + }); }); describe('sql/getCallHistoryGroupsCount', () => { @@ -383,6 +522,7 @@ describe('sql/getCallHistoryGroupsCount', () => { const result = await getCallHistoryGroupsCount({ status: CallHistoryFilterStatus.All, + callLinkRoomIds: null, conversationIds: null, }); @@ -419,6 +559,7 @@ describe('sql/getCallHistoryGroupsCount', () => { const result = await getCallHistoryGroupsCount({ status: CallHistoryFilterStatus.All, + callLinkRoomIds: null, conversationIds: null, }); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 927a8f56d..57b01a255 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -1363,12 +1363,12 @@ describe('calling duck', () => { sinon.assert.calledWith(dispatch, { type: 'calling/HANDLE_CALL_LINK_UPDATE', payload: { - roomId, - callLinkDetails: { + callLink: { name, restrictions, expiration, revoked, + roomId, rootKey, adminKey, }, @@ -1383,12 +1383,12 @@ describe('calling duck', () => { sinon.assert.calledWith(dispatch, { type: 'calling/HANDLE_CALL_LINK_UPDATE', payload: { - roomId, - callLinkDetails: { + callLink: { name, restrictions, expiration, revoked, + roomId, rootKey, adminKey: 'banana', }, @@ -1451,6 +1451,7 @@ describe('calling duck', () => { payload: { ...callLobbyData, callLinkState, + callLinkRoomId: roomId, callLinkRootKey: rootKey, conversationId: roomId, isConversationTooBigToRing: false, diff --git a/ts/types/CallDisposition.ts b/ts/types/CallDisposition.ts index 41a2d4fc8..a3e35864b 100644 --- a/ts/types/CallDisposition.ts +++ b/ts/types/CallDisposition.ts @@ -133,6 +133,7 @@ export type CallHistoryFilterOptions = Readonly<{ export type CallHistoryFilter = Readonly<{ status: CallHistoryFilterStatus; + callLinkRoomIds: ReadonlyArray | null; conversationIds: ReadonlyArray | null; }>; diff --git a/ts/util/filterCallLinks.ts b/ts/util/filterCallLinks.ts new file mode 100644 index 000000000..734405867 --- /dev/null +++ b/ts/util/filterCallLinks.ts @@ -0,0 +1,86 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type Fuse from 'fuse.js'; +import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse'; +import { removeDiacritics } from './removeDiacritics'; +import type { CallLinkType } from '../types/CallLink'; + +// Based on parameters in filterAndSortConversations +const FUSE_OPTIONS: Fuse.IFuseOptions = { + threshold: 0.2, + includeScore: true, + useExtendedSearch: true, + shouldSort: true, + distance: 200, + keys: [ + { + name: 'name', + weight: 1, + }, + ], + getFn: (item, path) => { + if ( + (path === 'name' || (path.length === 1 && path[0] === 'name')) && + item.name === '' + ) { + return removeDiacritics( + window.i18n('icu:calling__call-link-default-title') + ); + } + + return fuseGetFnRemoveDiacritics(item, path); + }, +}; + +function searchCallLinks( + callLinks: ReadonlyArray, + searchTerm: string +): ReadonlyArray, 'item' | 'score'>> { + // TODO: DESKTOP-6974 + + // Escape the search term + const extendedSearchTerm = removeDiacritics(searchTerm); + + const index = getCachedFuseIndex(callLinks, FUSE_OPTIONS); + + return index.search(extendedSearchTerm); +} + +function startsWithLetter(title: string) { + return /^\p{Letter}/u.test(title); +} + +function sortAlphabetically(a: CallLinkType, b: CallLinkType) { + const aStartsWithLetter = startsWithLetter(a.name); + const bStartsWithLetter = startsWithLetter(b.name); + if (aStartsWithLetter && !bStartsWithLetter) { + return -1; + } + if (!aStartsWithLetter && bStartsWithLetter) { + return 1; + } + return a.name.localeCompare(b.name); +} + +export function filterCallLinks( + callLinks: ReadonlyArray, + searchTerm: string +): Array { + if (searchTerm.length) { + return searchCallLinks(callLinks, searchTerm) + .slice() + .sort((a, b) => { + const score = (a.score ?? 0) - (b.score ?? 0); + if (score !== 0) { + return score; + } + return sortAlphabetically(a.item, b.item); + }) + .map(result => result.item); + } + + return callLinks.concat().sort((a, b) => { + return sortAlphabetically(a, b); + }); +}