match desktop search and highlight results (#7445)

This commit is contained in:
Elias Nahum
2023-07-18 12:17:19 -04:00
committed by GitHub
parent a576f7c97e
commit 3effecd6a4
10 changed files with 165 additions and 22 deletions

View File

@@ -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));

View File

@@ -27,8 +27,8 @@ export interface ClientPostsMix {
addReaction: (userId: string, postId: string, emojiName: string) => Promise<Reaction>;
removeReaction: (userId: string, postId: string, emojiName: string) => Promise<any>;
getReactionsForPost: (postId: string) => Promise<any>;
searchPostsWithParams: (teamId: string, params: PostSearchParams) => Promise<any>;
searchPosts: (teamId: string, terms: string, isOrSearch: boolean) => Promise<PostResponse>;
searchPostsWithParams: (teamId: string, params: PostSearchParams) => Promise<SearchPostResponse>;
searchPosts: (teamId: string, terms: string, isOrSearch: boolean) => Promise<SearchPostResponse>;
doPostAction: (postId: string, actionId: string, selectedOption?: string) => Promise<any>;
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise<any>;
acknowledgePost: (postId: string, userId: string) => Promise<PostAcknowledgement>;

View File

@@ -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);
}
}

View File

@@ -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 (
<View style={styles.container}>
<ChannelInfo
@@ -47,6 +49,7 @@ function PostWithChannelInfo({appsEnabled, customEmojiNames, isCRTEnabled, post,
post={post}
location={location}
highlightPinnedOrSaved={!skipSavedPostsHighlight}
searchPatterns={searchPatterns}
skipPinnedHeader={true}
skipSavedHeader={skipSavedPostsHighlight}
shouldRenderReplyButton={false}

View File

@@ -8,11 +8,13 @@ import NoResultsWithTerm from '@components/no_results_with_term';
import DateSeparator from '@components/post_list/date_separator';
import PostWithChannelInfo from '@components/post_with_channel_info';
import {Screens} from '@constants';
import {convertSearchTermToRegex, parseSearchTerms} from '@utils/markdown';
import {getDateForDateLine, selectOrderedPosts} from '@utils/post_list';
import {TabTypes} from '@utils/search';
import type {PostListItem, PostListOtherItem} from '@typings/components/post_list';
import type PostModel from '@typings/database/models/servers/post';
import type {SearchPattern} from '@typings/global/markdown';
type Props = {
appsEnabled: boolean;
@@ -20,6 +22,7 @@ type Props = {
currentTimezone: string;
isTimezoneEnabled: boolean;
posts: PostModel[];
matches?: SearchMatches;
paddingTop: StyleProp<ViewStyle>;
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 (
<PostWithChannelInfo
appsEnabled={appsEnabled}
customEmojiNames={customEmojiNames}
key={item.value.currentPost.id}
key={key}
location={Screens.SEARCH}
post={item.value.currentPost}
searchPatterns={searchPatterns}
testID='search_results.post_list'
/>
);
}
default:
return null;
}
}, [appsEnabled, customEmojiNames]);
}, [appsEnabled, customEmojiNames, searchValue, matches]);
const noResults = useMemo(() => (
<NoResultsWithTerm

View File

@@ -46,6 +46,7 @@ type Props = {
isTimezoneEnabled: boolean;
loading: boolean;
posts: PostModel[];
matches?: SearchMatches;
publicLinkEnabled: boolean;
scrollPaddingTop: number;
searchValue: string;
@@ -62,6 +63,7 @@ const Results = ({
isTimezoneEnabled,
loading,
posts,
matches,
publicLinkEnabled,
scrollPaddingTop,
searchValue,
@@ -105,6 +107,7 @@ const Results = ({
customEmojiNames={customEmojiNames}
isTimezoneEnabled={isTimezoneEnabled}
posts={posts}
matches={matches}
paddingTop={paddingTop}
searchValue={searchValue}
/>

View File

@@ -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<PostModel[]>(emptyPosts);
const [matches, setMatches] = useState<SearchMatches|undefined>();
const [fileInfos, setFileInfos] = useState<FileInfo[]>(emptyFileResults);
const [fileChannelIds, setFileChannelIds] = useState<string[]>([]);
@@ -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) => {
<Results
loading={resultsLoading}
selectedTab={selectedTab}
searchValue={lastSearchedValue}
searchValue={lastSearchedValue.replace(/[\u201C\u201D]/g, '"')}
posts={posts}
matches={matches}
fileInfos={fileInfos}
scrollPaddingTop={lockValue.value}
fileChannelIds={fileChannelIds}

View File

@@ -7,7 +7,7 @@ import {getViewPortWidth} from '@utils/images';
import {changeOpacity, concatStyles, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {MarkdownTextStyles} from '@typings/global/markdown';
import type {MarkdownTextStyles, SearchPattern} from '@typings/global/markdown';
type LanguageObject = {
[key: string]: {
@@ -17,6 +17,13 @@ type LanguageObject = {
};
}
// pattern to detect the existence of a Chinese, Japanese, or Korean character in a string
// http://stackoverflow.com/questions/15033196/using-javascript-to-check-whether-a-string-contains-japanese-characters-includi
const cjkPattern = /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf\uac00-\ud7a3]/;
const puncStart = /^[^\p{L}\d\s#]+/u;
const puncEnd = /[^\p{L}\d\s]+$/u;
export function getCodeFont() {
return Platform.OS === 'ios' ? 'Menlo' : 'monospace';
}
@@ -263,3 +270,89 @@ export const computeTextStyle = (textStyles: MarkdownTextStyles, baseStyle: Styl
const contextStyles: TextStyle[] = context.map((type) => 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,
};
}

10
types/api/posts.d.ts vendored
View File

@@ -94,6 +94,12 @@ type PostResponse = {
prev_post_id?: string;
};
type SearchMatches = {[key: $ID<Post>]: 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 = {

View File

@@ -13,4 +13,5 @@ type PostSearchRequest = {
error?: unknown;
order?: string[];
posts?: Post[];
matches?: SearchMatches;
}