From 3effecd6a40dbe1646f79b01da66cc22fb111da7 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Tue, 18 Jul 2023 12:17:19 -0400 Subject: [PATCH] match desktop search and highlight results (#7445) --- app/actions/remote/search.ts | 13 ++- app/client/rest/posts.ts | 4 +- app/components/markdown/transform.ts | 23 ++--- .../post_with_channel_info.tsx | 5 +- .../home/search/results/post_results.tsx | 23 ++++- app/screens/home/search/results/results.tsx | 3 + app/screens/home/search/search.tsx | 10 +- app/utils/markdown/index.ts | 95 ++++++++++++++++++- types/api/posts.d.ts | 10 ++ types/api/search.d.ts | 1 + 10 files changed, 165 insertions(+), 22 deletions(-) diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index e870b3b28a..af41b7e09c 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -5,10 +5,13 @@ import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; import {prepareMissingChannelsForAllTeams} from '@queries/servers/channel'; +import {getConfigValue} from '@queries/servers/system'; import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; import {getFullErrorMessage} from '@utils/errors'; +import {getUtcOffsetForTimeZone} from '@utils/helpers'; import {logDebug} from '@utils/log'; +import {getUserTimezone} from '@utils/user'; import {fetchPostAuthors, fetchMissingChannelsFromPosts} from './post'; import {forceLogoutIfNecessary} from './session'; @@ -51,9 +54,16 @@ export const searchPosts = async (serverUrl: string, teamId: string, params: Pos try { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const client = NetworkManager.getClient(serverUrl); + const viewArchivedChannels = await getConfigValue(database, 'ExperimentalViewArchivedChannels'); + const user = await getCurrentUser(database); + const timezoneOffset = getUtcOffsetForTimeZone(getUserTimezone(user)) * 60; let postsArray: Post[] = []; - const data = await client.searchPosts(teamId, params.terms, params.is_or_search); + const data = await client.searchPostsWithParams(teamId, { + ...params, + include_deleted_channels: Boolean(viewArchivedChannels), + time_zone_offset: timezoneOffset, + }); const posts = data.posts || {}; const order = data.order || []; @@ -108,6 +118,7 @@ export const searchPosts = async (serverUrl: string, teamId: string, params: Pos return { order, posts: postsArray, + matches: data.matches, }; } catch (error) { logDebug('error on searchPosts', getFullErrorMessage(error)); diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index ba0be13e16..2347d200e6 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -27,8 +27,8 @@ export interface ClientPostsMix { addReaction: (userId: string, postId: string, emojiName: string) => Promise; removeReaction: (userId: string, postId: string, emojiName: string) => Promise; getReactionsForPost: (postId: string) => Promise; - searchPostsWithParams: (teamId: string, params: PostSearchParams) => Promise; - searchPosts: (teamId: string, terms: string, isOrSearch: boolean) => Promise; + searchPostsWithParams: (teamId: string, params: PostSearchParams) => Promise; + searchPosts: (teamId: string, terms: string, isOrSearch: boolean) => Promise; doPostAction: (postId: string, actionId: string, selectedOption?: string) => Promise; doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise; acknowledgePost: (postId: string, userId: string) => Promise; diff --git a/app/components/markdown/transform.ts b/app/components/markdown/transform.ts index e23cd567b5..bf32d0c578 100644 --- a/app/components/markdown/transform.ts +++ b/app/components/markdown/transform.ts @@ -184,21 +184,22 @@ export function highlightSearchPatterns(ast: Node, searchPatterns: SearchPattern } const node = e.node; + if ((node.type === 'text' || node.type === 'code') && node.literal) { + for (const patternPattern of searchPatterns) { + const {index, length} = getFirstMatch(node.literal, [patternPattern.pattern]); - if (node.type === 'text' && node.literal) { - const {index, length} = getFirstMatch(node.literal, searchPatterns.map((pattern) => pattern.pattern)); + // TODO we might need special handling here for if the search term is part of a hashtag - // TODO we might need special handling here for if the search term is part of a hashtag + if (index === -1) { + continue; + } - if (index === -1) { - continue; + const matchNode = highlightTextNode(node, index, index + length, 'search_highlight'); + + // Resume processing on the next node after the match node which may include any remaining text + // that was part of this one + walker.resumeAt(matchNode, false); } - - const matchNode = highlightTextNode(node, index, index + length, 'search_highlight'); - - // Resume processing on the next node after the match node which may include any remaining text - // that was part of this one - walker.resumeAt(matchNode, false); } } diff --git a/app/components/post_with_channel_info/post_with_channel_info.tsx b/app/components/post_with_channel_info/post_with_channel_info.tsx index d704d85def..c4258e7456 100644 --- a/app/components/post_with_channel_info/post_with_channel_info.tsx +++ b/app/components/post_with_channel_info/post_with_channel_info.tsx @@ -9,6 +9,7 @@ import Post from '@components/post_list/post'; import ChannelInfo from './channel_info'; import type PostModel from '@typings/database/models/servers/post'; +import type {SearchPattern} from '@typings/global/markdown'; type Props = { appsEnabled: boolean; @@ -17,6 +18,7 @@ type Props = { post: PostModel; location: string; testID?: string; + searchPatterns?: SearchPattern[]; skipSavedPostsHighlight?: boolean; isSaved?: boolean; } @@ -32,7 +34,7 @@ const styles = StyleSheet.create({ }, }); -function PostWithChannelInfo({appsEnabled, customEmojiNames, isCRTEnabled, post, location, testID, skipSavedPostsHighlight = false, isSaved}: Props) { +function PostWithChannelInfo({appsEnabled, customEmojiNames, isCRTEnabled, post, location, testID, searchPatterns, skipSavedPostsHighlight = false, isSaved}: Props) { return ( ; searchValue: string; } @@ -30,6 +33,7 @@ const PostResults = ({ customEmojiNames, isTimezoneEnabled, posts, + matches, paddingTop, searchValue, }: Props) => { @@ -46,21 +50,34 @@ const PostResults = ({ timezone={isTimezoneEnabled ? currentTimezone : null} /> ); - case 'post': + case 'post': { + const key = item.value.currentPost.id; + const hasPhrases = (/"([^"]*)"/).test(searchValue || ''); + let searchPatterns: SearchPattern[] | undefined; + if (matches && !hasPhrases) { + searchPatterns = matches?.[key].map(convertSearchTermToRegex); + } else { + searchPatterns = parseSearchTerms(searchValue).map(convertSearchTermToRegex).sort((a, b) => { + return b.term.length - a.term.length; + }); + } + return ( ); + } default: return null; } - }, [appsEnabled, customEmojiNames]); + }, [appsEnabled, customEmojiNames, searchValue, matches]); const noResults = useMemo(() => ( diff --git a/app/screens/home/search/search.tsx b/app/screens/home/search/search.tsx index 170755f83e..ae03149554 100644 --- a/app/screens/home/search/search.tsx +++ b/app/screens/home/search/search.tsx @@ -69,8 +69,9 @@ const getSearchParams = (terms: string, filterValue?: FileFilter) => { const fileExtensions = filterFileExtensions(filterValue); const extensionTerms = fileExtensions ? ' ' + fileExtensions : ''; return { - terms: terms + extensionTerms, - is_or_search: true, + terms: terms.replace(/[\u201C\u201D]/g, '"') + extensionTerms, + is_or_search: false, + include_deleted_channels: true, }; }; @@ -105,6 +106,7 @@ const SearchScreen = ({teamId, teams}: Props) => { const [resultsLoading, setResultsLoading] = useState(false); const [lastSearchedValue, setLastSearchedValue] = useState(''); const [posts, setPosts] = useState(emptyPosts); + const [matches, setMatches] = useState(); const [fileInfos, setFileInfos] = useState(emptyFileResults); const [fileChannelIds, setFileChannelIds] = useState([]); @@ -192,6 +194,7 @@ const SearchScreen = ({teamId, teams}: Props) => { if (postResults.order) { const postModels = await getPosts(serverUrl, postResults.order, 'asc'); setPosts(postModels.length ? postModels : emptyPosts); + setMatches(postResults.matches); } setFileChannelIds(channels?.length ? channels : emptyChannelIds); handleLoading(false); @@ -399,8 +402,9 @@ const SearchScreen = ({teamId, teams}: Props) => { textStyles[type]).filter((f) => f !== undefined); return contextStyles.length ? concatStyles(baseStyle, contextStyles) : baseStyle; }; + +export function parseSearchTerms(searchTerm: string): string[] { + let terms = []; + + let termString = searchTerm; + + while (termString) { + let captured; + + // check for a quoted string + captured = (/^"([^"]*)"/).exec(termString); + if (captured) { + termString = termString.substring(captured[0].length); + + if (captured[1].length > 0) { + terms.push(captured[1]); + } + continue; + } + + // check for a search flag (and don't add it to terms) + captured = (/^-?(?:in|from|channel|on|before|after): ?\S+/).exec(termString); + if (captured) { + termString = termString.substring(captured[0].length); + continue; + } + + // capture at mentions differently from the server so we can highlight them with the preceeding at sign + captured = (/^@[a-z0-9.-_]+\b/).exec(termString); + if (captured) { + termString = termString.substring(captured[0].length); + + terms.push(captured[0]); + continue; + } + + // capture any plain text up until the next quote or search flag + captured = (/^.+?(?=(?:\b|\B-)(?:in:|from:|channel:|on:|before:|after:)|"|$)/).exec(termString); + if (captured) { + termString = termString.substring(captured[0].length); + + // break the text up into words based on how the server splits them in SqlPostStore.SearchPosts and then discard empty terms + terms.push( + ...captured[0].split(/[ <>+()~@]/).filter((term) => Boolean(term)), + ); + continue; + } + + // we should never reach this point since at least one of the regexes should match something in the remaining text + throw new Error( + 'Infinite loop in search term parsing: "' + termString + '"', + ); + } + + // remove punctuation from each term + terms = terms.map((term) => { + term.replace(puncStart, ''); + if (term.charAt(term.length - 1) !== '*') { + term.replace(puncEnd, ''); + } + return term; + }); + + return terms; +} + +export function convertSearchTermToRegex(term: string): SearchPattern { + let pattern; + + if (cjkPattern.test(term)) { + // term contains Chinese, Japanese, or Korean characters so don't mark word boundaries + pattern = '()(' + escapeRegex(term.replace(/\*/g, '')) + ')'; + } else if ((/[^\s][*]$/).test(term)) { + pattern = '\\b()(' + escapeRegex(term.substring(0, term.length - 1)) + ')'; + } else if (term.startsWith('@') || term.startsWith('#')) { + // needs special handling of the first boundary because a word boundary doesn't work before a symbol + pattern = '(\\W|^)(' + escapeRegex(term) + ')\\b'; + } else { + pattern = '\\b()(' + escapeRegex(term) + ')\\b'; + } + + return { + pattern: new RegExp(pattern, 'gi'), + term, + }; +} diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index f8a4ac7c53..a9b3a3c603 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -94,6 +94,12 @@ type PostResponse = { prev_post_id?: string; }; +type SearchMatches = {[key: $ID]: string[]}; + +type SearchPostResponse = PostResponse & { + matches?: SearchMatches; +} + type ProcessedPosts = { order: string[]; posts: Post[]; @@ -129,6 +135,10 @@ type MessageAttachmentField = { type PostSearchParams = { terms: string; is_or_search: boolean; + include_deleted_channels?: boolean; + time_zone_offset?: number; + page?: number; + per_page?: number; }; type FetchPaginatedThreadOptions = { diff --git a/types/api/search.d.ts b/types/api/search.d.ts index c92a2a2b25..36e3d26845 100644 --- a/types/api/search.d.ts +++ b/types/api/search.d.ts @@ -13,4 +13,5 @@ type PostSearchRequest = { error?: unknown; order?: string[]; posts?: Post[]; + matches?: SearchMatches; }