Skip to content

Commit b6cd478

Browse files
morethanwordsclaude
andcommitted
Search: found words stay highlighted as results come in, and a query that is over leaves the next one alone
More review of the smooth search results (1d435c2) found what it left behind: - Rows put into the shown list from the one built aside, or into the look-alike a held tab shows, lost the highlight of the query's words: a live range collapses when an ancestor of what it spans is moved, and the row's own observer sees nothing of that. Whoever moves highlighted rows now has their text found again (`reapplyTextHighlights` in textHighlight). - A load of a query that is over could still reach the next one's: create its group of found media under a dead middleware (its messages then never showed), make a row with its words, or mark Apps loaded. Every such step checks the query is still the current one after it has waited. - Opening another tab with nothing in it while a new query was held let go of what the held tab showed, tearing down its rows: only the new query's own reveal lets the queries before it go. - Apps and Channels without a query put their two groups in together, and a failed request of one (popular apps, similar channels) kept both out. The one that failed stays out now, the other comes in, and a row's picture that did not load no longer keeps the row out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ead940b commit b6cd478

4 files changed

Lines changed: 93 additions & 28 deletions

File tree

‎src/components/appSearchSuper.ts‎

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {commitSearchGroups, createSearchGroup, destroySearchRows, getSearchTrans
77
import {horizontalMenu} from '@components/horizontalMenu';
88
import LazyLoadQueue from '@components/lazyLoadQueue';
99
import {putPreloader} from '@components/putPreloader';
10+
import noop from '@helpers/noop';
11+
import {reapplyTextHighlights} from '@helpers/dom/textHighlight';
1012
import Scrollable from '@components/scrollable';
1113
import {ScrollableContextValue} from '@components/scrollable2';
1214
import useHeavyAnimationCheck, {getHeavyAnimationPromise} from '@hooks/useHeavyAnimationCheck';
@@ -1341,16 +1343,19 @@ export default class AppSearchSuper {
13411343
return filterMessagesByInputFilter({inputFilter: type, messages: messages, limit: messages.length});
13421344
}
13431345

1344-
private async processEmptyFilter({message, mediaTab}: ProcessSearchSuperResult) {
1346+
private async processEmptyFilter({message, mediaTab, middleware}: ProcessSearchSuperResult) {
13451347
const isSaved = mediaTab.type === 'saved';
1348+
const query = this.searchContext.query;
13461349

13471350
let peerId = message.peerId;
13481351
if(isSaved) {
13491352
peerId = message.fromId;
13501353
}
13511354
peerId = await this.managers.appPeersManager.getPeerMigratedTo(peerId) || peerId;
1352-
1353-
const middleware = this.middleware.get();
1355+
// * the query that asked is over: no row for it, under the next one's middleware or words
1356+
if(!middleware()) {
1357+
return;
1358+
}
13541359

13551360
const loadPromises: Promise<any>[] = [];
13561361
// * put in whole, with the others of its page (`performSearchResult`)
@@ -1375,7 +1380,7 @@ export default class AppSearchSuper {
13751380
} as any,
13761381
lastMessage: message,
13771382
dialogElement,
1378-
highlightWord: this.searchContext.query,
1383+
highlightWord: query,
13791384
noForwardIcon: isSaved
13801385
});
13811386

@@ -1616,6 +1621,10 @@ export default class AppSearchSuper {
16161621
let inputFilter = mediaTab.inputFilter;
16171622

16181623
await getHeavyAnimationPromise();
1624+
// * the query that asked is over: the search's context and its group of found media are the next one's
1625+
if(!middleware()) {
1626+
return 0;
1627+
}
16191628

16201629
let searchGroup: SearchGroup;
16211630
if(inputFilter === 'inputMessagesFilterPhotoVideo' && !!this.searchContext.query.trim()) {
@@ -2610,7 +2619,8 @@ export default class AppSearchSuper {
26102619
dom.lastMessageSpan.append(i18n('UnknownBotUsers'));
26112620
}
26122621

2613-
await Promise.all(loadPromises);
2622+
// * a picture that did not come does not keep its row out
2623+
await Promise.all(loadPromises).catch(noop);
26142624
return dom.containerEl;
26152625
}));
26162626

@@ -2627,6 +2637,23 @@ export default class AppSearchSuper {
26272637
}
26282638
}
26292639

2640+
/**
2641+
* Puts a tab's groups in together once their rows are there: one whose rows could not be had (a request of its
2642+
* failed) stays out, and the others go in all the same. False when the query is over by then
2643+
*/
2644+
private async appendRenderedGroups(mediaTab: SearchSuperMediaTab, middleware: Middleware, rendering: [SearchGroup, Promise<any>][]) {
2645+
const isRendered = await Promise.all(rendering.map(([, promise]) => promise.then(() => true, (err) => {
2646+
this.log.error('search group error:', err);
2647+
return false;
2648+
})));
2649+
if(!middleware()) {
2650+
return false;
2651+
}
2652+
2653+
mediaTab.itemsTab.append(...rendering.filter((_, idx) => isRendered[idx]).map(([group]) => group.container));
2654+
return true;
2655+
}
2656+
26302657
private async loadChannels({mediaTab, middleware}: SearchSuperLoadTypeOptions) {
26312658
const managers = bindManagersToSession(this.managers);
26322659
if(this.searchContext.query) {
@@ -2659,34 +2686,33 @@ export default class AppSearchSuper {
26592686
const channelDialogs = channelDialogsWithUndefined.filter(Boolean);
26602687

26612688
// * the groups go in together, once they have their rows - not the joined channels before the similar ones
2662-
const groups: SearchGroup[] = [];
2663-
const promises: Promise<any>[] = [];
2689+
const rendering: [SearchGroup, Promise<any>][] = [];
26642690
if(channelDialogs.length) {
26652691
const group = createSearchGroup({name: 'Chat.Search.JoinedChannels', type: 'channels', middleware});
26662692
group.setActive();
2667-
groups.push(group);
26682693

26692694
const SHOW_MORE_LIMIT = 5;
26702695
if(channelDialogs.length > SHOW_MORE_LIMIT) group.needShowMoreButton();
26712696

2672-
promises.push(this.renderPeerDialogs(channelDialogs.map((dialog) => dialog.peerId), group, middleware));
2697+
rendering.push([group, this.renderPeerDialogs(channelDialogs.map((dialog) => dialog.peerId), group, middleware)]);
26732698
}
26742699

2675-
const recommendations = await managers.appChatsManager.getChannelRecommendations();
2700+
// * on top of the joined ones: failing, it leaves them be
2701+
const recommendations = await managers.appChatsManager.getChannelRecommendations().catch((err): undefined => {
2702+
this.log.error('channel recommendations error:', err);
2703+
return undefined;
2704+
});
26762705
if(!middleware()) return;
26772706

2678-
if(recommendations.chats.length) {
2707+
if(recommendations?.chats.length) {
26792708
const group = createSearchGroup({name: 'SimilarChannels', type: 'channels', middleware});
26802709
group.setActive();
2681-
groups.push(group);
26822710

2683-
promises.push(this.renderPeerDialogs(recommendations.chats.map((chat) => chat.id.toPeerId(true)), group, middleware));
2711+
rendering.push([group, this.renderPeerDialogs(recommendations.chats.map((chat) => chat.id.toPeerId(true)), group, middleware)]);
26842712
}
26852713

2686-
await Promise.all(promises);
2687-
if(!middleware()) return;
2714+
if(!await this.appendRenderedGroups(mediaTab, middleware, rendering)) return;
26882715

2689-
mediaTab.itemsTab.append(...groups.map((group) => group.container));
26902716
this.afterPerforming(1, mediaTab);
26912717
this.loaded[mediaTab.type] = true;
26922718
}
@@ -2741,22 +2767,19 @@ export default class AppSearchSuper {
27412767
if(!middleware()) return;
27422768

27432769
// * the groups go in together, once they have their rows - not the first one before the popular apps
2744-
const groups: SearchGroup[] = [];
2745-
let topAppsPromise: Promise<void>;
2770+
const rendering: [SearchGroup, Promise<any>][] = [];
27462771
if(myTopApps.length) {
27472772
const group = createSearchGroup({name: 'MiniApps.Apps', type: 'apps', onFound: onClick, middleware});
27482773
group.setActive();
2749-
groups.push(group);
27502774

27512775
const SHOW_MORE_LIMIT = 5;
27522776
if(myTopApps.length > SHOW_MORE_LIMIT) group.needShowMoreButton();
27532777

2754-
topAppsPromise = this.renderPeerDialogs(myTopApps.map((app) => app.id.toPeerId(false)), group, middleware, 'bots');
2778+
rendering.push([group, this.renderPeerDialogs(myTopApps.map((app) => app.id.toPeerId(false)), group, middleware, 'bots')]);
27552779
}
27562780

27572781
const group = createSearchGroup({name: 'MiniApps.Popular', type: 'apps', onFound: onClick, middleware});
27582782
group.setActive();
2759-
groups.push(group);
27602783

27612784
type GetPopularAppsResult = ReturnType<typeof rootScope.managers.appAttachMenuBotsManager.getPopularAppBots>;
27622785
let currentOffset: string = '', loadPromise: GetPopularAppsResult;
@@ -2773,6 +2796,8 @@ export default class AppSearchSuper {
27732796
const {nextOffset, userIds} = await loadPromise;
27742797

27752798
await this.renderPeerDialogs(userIds.map((id) => id.toPeerId(false)), group, middleware, 'bots');
2799+
// * a query that is over marks nothing loaded: `loaded` is the next one's by now
2800+
if(!middleware()) return;
27762801

27772802
currentOffset = nextOffset || undefined;
27782803
loadPromise = undefined;
@@ -2790,10 +2815,9 @@ export default class AppSearchSuper {
27902815
}
27912816
});
27922817

2793-
await Promise.all([topAppsPromise, loadMoreApps()]);
2794-
if(!middleware()) return;
2818+
rendering.push([group, loadMoreApps()]);
2819+
if(!await this.appendRenderedGroups(mediaTab, middleware, rendering)) return;
27952820

2796-
mediaTab.itemsTab.append(...groups.map((group) => group.container));
27972821
this.afterPerforming(1, mediaTab);
27982822
}
27992823

@@ -3364,6 +3388,8 @@ export default class AppSearchSuper {
33643388
ghost.inert = true;
33653389
ghost.append(...content);
33663390
tabContainer.append(ghost);
3391+
// * the rows were moved into it: their highlights of the query are found again
3392+
reapplyTextHighlights(ghost);
33673393
return ghost;
33683394
}
33693395

@@ -3398,7 +3424,8 @@ export default class AppSearchSuper {
33983424
const wait = isNewQuery ? SEARCH_RESULTS_WAIT : SEARCH_RESULTS_WAIT + SEARCH_RESULTS_WAIT_LATE;
33993425
Promise.race([settle(promise), pause(wait)]).then(() => {
34003426
if(middleware()) {
3401-
this.revealTab(mediaTab, {resetScroll: isNewQuery, isLast: true});
3427+
// * only a new query's reveal lets the queries before it go: a tab opened empty held none of theirs
3428+
this.revealTab(mediaTab, {resetScroll: isNewQuery, isLast: isNewQuery});
34023429
}
34033430
});
34043431
return;
@@ -3427,7 +3454,7 @@ export default class AppSearchSuper {
34273454
groups: revealed.length === parts.length ? undefined : flatten(revealed.map((part) => part.groups)),
34283455
resetScroll,
34293456
merge,
3430-
isLast: !merge && parts.every((part) => part.isShown)
3457+
isLast: isNewQuery && !merge && parts.every((part) => part.isShown)
34313458
});
34323459
resetScroll = false;
34333460
};

‎src/components/searchGroup.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {hotReloadGuard} from '@lib/solidjs/hotReloadGuardProvider';
99
import {CROSS_FADE_KEYFRAMES, getAnimatedOptions} from '@helpers/dom/crossfadeContent';
1010
import liteMode from '@helpers/liteMode';
1111
import deferredPromise from '@helpers/cancellablePromise';
12+
import {reapplyTextHighlights} from '@helpers/dom/textHighlight';
1213

1314
export type SearchGroupType = 'contacts' | 'globalContacts' | 'messages' | string;
1415

@@ -231,6 +232,9 @@ export function createSearchGroup(options: {
231232
} else {
232233
list.append(...newRows);
233234
}
235+
236+
// * moved from the draft, as the rows leaving are into their list: their highlights are found again
237+
reapplyTextHighlights(container);
234238
};
235239

236240
const applyRest = () => {

‎src/helpers/dom/textHighlight.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,19 @@ hotEffect(import.meta.hot, () => {
526526
// * every live highlight, so the ones whose container silently left the document (a cleared
527527
// * search list, an ejected bubble) can be released without a hook at each call site
528528
const instances = new Set<TextHighlighter>();
529+
530+
/**
531+
* Finds the text of every highlight inside `root` again. Its ranges collapse when an ancestor of its container
532+
* is moved to another parent (rows put in from a list built aside, held in a look-alike of what they were in),
533+
* and the container's own observer sees nothing of that: whoever moves highlighted rows calls this after
534+
*/
535+
export function reapplyTextHighlights(root: Node) {
536+
for(const instance of instances) {
537+
if(root.contains(instance.container)) {
538+
instance.apply();
539+
}
540+
}
541+
}
529542
const SWEEP_INTERVAL = 30e3;
530543
// * a container may be detached only briefly (virtual lists reattach rows) — give it time
531544
const DETACHED_GRACE = 10e3;
@@ -641,7 +654,7 @@ class TextHighlighter implements TextHighlight {
641654
this.found = false;
642655
}
643656

644-
private apply() {
657+
public apply() {
645658
if(this.disposed) {
646659
return;
647660
}

‎src/tests/textHighlight.test.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ import createHighlight, {
2626
computeRanges,
2727
extractText,
2828
findQuoteSpan,
29-
findSearchSpans
29+
findSearchSpans,
30+
reapplyTextHighlights
3031
} from '@helpers/dom/textHighlight';
3132

3233
const highlights = new Set<TextHighlight>();
@@ -395,6 +396,26 @@ describe('highlightText', () => {
395396
highlight.dispose();
396397
});
397398

399+
it('finds its text again once a row moved to another list is reapplied', () => {
400+
// * a row highlighted in a list built aside (a search group's draft), then put in the shown one
401+
const draft = document.createElement('div');
402+
const row = document.createElement('div');
403+
row.innerHTML = '<span>moved text</span>';
404+
draft.append(row);
405+
const highlight = highlightText({container: row, match: {type: 'search', query: 'moved'}});
406+
const texts = () => registered('tg-search').map((range) => range.toString());
407+
expect(texts()).toEqual(['moved']);
408+
409+
// * the move collapses the live range, and the row's own observer sees nothing of it
410+
const list = html('');
411+
list.append(row);
412+
expect(texts()).toEqual(['']);
413+
414+
reapplyTextHighlights(list);
415+
expect(texts()).toEqual(['moved']);
416+
highlight.dispose();
417+
});
418+
398419
it('collects a highlight whose container has left the document', async() => {
399420
const element = html('gone soon');
400421
highlightText({container: element, match: {type: 'search', query: 'gone'}});

0 commit comments

Comments
 (0)