feat(app): keep search history per conversation and allow in: in DMs (#1886)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:39:34 +02:00
1 parent 406340fa65
commit 202856c0f7
4 files changed
+68 -31

No files matched your search

@@ -254,7 +254,7 @@ export const ChannelSearchResults = observer(
(page: number, scopeOverride?: MessageSearchScope | null): MessageSearchParams => { (page: number, scopeOverride?: MessageSearchScope | null): MessageSearchParams => {
const params: MessageSearchParams = { const params: MessageSearchParams = {
...parseSearchQueryWithSegments(currentSearchQuery.current, currentSearchSegments.current, { ...parseSearchQueryWithSegments(currentSearchQuery.current, currentSearchSegments.current, {
channelId: channelId.current, historyKey: contextId ?? undefined,
guildId: channelGuildId.current, guildId: channelGuildId.current,
}), }),
page, page,
@@ -265,7 +265,7 @@ export const ChannelSearchResults = observer(
applyMatureContentToParamsIfNeeded(params, channelId.current); applyMatureContentToParamsIfNeeded(params, channelId.current);
return params; return params;
}, },
[applyScopeToParams], [applyScopeToParams, contextId],
); );
const performSearch = useCallback( const performSearch = useCallback(
async (page = 1, sortOverride?: ChannelSearchSortMode, scopeOverride?: MessageSearchScope | null) => { async (page = 1, sortOverride?: ChannelSearchSortMode, scopeOverride?: MessageSearchScope | null) => {
@@ -13,6 +13,7 @@ import {
transitionSearchMachineSnapshot, transitionSearchMachineSnapshot,
} from '@app/features/channel/components/SearchStateMachine'; } from '@app/features/channel/components/SearchStateMachine';
import type {Channel} from '@app/features/channel/models/Channel'; import type {Channel} from '@app/features/channel/models/Channel';
import {getChannelSearchContextId} from '@app/features/channel/state/ChannelSearch';
import Channels from '@app/features/channel/state/Channels'; import Channels from '@app/features/channel/state/Channels';
import {getChannelSearchIndexingPollInterval} from '@app/features/channel/utils/ChannelSearchPolling'; import {getChannelSearchIndexingPollInterval} from '@app/features/channel/utils/ChannelSearchPolling';
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree'; import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
@@ -269,7 +270,7 @@ export const useChannelSearch = ({
currentSegmentsRef.current = segments; currentSegmentsRef.current = segments;
currentFiltersRef.current = null; currentFiltersRef.current = null;
const params = parseSearchQueryWithSegments(query, segments, { const params = parseSearchQueryWithSegments(query, segments, {
channelId: channel.id, historyKey: getChannelSearchContextId(channel) ?? undefined,
guildId: channel.guildId ?? null, guildId: channel.guildId ?? null,
}); });
await executeSearch(params, page, overrides); await executeSearch(params, page, overrides);
@@ -293,7 +294,7 @@ export const useChannelSearch = ({
executeSearch(params, page); executeSearch(params, page);
} else if (currentQueryRef.current) { } else if (currentQueryRef.current) {
const params = parseSearchQueryWithSegments(currentQueryRef.current, currentSegmentsRef.current, { const params = parseSearchQueryWithSegments(currentQueryRef.current, currentSegmentsRef.current, {
channelId: channel.id, historyKey: getChannelSearchContextId(channel) ?? undefined,
guildId: channel.guildId ?? null, guildId: channel.guildId ?? null,
}); });
executeSearch(params, page); executeSearch(params, page);
@@ -364,7 +365,7 @@ export const useChannelSearch = ({
params = filtersToParams(currentFiltersRef.current); params = filtersToParams(currentFiltersRef.current);
} else { } else {
params = parseSearchQueryWithSegments(currentQueryRef.current, currentSegmentsRef.current, { params = parseSearchQueryWithSegments(currentQueryRef.current, currentSegmentsRef.current, {
channelId: channel.id, historyKey: getChannelSearchContextId(channel) ?? undefined,
guildId: channel.guildId ?? null, guildId: channel.guildId ?? null,
}); });
} }
@@ -4,6 +4,9 @@ import {makePersistent} from '@app/features/platform/utils/MobXPersistence';
import type {SearchHints} from '@app/features/search/utils/SearchQueryParser'; import type {SearchHints} from '@app/features/search/utils/SearchQueryParser';
import {action, makeAutoObservable} from 'mobx'; import {action, makeAutoObservable} from 'mobx';
export const SEARCH_HISTORY_LIMIT = 10;
export const SEARCH_HISTORY_DISPLAY_LIMIT = 5;
export interface SearchHistoryEntry { export interface SearchHistoryEntry {
query: string; query: string;
hints?: SearchHints; hints?: SearchHints;
@@ -44,17 +47,42 @@ class SearchHistory {
} }
const entries = this.entriesByChannel[channelId]; const entries = this.entriesByChannel[channelId];
const ts = Date.now(); const ts = Date.now();
const existingIdx = entries.findIndex((e) => e.query === q);
const entry: SearchHistoryEntry = {query: q, hints, ts}; const entry: SearchHistoryEntry = {query: q, hints, ts};
const existingIdx = entries.findIndex((e) => e.query === q);
if (existingIdx !== -1) { if (existingIdx !== -1) {
entries.splice(existingIdx, 1); entries.splice(existingIdx, 1);
} }
entries.unshift(entry); entries.unshift(entry);
if (entries.length > 10) { if (entries.length > SEARCH_HISTORY_LIMIT) {
this.entriesByChannel[channelId] = entries.slice(0, 10); this.entriesByChannel[channelId] = entries.slice(0, SEARCH_HISTORY_LIMIT);
} }
} }
@action
adoptLegacyEntries(targetKey: string | undefined, legacyKeys: ReadonlyArray<string>): void {
if (!targetKey) return;
if (this.getEntries(targetKey).length > 0) return;
const merged: Array<SearchHistoryEntry> = [];
for (const legacyKey of legacyKeys) {
if (legacyKey === targetKey) continue;
const legacyEntries = this.entriesByChannel[legacyKey];
if (legacyEntries == null) continue;
merged.push(...legacyEntries);
delete this.entriesByChannel[legacyKey];
}
if (merged.length === 0) return;
merged.sort((left, right) => right.ts - left.ts);
const seen = new Set<string>();
const adopted: Array<SearchHistoryEntry> = [];
for (const entry of merged) {
if (seen.has(entry.query)) continue;
seen.add(entry.query);
adopted.push(entry);
if (adopted.length >= SEARCH_HISTORY_LIMIT) break;
}
this.entriesByChannel[targetKey] = adopted;
}
@action @action
clear(channelId?: string): void { clear(channelId?: string): void {
if (!channelId) return; if (!channelId) return;
@@ -5,7 +5,7 @@ import {Channel} from '@app/features/channel/models/Channel';
import {Message} from '@app/features/messaging/models/MessagingMessage'; import {Message} from '@app/features/messaging/models/MessagingMessage';
import {http} from '@app/features/platform/transport/RestTransport'; import {http} from '@app/features/platform/transport/RestTransport';
import SearchHistory from '@app/features/search/state/SearchHistory'; import SearchHistory from '@app/features/search/state/SearchHistory';
import {parseQuery} from '@app/features/search/utils/SearchQueryParser'; import {addUniqueSearchParam as addUnique, parseQuery} from '@app/features/search/utils/SearchQueryParser';
import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager'; import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager';
import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
@@ -245,7 +245,7 @@ export interface SearchContext {
} }
interface SearchQueryExecutionContext { interface SearchQueryExecutionContext {
channelId?: string; historyKey?: string;
guildId?: string | null; guildId?: string | null;
} }
@@ -299,20 +299,11 @@ export interface SearchFilterOption {
values?: Array<SearchValueOption>; values?: Array<SearchValueOption>;
requiresValue?: boolean; requiresValue?: boolean;
requiresGuild?: boolean; requiresGuild?: boolean;
dmEligible?: boolean;
} }
export function getSearchFilterOptions(i18n: I18n): Array<SearchFilterOption> { export function getSearchFilterOptions(i18n: I18n): Array<SearchFilterOption> {
const hasContentValues: Array<SearchValueOption> = [ const hasContentValues: Array<SearchValueOption> = [
{
value: 'link',
label: i18n._(VALUE_LINK_LABEL_DESCRIPTOR),
description: i18n._(VALUE_LINK_DESCRIPTION_DESCRIPTOR),
},
{
value: 'embed',
label: i18n._(VALUE_EMBED_LABEL_DESCRIPTOR),
description: i18n._(VALUE_EMBED_DESCRIPTION_DESCRIPTOR),
},
{ {
value: 'image', value: 'image',
label: i18n._(VALUE_IMAGE_LABEL_DESCRIPTOR), label: i18n._(VALUE_IMAGE_LABEL_DESCRIPTOR),
@@ -324,9 +315,9 @@ export function getSearchFilterOptions(i18n: I18n): Array<SearchFilterOption> {
description: i18n._(VALUE_VIDEO_DESCRIPTION_DESCRIPTOR), description: i18n._(VALUE_VIDEO_DESCRIPTION_DESCRIPTOR),
}, },
{ {
value: 'sound', value: 'link',
label: i18n._(VALUE_SOUND_LABEL_DESCRIPTOR), label: i18n._(VALUE_LINK_LABEL_DESCRIPTOR),
description: i18n._(VALUE_SOUND_DESCRIPTION_DESCRIPTOR), description: i18n._(VALUE_LINK_DESCRIPTION_DESCRIPTOR),
}, },
{ {
value: 'file', value: 'file',
@@ -334,15 +325,25 @@ export function getSearchFilterOptions(i18n: I18n): Array<SearchFilterOption> {
description: i18n._(VALUE_FILE_DESCRIPTION_DESCRIPTOR), description: i18n._(VALUE_FILE_DESCRIPTION_DESCRIPTOR),
}, },
{ {
value: 'sticker', value: 'embed',
label: i18n._(VALUE_STICKER_LABEL_DESCRIPTOR), label: i18n._(VALUE_EMBED_LABEL_DESCRIPTOR),
description: i18n._(VALUE_STICKER_DESCRIPTION_DESCRIPTOR), description: i18n._(VALUE_EMBED_DESCRIPTION_DESCRIPTOR),
},
{
value: 'sound',
label: i18n._(VALUE_SOUND_LABEL_DESCRIPTOR),
description: i18n._(VALUE_SOUND_DESCRIPTION_DESCRIPTOR),
}, },
{ {
value: 'poll', value: 'poll',
label: i18n._(VALUE_POLL_LABEL_DESCRIPTOR), label: i18n._(VALUE_POLL_LABEL_DESCRIPTOR),
description: i18n._(VALUE_POLL_DESCRIPTION_DESCRIPTOR), description: i18n._(VALUE_POLL_DESCRIPTION_DESCRIPTOR),
}, },
{
value: 'sticker',
label: i18n._(VALUE_STICKER_LABEL_DESCRIPTOR),
description: i18n._(VALUE_STICKER_DESCRIPTION_DESCRIPTOR),
},
{ {
value: 'forward', value: 'forward',
label: i18n._(VALUE_FORWARD_LABEL_DESCRIPTOR), label: i18n._(VALUE_FORWARD_LABEL_DESCRIPTOR),
@@ -427,6 +428,7 @@ export function getSearchFilterOptions(i18n: I18n): Array<SearchFilterOption> {
syntax: 'in:', syntax: 'in:',
requiresValue: true, requiresValue: true,
requiresGuild: true, requiresGuild: true,
dmEligible: true,
}, },
{ {
key: '-in', key: '-in',
@@ -435,6 +437,7 @@ export function getSearchFilterOptions(i18n: I18n): Array<SearchFilterOption> {
syntax: '-in:', syntax: '-in:',
requiresValue: true, requiresValue: true,
requiresGuild: true, requiresGuild: true,
dmEligible: true,
}, },
{ {
key: 'pinned', key: 'pinned',
@@ -603,10 +606,6 @@ function buildHintsFromSegments(segments: Array<SearchSegment>) {
}; };
} }
function addUnique<T>(values: Array<T> | undefined, value: T): Array<T> {
return values?.includes(value) ? values : [...(values ?? []), value];
}
function segmentMatchesQuery(query: string, segment: SearchSegment): boolean { function segmentMatchesQuery(query: string, segment: SearchSegment): boolean {
return ( return (
segment.start >= 0 && segment.end <= query.length && query.slice(segment.start, segment.end) === segment.displayText segment.start >= 0 && segment.end <= query.length && query.slice(segment.start, segment.end) === segment.displayText
@@ -654,12 +653,21 @@ function applySegmentsToParams(
return params; return params;
} }
export function hasSearchableParams(params: MessageSearchParams): boolean {
for (const value of Object.values(params)) {
if (value !== undefined) {
return true;
}
}
return false;
}
export function parseSearchQueryWithSegments( export function parseSearchQueryWithSegments(
query: string, query: string,
segments: Array<SearchSegment>, segments: Array<SearchSegment>,
ctx?: SearchQueryExecutionContext, ctx?: SearchQueryExecutionContext,
): MessageSearchParams { ): MessageSearchParams {
const entry = SearchHistory.recent(ctx?.channelId).find((historyEntry) => historyEntry.query === query); const entry = SearchHistory.recent(ctx?.historyKey).find((historyEntry) => historyEntry.query === query);
const segmentHints = buildHintsFromSegments(segments); const segmentHints = buildHintsFromSegments(segments);
const segmentUserValues = new Set<string>(); const segmentUserValues = new Set<string>();
for (const segment of segments) { for (const segment of segments) {