diff --git a/resources/js/components/chat/conversation-list.tsx b/resources/js/components/chat/conversation-list.tsx index be67175d7..ed210535d 100644 --- a/resources/js/components/chat/conversation-list.tsx +++ b/resources/js/components/chat/conversation-list.tsx @@ -343,7 +343,11 @@ function OrgAvatar({ ); } -function ConversationListSkeleton({ isCompact }: { isCompact: boolean }) { +export function ConversationListSkeleton({ + isCompact, +}: { + isCompact: boolean; +}) { return (
(null); const [query, setQuery] = useState(''); const [found, setFound] = useState(null); - const term = query.trim().replace(/^@/u, ''); + const term = query.trim().replace(/^@+/u, ''); useEffect(() => { if (term === '') { @@ -93,13 +97,7 @@ export function InboxPanel({ term, tab, conversations: matches, - developers: developers.filter( - (developer) => - !matches.some( - (match) => - match.partner?.id === developer.user.id, - ), - ), + developers, }), ) .catch(() => undefined); @@ -143,13 +141,24 @@ export function InboxPanel({ } }; - const results = - found !== null && found.term === term && found.tab === tab - ? found - : null; - const list = isSearching - ? (results?.conversations ?? null) - : { open: conversations, fetched, stashed: archived }[tab]; + const loaded = { open: conversations, fetched, stashed: archived }[tab]; + const earlier = found?.tab === tab ? found : null; + const results = earlier?.term === term ? earlier : null; + const list = + !isSearching || (loaded === null && earlier === null) + ? loaded + : conversationsMatching( + term, + [...(loaded ?? []), ...(earlier?.conversations ?? [])], + results?.conversations, + ); + const developers = (results?.developers ?? []).filter( + (developer) => + !list?.some( + (conversation) => + conversation.partner?.id === developer.user.id, + ), + ); return (
@@ -260,7 +269,7 @@ export function InboxPanel({ empty={ isSearching ? ( results !== null && - results.developers.length === 0 && ( + developers.length === 0 && (

+ {isSearching && results === null && list !== null && ( + + )} + {!isSearching && (

)} - {isSearching && - results !== null && - results.developers.length > 0 && ( -
-

- Start a conversation -

-
    - {results.developers.map( - ({ user: developer, reason }) => ( -
  • - - onOpen({ - kind: 'direct', - partner: developer, - }) - } - /> -
  • - ), - )} -
-
- )} + {developers.length > 0 && ( +
+

+ Start a conversation +

+
    + {developers.map(({ user: developer, reason }) => ( +
  • + + onOpen({ + kind: 'direct', + partner: developer, + }) + } + /> +
  • + ))} +
+
+ )}
); diff --git a/resources/js/lib/conversation-search.ts b/resources/js/lib/conversation-search.ts new file mode 100644 index 000000000..0d775e244 --- /dev/null +++ b/resources/js/lib/conversation-search.ts @@ -0,0 +1,33 @@ +import type { ConversationSummary, User } from '@/types'; + +/** + * Everything the server found for the term, then the conversations on hand that the inbox search would also find: + * by a partner's or member's name or handle, or an org's name. A conversation shows once, as its first copy. + */ +export function conversationsMatching( + term: string, + onHand: ConversationSummary[], + found: ConversationSummary[] = [], +): ConversationSummary[] { + const needle = term.toLowerCase(); + const isMatch = (value: string | null) => + value?.toLowerCase().includes(needle) ?? false; + const isPersonMatch = ({ username, name }: User) => + isMatch(username) || isMatch(name); + + return [ + ...found, + ...onHand.filter( + (conversation) => + (conversation.partner !== null && + isPersonMatch(conversation.partner)) || + isMatch(conversation.name) || + (conversation.members ?? []).some(({ user }) => + isPersonMatch(user), + ), + ), + ].filter( + (conversation, index, all) => + all.findIndex(({ id }) => id === conversation.id) === index, + ); +} diff --git a/tests/Unit/ConversationSearch.test.ts b/tests/Unit/ConversationSearch.test.ts new file mode 100644 index 000000000..569d5e76d --- /dev/null +++ b/tests/Unit/ConversationSearch.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { conversationsMatching } from '../../resources/js/lib/conversation-search.ts'; +import type { ConversationSummary, User } from '../../resources/js/types'; + +const person = (username: string, name: string) => ({ id: username, username, name }) as User; +const ana = person('ana', 'Ana Lovelace'); +const ben = person('benji', 'Ben Hopper'); + +const direct = (partner: User, changes: Partial = {}) => + ({ id: `direct-${partner.id}`, kind: 'direct', name: null, partner, members: null, ...changes }) as ConversationSummary; + +const org = (id: string, name: string | null, users: User[]) => + ({ id, kind: 'org', name, partner: null, members: users.map((user) => ({ user, role: null })) }) as ConversationSummary; + +const ids = (conversations: ConversationSummary[]) => conversations.map(({ id }) => id); + +test('finds loaded conversations by a partner or member handle or name, or an org name, ignoring case', () => { + const loaded = [direct(ana), direct(ben), org('core', 'Core Team', [ana]), org('crew', null, [ben])]; + + assert.deepEqual(ids(conversationsMatching('ANA', loaded)), ['direct-ana', 'core']); + assert.deepEqual(ids(conversationsMatching('hopp', loaded)), ['direct-benji', 'crew']); + assert.deepEqual(ids(conversationsMatching('core t', loaded)), ['core']); + assert.deepEqual(conversationsMatching('nobody', loaded), []); +}); + +test('keeps the first matching copy of a conversation found in several lists', () => { + const fresh = direct(ana, { ahead: 2 }); + const renamed = org('core', 'Core', [ben]); + + assert.deepEqual(conversationsMatching('ana', [fresh, renamed, direct(ana, { ahead: 0 }), org('core', 'Ana fans', [ben])]), [ + fresh, + org('core', 'Ana fans', [ben]), + ]); +}); + +test('keeps every conversation the server found for the term, ahead of stale copies on hand', () => { + const found = [org('core', 'Review Renamed Team', [ben]), direct(ana)]; + + assert.deepEqual(conversationsMatching('review', [org('core', 'Core', [ben])], found), [...found]); +});