From 75856677a417cb5833c38a171bce1c4f60d29d59 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 24 Oct 2026 01:16:53 +0200 Subject: [PATCH 1/2] feat: show loaded remotes instantly while searching --- .../js/components/chat/conversation-list.tsx | 6 +- resources/js/components/chat/inbox-panel.tsx | 95 ++++++++++--------- resources/js/lib/conversation-search.ts | 29 ++++++ tests/Unit/ConversationSearch.test.ts | 34 +++++++ 4 files changed, 120 insertions(+), 44 deletions(-) create mode 100644 resources/js/lib/conversation-search.ts create mode 100644 tests/Unit/ConversationSearch.test.ts 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 (
- !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 ?? [], + ); + 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..7835e6380 --- /dev/null +++ b/resources/js/lib/conversation-search.ts @@ -0,0 +1,29 @@ +import type { ConversationSummary, User } from '@/types'; + +/** + * The conversations already 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 in several lists shows once, as its first copy. + */ +export function conversationsMatching( + term: string, + ...lists: 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 lists + .flat() + .filter( + (conversation, index, all) => + all.findIndex(({ id }) => id === conversation.id) === index && + ((conversation.partner !== null && + isPersonMatch(conversation.partner)) || + isMatch(conversation.name) || + (conversation.members ?? []).some(({ user }) => + isPersonMatch(user), + )), + ); +} diff --git a/tests/Unit/ConversationSearch.test.ts b/tests/Unit/ConversationSearch.test.ts new file mode 100644 index 000000000..80331f1e6 --- /dev/null +++ b/tests/Unit/ConversationSearch.test.ts @@ -0,0 +1,34 @@ +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 copy of a conversation found in several lists', () => { + const fresh = direct(ana, { ahead: 2 }); + + assert.deepEqual(conversationsMatching('ana', [fresh], [direct(ana, { ahead: 0 }), org('core', 'Core', [ana])]), [ + fresh, + org('core', 'Core', [ana]), + ]); +}); From cf8aa22b0fe68db3a237d8cbb4379784c9186b24 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 24 Oct 2026 01:26:46 +0200 Subject: [PATCH 2/2] fix: keep server search hits ahead of stale loaded remotes --- resources/js/components/chat/inbox-panel.tsx | 6 ++-- resources/js/lib/conversation-search.ts | 32 +++++++++++--------- tests/Unit/ConversationSearch.test.ts | 13 ++++++-- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/resources/js/components/chat/inbox-panel.tsx b/resources/js/components/chat/inbox-panel.tsx index 534f243cf..bc5c09b5c 100644 --- a/resources/js/components/chat/inbox-panel.tsx +++ b/resources/js/components/chat/inbox-panel.tsx @@ -73,7 +73,7 @@ export function InboxPanel({ const end = useRef(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 === '') { @@ -149,8 +149,8 @@ export function InboxPanel({ ? loaded : conversationsMatching( term, - loaded ?? [], - earlier?.conversations ?? [], + [...(loaded ?? []), ...(earlier?.conversations ?? [])], + results?.conversations, ); const developers = (results?.developers ?? []).filter( (developer) => diff --git a/resources/js/lib/conversation-search.ts b/resources/js/lib/conversation-search.ts index 7835e6380..0d775e244 100644 --- a/resources/js/lib/conversation-search.ts +++ b/resources/js/lib/conversation-search.ts @@ -1,12 +1,13 @@ import type { ConversationSummary, User } from '@/types'; /** - * The conversations already 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 in several lists shows once, as its first copy. + * 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, - ...lists: ConversationSummary[][] + onHand: ConversationSummary[], + found: ConversationSummary[] = [], ): ConversationSummary[] { const needle = term.toLowerCase(); const isMatch = (value: string | null) => @@ -14,16 +15,19 @@ export function conversationsMatching( const isPersonMatch = ({ username, name }: User) => isMatch(username) || isMatch(name); - return lists - .flat() - .filter( - (conversation, index, all) => - all.findIndex(({ id }) => id === conversation.id) === index && - ((conversation.partner !== null && + return [ + ...found, + ...onHand.filter( + (conversation) => + (conversation.partner !== null && isPersonMatch(conversation.partner)) || - isMatch(conversation.name) || - (conversation.members ?? []).some(({ user }) => - isPersonMatch(user), - )), - ); + 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 index 80331f1e6..569d5e76d 100644 --- a/tests/Unit/ConversationSearch.test.ts +++ b/tests/Unit/ConversationSearch.test.ts @@ -24,11 +24,18 @@ test('finds loaded conversations by a partner or member handle or name, or an or assert.deepEqual(conversationsMatching('nobody', loaded), []); }); -test('keeps the first copy of a conversation found in several lists', () => { +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], [direct(ana, { ahead: 0 }), org('core', 'Core', [ana])]), [ + assert.deepEqual(conversationsMatching('ana', [fresh, renamed, direct(ana, { ahead: 0 }), org('core', 'Ana fans', [ben])]), [ fresh, - org('core', 'Core', [ana]), + 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]); +});