feat(search): suggestions sans doublons + suggestions locales a la frappe
This commit is contained in:
@@ -11,7 +11,7 @@ import { Subject, of } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, switchMap, catchError, finalize } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { SuggestService } from '../../app/search/suggest.service';
|
||||
import { mergeGroups, highlightParts } from '../../app/search/suggest.util';
|
||||
import { mergeGroups, highlightParts, dedupeStrings, buildLocalQuerySuggestions, normSuggest } from '../../app/search/suggest.util';
|
||||
// No overlays; we render fixed-position modals with high z-index
|
||||
|
||||
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
|
||||
@@ -87,16 +87,32 @@ export class SearchBoxComponent {
|
||||
private suggestShownFor: string | null = null;
|
||||
readonly suggestFlat = computed((): SuggestFlatItem[] => {
|
||||
const q = this.query();
|
||||
// Tier 1 — recherches récentes (dédupliquées, une seule fois chacune).
|
||||
const recents = (this.recentSuggest() || []).slice(0, 5).map((t) => ({ text: t, provider: null as string | null }));
|
||||
const seen = new Set(recents.map((r) => r.text.toLowerCase()));
|
||||
const seen = new Set(recents.map((r) => normSuggest(r.text)));
|
||||
// Tier 2 — suggestions providers, dédupliquées entre providers (mergeGroups)
|
||||
// et contre l'historique.
|
||||
const merged = mergeGroups(this.suggestGroups() || {}).filter((m) => {
|
||||
const k = m.text.toLowerCase();
|
||||
const k = normSuggest(m.text);
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
const out: SuggestFlatItem[] = [...recents, ...merged];
|
||||
// Tier 3 — suggestions locales générées à la frappe (sans réseau) : la
|
||||
// frappe propose toujours quelque chose, même sans provider suggest
|
||||
// (tw/pt/od/ru), hors-ligne ou en cas de réponse vide.
|
||||
if (out.length < 10) {
|
||||
for (const t of buildLocalQuerySuggestions(q, 10 - out.length)) {
|
||||
const k = normSuggest(t);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push({ text: t, provider: null });
|
||||
if (out.length >= 15) break;
|
||||
}
|
||||
}
|
||||
void q;
|
||||
return [...recents, ...merged].slice(0, 15);
|
||||
return out.slice(0, 15);
|
||||
});
|
||||
|
||||
// Element to restore focus to when a modal closes (a11y)
|
||||
@@ -247,21 +263,28 @@ export class SearchBoxComponent {
|
||||
/** Filter cached history synchronously; refresh the cache at most every 10s. */
|
||||
private refreshRecentSuggest(value: string) {
|
||||
const q = String(value || '').trim().toLowerCase();
|
||||
if (q.length >= 2 && this.allRecentCache.length > 0) {
|
||||
this.recentSuggest.set(this.allRecentCache.filter((t) => t.toLowerCase().includes(q)).slice(0, 5));
|
||||
if (this.allRecentCache.length > 0) {
|
||||
// Historique dédupliqué : la même recherche ne s'affiche qu'une fois.
|
||||
this.recentSuggest.set(
|
||||
q.length >= 2
|
||||
? this.allRecentCache.filter((t) => t.toLowerCase().includes(q)).slice(0, 5)
|
||||
: this.allRecentCache.slice(0, 5),
|
||||
);
|
||||
} else if (q.length < 2) {
|
||||
this.recentSuggest.set([]);
|
||||
}
|
||||
const now = Date.now();
|
||||
if (q.length < 2 || now - this.lastRecentFetch < 10_000) return;
|
||||
if (now - this.lastRecentFetch < 10_000) return;
|
||||
this.lastRecentFetch = now;
|
||||
try {
|
||||
this.history.getSearchHistory(15).subscribe({
|
||||
next: (items) => {
|
||||
this.allRecentCache = (items || []).map((i) => String(i?.query || '').trim()).filter(Boolean);
|
||||
this.allRecentCache = dedupeStrings((items || []).map((i) => String(i?.query || '').trim()).filter(Boolean));
|
||||
const cur = String(this.query() || '').trim().toLowerCase();
|
||||
if (cur.length >= 2) {
|
||||
this.recentSuggest.set(this.allRecentCache.filter((t) => t.toLowerCase().includes(cur)).slice(0, 5));
|
||||
} else {
|
||||
this.recentSuggest.set(this.allRecentCache.slice(0, 5));
|
||||
}
|
||||
},
|
||||
error: () => {},
|
||||
@@ -300,7 +323,9 @@ export class SearchBoxComponent {
|
||||
}
|
||||
|
||||
onSuggestInputFocus() {
|
||||
if (!this.atOpen() && String(this.query() || '').trim().length >= 2 && this.suggestFlat().length > 0) {
|
||||
// Même avec moins de 2 caractères, on propose l'historique récent.
|
||||
this.refreshRecentSuggest(this.query());
|
||||
if (!this.atOpen() && this.suggestFlat().length > 0) {
|
||||
this.suggestionsOpen.set(true);
|
||||
}
|
||||
}
|
||||
@@ -475,8 +500,23 @@ export class SearchBoxComponent {
|
||||
this.saveFocus();
|
||||
this.quickOpen.set(true);
|
||||
this.telemetry.track('quick_menu_open');
|
||||
// Load last 15 searches
|
||||
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
|
||||
// Load last 15 searches (deduped: same query shown once)
|
||||
try {
|
||||
this.history.getSearchHistory(15).subscribe({
|
||||
next: (items) => {
|
||||
const seen = new Set<string>();
|
||||
const clean: SearchHistoryItem[] = [];
|
||||
for (const it of items || []) {
|
||||
const k = normSuggest(it?.query || '');
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
clean.push(it);
|
||||
}
|
||||
this.recentSearches.set(clean);
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
closeQuickMenu() {
|
||||
if (!this.quickOpen()) return;
|
||||
|
||||
Reference in New Issue
Block a user