forked from Ivasoft/mattermost-mobile
match desktop search and highlight results (#7445)
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
10
types/api/posts.d.ts
vendored
@@ -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 = {
|
||||
|
||||
1
types/api/search.d.ts
vendored
1
types/api/search.d.ts
vendored
@@ -13,4 +13,5 @@ type PostSearchRequest = {
|
||||
error?: unknown;
|
||||
order?: string[];
|
||||
posts?: Post[];
|
||||
matches?: SearchMatches;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user