diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 177698f09..6b4de3194 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -131,3 +131,27 @@ export const removePost = async (serverUrl: string, post: PostModel) => { export const selectAttachmentMenuAction = (serverUrl: string, postId: string, actionId: string, selectedOption: string) => { return postActionWithCookie(serverUrl, postId, actionId, '', selectedOption); }; + +export const processPostsFetched = async (serverUrl: string, actionType: string, data: {order: string[]; posts: Post[]; prev_post_id?: string}, fetchOnly = false) => { + const order = data.order; + const posts = Object.values(data.posts) as Post[]; + const previousPostId = data.prev_post_id; + + if (!fetchOnly) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (operator) { + await operator.handlePosts({ + actionType, + order, + posts, + previousPostId, + }); + } + } + + return { + posts, + order, + previousPostId, + }; +}; diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index 3c15db432..f5b50525d 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -1,11 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +// +import Model from '@nozbe/watermelondb/Model'; +import {processPostsFetched} from '@actions/local/post'; import {ActionType, General} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {getNeededAtMentionedUsernames} from '@helpers/api/user'; import NetworkManager from '@init/network_manager'; +import {prepareMissingChannelsForAllTeams, queryAllMyChannelIds} from '@queries/servers/channel'; import {queryRecentPostsInChannel} from '@queries/servers/post'; import {queryCurrentUserId, queryCurrentChannelId} from '@queries/servers/system'; import {queryAllUsers} from '@queries/servers/user'; @@ -235,26 +239,56 @@ export const postActionWithCookie = async (serverUrl: string, postId: string, ac } }; -const processPostsFetched = (serverUrl: string, actionType: string, data: {order: string[]; posts: Post[]; prev_post_id?: string}, fetchOnly = false) => { - const order = data.order; - const posts = Object.values(data.posts) as Post[]; - const previousPostId = data.prev_post_id; +export async function getMissingChannelsFromPosts(serverUrl: string, posts: Post[], fetchOnly = false) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } - if (!fetchOnly) { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (operator) { - operator.handlePosts({ - actionType, - order, - posts, - previousPostId, - }); + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + const channelIds = await queryAllMyChannelIds(operator.database); + const channelPromises: Array> = []; + const userPromises: Array> = []; + + posts.forEach((post) => { + const id = post.channel_id; + + if (channelIds.indexOf(id) === -1) { + channelPromises.push(client.getChannel(id)); + userPromises.push(client.getMyChannelMember(id)); + } + }); + + const channels = await Promise.all(channelPromises); + const channelMemberships = await Promise.all(userPromises); + + if (!fetchOnly && channels.length && channelMemberships.length) { + const modelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array>; + if (modelPromises && modelPromises.length) { + const channelModelsArray = await Promise.all(modelPromises); + if (channelModelsArray.length) { + const models = channelModelsArray.flatMap((mdls) => { + if (!mdls || mdls.length) { + return []; + } + return mdls; + }); + + if (models) { + operator.batchRecords(models); + } + } } } return { - posts, - order, - previousPostId, + channels, + channelMemberships, }; -}; +} diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts new file mode 100644 index 000000000..f0033ad51 --- /dev/null +++ b/app/actions/remote/search.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Model from '@nozbe/watermelondb/Model'; + +import {processPostsFetched} from '@actions/local/post'; +import {prepareMissingChannelsForAllTeams} from '@app/queries/servers/channel'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@init/network_manager'; +import {queryCurrentUser} from '@queries/servers/user'; + +import {fetchPostAuthors, getMissingChannelsFromPosts} from './post'; +import {forceLogoutIfNecessary} from './session'; + +import type {Client} from '@client/rest'; + +type PostSearchRequest = { + error?: unknown; + order?: string[]; + posts?: Post[]; +} + +export async function getRecentMentions(serverUrl: string): Promise { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + let posts: Record = {}; + let postsArray: Post[] = []; + let order: string[] = []; + + try { + const currentUser = await queryCurrentUser(operator.database); + if (!currentUser) { + return { + posts: [], + order: [], + }; + } + const terms = currentUser.userMentionKeys.map(({key}) => key).join(' ').trim() + ' '; + const data = await client.searchPosts('', terms, true); + + posts = data.posts || {}; + order = data.order || []; + + const promises: Array> = []; + postsArray = order.map((id) => posts[id]); + + const mentions: IdValue = { + id: SYSTEM_IDENTIFIERS.RECENT_MENTIONS, + value: JSON.stringify(order), + }; + + promises.push(operator.handleSystem({ + systems: [mentions], + prepareRecordsOnly: true, + })); + + if (postsArray.length) { + const {authors} = await fetchPostAuthors(serverUrl, postsArray, true); + const {channels, channelMemberships} = await getMissingChannelsFromPosts(serverUrl, postsArray, true) as {channels: Channel[]; channelMemberships: ChannelMembership[]}; + + if (authors?.length) { + promises.push( + operator.handleUsers({ + users: authors, + prepareRecordsOnly: true, + }), + ); + } + + if (channels?.length && channelMemberships?.length) { + const channelPromises = prepareMissingChannelsForAllTeams(operator, channels, channelMemberships) as Array>; + if (channelPromises && channelPromises.length) { + promises.push(...channelPromises); + } + } + + promises.push( + operator.handlePosts({ + actionType: '', + order: [], + posts: postsArray, + previousPostId: '', + prepareRecordsOnly: true, + }), + ); + } + + const modelArrays = await Promise.all(promises); + const models = modelArrays.flatMap((mdls) => { + if (!mdls || !mdls.length) { + return []; + } + return mdls; + }); + + if (models.length) { + await operator.batchRecords(models); + } + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } + + return { + order, + posts: postsArray, + }; +} + +export const searchPosts = async (serverUrl: string, params: PostSearchParams): Promise => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + let data; + try { + data = await client.searchPosts('', params.terms, params.is_or_search); + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } + + return processPostsFetched(serverUrl, '', data, false); +}; diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index 9376b3d2b..8b173a101 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -25,7 +25,7 @@ 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: any) => 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; @@ -194,13 +194,10 @@ const ClientPosts = (superclass: any) => class extends superclass { ); }; - searchPostsWithParams = async (teamId: string, params: any) => { - this.analytics.trackAPI('api_posts_search', {team_id: teamId}); - - return this.doFetch( - `${this.getTeamRoute(teamId)}/posts/search`, - {method: 'post', body: params}, - ); + searchPostsWithParams = async (teamId: string, params: PostSearchParams) => { + this.analytics.trackAPI('api_posts_search'); + const endpoint = teamId ? `${this.getTeamRoute(teamId)}/posts/search` : `${this.getPostsRoute()}/search`; + return this.doFetch(endpoint, {method: 'post', body: params}); }; searchPosts = async (teamId: string, terms: string, isOrSearch: boolean) => { diff --git a/app/components/post_list/date_separator/index.tsx b/app/components/post_list/date_separator/index.tsx index 5c3212390..73916fde2 100644 --- a/app/components/post_list/date_separator/index.tsx +++ b/app/components/post_list/date_separator/index.tsx @@ -20,11 +20,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { container: { alignItems: 'center', flexDirection: 'row', - height: 28, - marginTop: 8, - }, - dateContainer: { - marginHorizontal: 15, + marginVertical: 8, }, line: { flex: 1, @@ -94,12 +90,10 @@ const DateSeparator = (props: DateSeparatorProps) => { return ( - - - + ); diff --git a/app/components/post_list/post/avatar/index.tsx b/app/components/post_list/post/avatar/index.tsx index 0db35d02a..bab093f92 100644 --- a/app/components/post_list/post/avatar/index.tsx +++ b/app/components/post_list/post/avatar/index.tsx @@ -44,8 +44,7 @@ const style = StyleSheet.create({ }, profilePictureContainer: { marginBottom: 5, - marginLeft: 12, - marginRight: Platform.select({android: 11, ios: 10}), + marginRight: 10, marginTop: 10, }, }); diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index b504c746e..cbb35477c 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -80,7 +80,7 @@ const Header = (props: HeaderProps) => { const pendingPostStyle = isPendingOrFailed ? style.pendingPost : undefined; const isReplyPost = Boolean(post.rootId && !isEphemeral); const showReply = !isReplyPost && (location !== THREAD) && (shouldRenderReplyButton || (!rootPostAuthor && commentCount > 0)); - const displayName = enablePostUsernameOverride ? postUserDisplayName(post, author, teammateNameDisplay, enablePostUsernameOverride) : ''; + const displayName = postUserDisplayName(post, author, teammateNameDisplay, enablePostUsernameOverride); const rootAuthorDisplayName = rootPostAuthor ? displayUsername(rootPostAuthor, currentUser.locale, teammateNameDisplay, true) : undefined; const customStatus = getUserCustomStatus(author); const customStatusExpired = isCustomStatusExpired(author); diff --git a/app/components/system_avatar/index.tsx b/app/components/system_avatar/index.tsx index 6aa4cf2ff..0edbb9206 100644 --- a/app/components/system_avatar/index.tsx +++ b/app/components/system_avatar/index.tsx @@ -14,8 +14,7 @@ type Props = { const styles = StyleSheet.create({ profilePictureContainer: { marginBottom: 5, - marginLeft: 12, - marginRight: 13, + marginRight: 10, marginTop: 10, }, }); diff --git a/app/constants/database.ts b/app/constants/database.ts index c7ad7ef2f..04ff10a3f 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -53,12 +53,13 @@ export const SYSTEM_IDENTIFIERS = { CURRENT_USER_ID: 'currentUserId', DATA_RETENTION_POLICIES: 'dataRetentionPolicies', EXPANDED_LINKS: 'expandedLinks', - RECENT_REACTIONS: 'recentReactions', INTEGRATION_TRIGGER_ID: 'IntegreationTriggerId', LICENSE: 'license', - WEBSOCKET: 'WebSocket', - TEAM_HISTORY: 'teamHistory', RECENT_CUSTOM_STATUS: 'recentCustomStatus', + RECENT_MENTIONS: 'recentMentions', + RECENT_REACTIONS: 'recentReactions', + TEAM_HISTORY: 'teamHistory', + WEBSOCKET: 'WebSocket', }; export const GLOBAL_IDENTIFIERS = { diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 4dffda767..ee6adcc75 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -21,6 +21,7 @@ export const SERVER = 'Server'; export const SETTINGS_SIDEBAR = 'SettingsSidebar'; export const SSO = 'SSO'; export const THREAD = 'Thread'; +export const MENTIONS = 'Mentions'; export default { ABOUT, @@ -43,4 +44,5 @@ export default { SETTINGS_SIDEBAR, SSO, THREAD, + MENTIONS, }; diff --git a/app/database/models/server/index.ts b/app/database/models/server/index.ts index 473b942c4..17ae6a5b8 100644 --- a/app/database/models/server/index.ts +++ b/app/database/models/server/index.ts @@ -8,15 +8,15 @@ export {default as CustomEmojiModel} from './custom_emoji'; export {default as DraftModel} from './draft'; export {default as FileModel} from './file'; export {default as GroupMembershipModel} from './group_membership'; +export {default as GroupModel} from './group'; export {default as GroupsChannelModel} from './groups_channel'; export {default as GroupsTeamModel} from './groups_team'; -export {default as GroupModel} from './group'; -export {default as MyChannelSettingsModel} from './my_channel_settings'; export {default as MyChannelModel} from './my_channel'; +export {default as MyChannelSettingsModel} from './my_channel_settings'; export {default as MyTeamModel} from './my_team'; +export {default as PostModel} from './post'; export {default as PostsInChannelModel} from './posts_in_channel'; export {default as PostsInThreadModel} from './posts_in_thread'; -export {default as PostModel} from './post'; export {default as PreferenceModel} from './preference'; export {default as ReactionModel} from './reaction'; export {default as RoleModel} from './role'; @@ -24,7 +24,7 @@ export {default as SlashCommandModel} from './slash_command'; export {default as SystemModel} from './system'; export {default as TeamChannelHistoryModel} from './team_channel_history'; export {default as TeamMembershipModel} from './team_membership'; -export {default as TeamSearchHistoryModel} from './team_search_history'; export {default as TeamModel} from './team'; +export {default as TeamSearchHistoryModel} from './team_search_history'; export {default as TermsOfServiceModel} from './terms_of_service'; export {default as UserModel} from './user'; diff --git a/app/database/models/server/user.ts b/app/database/models/server/user.ts index f63a4ddfd..d27f3d53f 100644 --- a/app/database/models/server/user.ts +++ b/app/database/models/server/user.ts @@ -14,6 +14,7 @@ import type PostModel from '@typings/database/models/servers/post'; import type PreferenceModel from '@typings/database/models/servers/preference'; import type ReactionModel from '@typings/database/models/servers/reaction'; import type TeamMembershipModel from '@typings/database/models/servers/team_membership'; +import type {UserMentionKey} from '@typings/global/markdown'; const { CHANNEL, @@ -141,4 +142,45 @@ export default class UserModel extends Model { u.status = status; }); }; + + get mentionKeys() { + let keys: UserMentionKey[] = []; + + if (!this.notifyProps) { + return keys; + } + + if (this.notifyProps.mention_keys) { + keys = keys.concat(this.notifyProps.mention_keys.split(',').map((key) => { + return {key}; + })); + } + + if (this.notifyProps.first_name === 'true' && this.firstName) { + keys.push({key: this.firstName, caseSensitive: true}); + } + + if (this.notifyProps.channel === 'true') { + keys.push({key: '@channel'}); + keys.push({key: '@all'}); + keys.push({key: '@here'}); + } + + const usernameKey = '@' + this.username; + if (keys.findIndex((key) => key.key === usernameKey) === -1) { + keys.push({key: usernameKey}); + } + + return keys; + } + + get userMentionKeys() { + const mentionKeys = this.mentionKeys; + + return mentionKeys.filter((m) => ( + m.key !== '@all' && + m.key !== '@channel' && + m.key !== '@here' + )); + } } diff --git a/app/database/operator/server_data_operator/handlers/post.ts b/app/database/operator/server_data_operator/handlers/post.ts index 2b69b176c..c02bd08d7 100644 --- a/app/database/operator/server_data_operator/handlers/post.ts +++ b/app/database/operator/server_data_operator/handlers/post.ts @@ -31,7 +31,7 @@ const { export interface PostHandlerMix { handleDraft: ({drafts, prepareRecordsOnly}: HandleDraftArgs) => Promise; handleFiles: ({files, prepareRecordsOnly}: HandleFilesArgs) => Promise; - handlePosts: ({actionType, order, posts, previousPostId}: HandlePostsArgs) => Promise; + handlePosts: ({actionType, order, posts, previousPostId, prepareRecordsOnly}: HandlePostsArgs) => Promise; handlePostsInChannel: (posts: Post[]) => Promise; handlePostsInThread: (rootPosts: PostsInThread[]) => Promise; } @@ -73,12 +73,12 @@ const PostHandler = (superclass: any) => class extends superclass { * @param {string | undefined} handlePosts.previousPostId * @returns {Promise} */ - handlePosts = async ({actionType, order, posts, previousPostId = ''}: HandlePostsArgs): Promise => { + handlePosts = async ({actionType, order, posts, previousPostId = '', prepareRecordsOnly = false}: HandlePostsArgs): Promise => { const tableName = POST; // We rely on the posts array; if it is empty, we stop processing if (!posts.length) { - return; + return []; } const emojis: CustomEmoji[] = []; @@ -197,9 +197,11 @@ const PostHandler = (superclass: any) => class extends superclass { } } - if (batch.length) { + if (batch.length && !prepareRecordsOnly) { await this.batchRecords(batch); } + + return batch; }; /** diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index 8e16dc120..92c32206f 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -49,9 +49,9 @@ export const serverSchema: AppSchema = appSchema({ MyChannelSchema, MyChannelSettingsSchema, MyTeamSchema, - PostsInChannelSchema, PostInThreadSchema, PostSchema, + PostsInChannelSchema, PreferenceSchema, ReactionSchema, RoleSchema, diff --git a/app/database/schema/server/table_schemas/index.ts b/app/database/schema/server/table_schemas/index.ts index c264074fe..2456b1482 100644 --- a/app/database/schema/server/table_schemas/index.ts +++ b/app/database/schema/server/table_schemas/index.ts @@ -14,9 +14,9 @@ export {default as GroupsTeamSchema} from './groups_team'; export {default as MyChannelSchema} from './my_channel'; export {default as MyChannelSettingsSchema} from './my_channel_settings'; export {default as MyTeamSchema} from './my_team'; -export {default as PostsInChannelSchema} from './posts_in_channel'; export {default as PostInThreadSchema} from './posts_in_thread'; export {default as PostSchema} from './post'; +export {default as PostsInChannelSchema} from './posts_in_channel'; export {default as PreferenceSchema} from './preference'; export {default as ReactionSchema} from './reaction'; export {default as RoleSchema} from './role'; diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index f36f27f65..f89e4f018 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -19,6 +19,34 @@ import type PostModel from '@typings/database/models/servers/post'; const {SERVER: {CHANNEL, MY_CHANNEL, CHANNEL_MEMBERSHIP}} = MM_TABLES; +export function prepareMissingChannelsForAllTeams(operator: ServerDataOperator, channels: Channel[], channelMembers: ChannelMembership[]): Array> | undefined { + const channelInfos: ChannelInfo[] = []; + const memberships = channelMembers.map((cm) => ({...cm, id: cm.channel_id})); + + for (const c of channels) { + channelInfos.push({ + id: c.id, + header: c.header, + purpose: c.purpose, + guest_count: 0, + member_count: 0, + pinned_post_count: 0, + }); + } + + try { + const channelRecords: Promise = operator.handleChannel({channels, prepareRecordsOnly: true}); + const channelInfoRecords: Promise = operator.handleChannelInfo({channelInfos, prepareRecordsOnly: true}); + const membershipRecords: Promise = operator.handleChannelMembership({channelMemberships: memberships, prepareRecordsOnly: true}); + const myChannelRecords: Promise = operator.handleMyChannel({channels, myChannels: memberships, prepareRecordsOnly: true}); + const myChannelSettingsRecords: Promise = operator.handleMyChannelSettings({settings: memberships, prepareRecordsOnly: true}); + + return [channelRecords, channelInfoRecords, membershipRecords, myChannelRecords, myChannelSettingsRecords]; + } catch { + return undefined; + } +} + export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, teamId: string, channels: Channel[], channelMembers: ChannelMembership[]) => { const allChannelsForTeam = await queryAllChannelsForTeam(operator.database, teamId); const channelInfos: ChannelInfo[] = []; @@ -190,3 +218,7 @@ export const deleteChannelMembership = async (operator: ServerDataOperator, user return {error}; } }; + +export const queryAllMyChannelMembershipIds = async (database: Database, userId: string) => { + return database.get(CHANNEL_MEMBERSHIP).query(Q.where('user_id', Q.eq(userId))).fetchIds(); +}; diff --git a/app/screens/home/recent_mentions/components/channel_info/channel_info.tsx b/app/screens/home/recent_mentions/components/channel_info/channel_info.tsx new file mode 100644 index 000000000..4e4aa0bd9 --- /dev/null +++ b/app/screens/home/recent_mentions/components/channel_info/channel_info.tsx @@ -0,0 +1,59 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View, Text, StyleSheet} from 'react-native'; + +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type PostModel from '@typings/database/models/servers/post'; +import type TeamModel from '@typings/database/models/servers/team'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flex: 1, + flexDirection: 'row', + marginTop: 8, + }, + channel: { + ...typography('Body', 75, 'SemiBold'), + color: theme.centerChannelColor, + marginRight: 5, + }, + teamContainer: { + borderColor: theme.centerChannelColor, + borderLeftWidth: StyleSheet.hairlineWidth, + }, + team: { + ...typography('Body', 75, 'Light'), + color: theme.centerChannelColor, + marginLeft: 5, + }, +})); + +type Props = { + channelName: ChannelModel['displayName']; + post: PostModel; + teamName: TeamModel['displayName']; +} + +function ChannelInfo({channelName, teamName}: Props) { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + return ( + + {channelName} + {Boolean(teamName) && ( + + {teamName} + + )} + + ); +} + +export default ChannelInfo; diff --git a/app/screens/home/recent_mentions/components/channel_info/index.ts b/app/screens/home/recent_mentions/components/channel_info/index.ts new file mode 100644 index 000000000..93a9fdd74 --- /dev/null +++ b/app/screens/home/recent_mentions/components/channel_info/index.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; +import {switchMap, of as of$} from 'rxjs'; + +import ChannelInfo from './channel_info'; + +import type PostModel from '@typings/database/models/servers/post'; +import type TeamModel from '@typings/database/models/servers/team'; + +const enhance = withObservables(['post'], ({post}: {post: PostModel}) => { + const channel = post.channel.observe(); + + return { + channelName: channel.pipe( + switchMap((chan) => of$(chan.displayName)), + ), + teamName: channel.pipe( + switchMap((chan) => chan.team || of$(null)), + switchMap((team: TeamModel|null) => of$(team?.displayName || null)), + ), + }; +}); + +export default enhance(ChannelInfo); diff --git a/app/screens/home/recent_mentions/components/empty.tsx b/app/screens/home/recent_mentions/components/empty.tsx new file mode 100644 index 000000000..b8947c783 --- /dev/null +++ b/app/screens/home/recent_mentions/components/empty.tsx @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {View} from 'react-native'; + +import {useTheme} from '@app/context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme'; +import {typography} from '@app/utils/typography'; +import FormattedText from '@components/formatted_text'; + +// @ts-expect-error svg extension +import Mention from './mention_icon.svg'; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 40, + }, + title: { + color: theme.centerChannelColor, + ...typography('Heading', 400), + }, + paragraph: { + marginTop: 8, + textAlign: 'center', + color: changeOpacity(theme.centerChannelColor, 0.72), + ...typography('Body', 200), + }, + icon: { + alignItems: 'center', + justifyContent: 'center', + }, +})); + +function EmptyMentions() { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + return ( + + + + + + ); +} + +export default EmptyMentions; diff --git a/app/screens/home/recent_mentions/components/mention/index.ts b/app/screens/home/recent_mentions/components/mention/index.ts new file mode 100644 index 000000000..72cd5b649 --- /dev/null +++ b/app/screens/home/recent_mentions/components/mention/index.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; + +import Mention from './mention'; + +const enhance = withObservables(['post'], ({post}) => ({ + post, +})); + +export default enhance(Mention); diff --git a/app/screens/home/recent_mentions/components/mention/mention.tsx b/app/screens/home/recent_mentions/components/mention/mention.tsx new file mode 100644 index 000000000..5f98e06f4 --- /dev/null +++ b/app/screens/home/recent_mentions/components/mention/mention.tsx @@ -0,0 +1,107 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View, StyleSheet} from 'react-native'; + +import Avatar from '@components/post_list/post/avatar'; +import Message from '@components/post_list/post/body/message'; +import Header from '@components/post_list/post/header'; +import SystemAvatar from '@components/system_avatar'; +import SystemHeader from '@components/system_header'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {fromAutoResponder, isFromWebhook, isSystemMessage, isEdited as postEdited} from '@utils/post'; + +import ChannelInfo from '../channel_info'; + +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + currentUser: UserModel; + post: PostModel; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 20, + }, + content: { + flexDirection: 'row', + paddingBottom: 8, + }, + rightColumn: { + flex: 1, + flexDirection: 'column', + marginRight: 12, + }, + message: { + flex: 1, + }, +}); + +function Mention({post, currentUser}: Props) { + const theme = useTheme(); + + const isAutoResponder = fromAutoResponder(post); + const isSystemPost = isSystemMessage(post); + const isWebHook = isFromWebhook(post); + const isEdited = postEdited(post); + + const postAvatar = isAutoResponder ? ( + + ) : ( + + ); + + const header = isSystemPost && !isAutoResponder ? ( + + ) : ( +
+ ); + + return ( + + + + {postAvatar} + + {header} + + + + + + + ); +} + +export default Mention; diff --git a/app/screens/home/recent_mentions/components/mention_icon.svg b/app/screens/home/recent_mentions/components/mention_icon.svg new file mode 100644 index 000000000..6703c22bd --- /dev/null +++ b/app/screens/home/recent_mentions/components/mention_icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/screens/home/recent_mentions/index.ts b/app/screens/home/recent_mentions/index.ts new file mode 100644 index 000000000..d7edb6aa0 --- /dev/null +++ b/app/screens/home/recent_mentions/index.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import compose from 'lodash/fp/compose'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {SystemModel, UserModel} from '@app/database/models/server'; +import {getTimezone} from '@app/utils/user'; +import {SYSTEM_IDENTIFIERS, MM_TABLES} from '@constants/database'; + +import RecentMentionsScreen from './recent_mentions'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const {USER, SYSTEM, POST} = MM_TABLES.SERVER; + +const enhance = withObservables([], ({database}: WithDatabaseArgs) => { + const currentUser = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap((currentUserId) => database.get(USER).findAndObserve(currentUserId.value)), + ); + + return { + mentions: database.get(SYSTEM).query( + Q.where('id', SYSTEM_IDENTIFIERS.RECENT_MENTIONS), + Q.take(1), + ).observeWithColumns(['value']).pipe( + switchMap((rows) => { + if (!rows.length || !rows[0].value.length) { + return of$([]); + } + const row = rows[0]; + return database.get(POST).query( + Q.where('id', Q.oneOf(row.value)), + Q.sortBy('create_at', Q.asc), + ).observe(); + }), + ), + currentUser, + currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))), + isTimezoneEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')), + ), + }; +}); + +export default compose( + withDatabase, + enhance, +)(RecentMentionsScreen); diff --git a/app/screens/home/recent_mentions/index.tsx b/app/screens/home/recent_mentions/index.tsx deleted file mode 100644 index 3365e7abc..000000000 --- a/app/screens/home/recent_mentions/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useState} from 'react'; -import {Text} from 'react-native'; -import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; -import {SafeAreaView} from 'react-native-safe-area-context'; - -const RecentMentionsScreen = () => { - const route = useRoute(); - const params = route.params! as {direction: string}; - const toLeft = params.direction === 'left'; - const [start, setStart] = useState(false); - - const onLayout = useCallback(() => { - setStart(true); - }, []); - - const animated = useAnimatedStyle(() => { - if (start) { - return { - opacity: withTiming(1, {duration: 150}), - transform: [{translateX: withTiming(0, {duration: 150})}], - }; - } - - return { - opacity: withTiming(0, {duration: 150}), - transform: [{translateX: withTiming(toLeft ? -25 : 25, {duration: 150})}], - }; - }, [start]); - - return ( - - - {'Recent Mentions Screen'} - - - ); -}; - -export default RecentMentionsScreen; diff --git a/app/screens/home/recent_mentions/recent_mentions.tsx b/app/screens/home/recent_mentions/recent_mentions.tsx new file mode 100644 index 000000000..ed8ccf850 --- /dev/null +++ b/app/screens/home/recent_mentions/recent_mentions.tsx @@ -0,0 +1,169 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useIsFocused, useRoute} from '@react-navigation/native'; +import React, {useCallback, useState, useEffect, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {StyleSheet, View, ActivityIndicator, FlatList} from 'react-native'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import {SafeAreaView, Edge} from 'react-native-safe-area-context'; + +import {getRecentMentions} from '@actions/remote/search'; +import NavigationHeader from '@components/navigation_header'; +import DateSeparator from '@components/post_list/date_separator'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {UserModel} from '@database/models/server'; +import {useCollapsibleHeader} from '@hooks/header'; +import {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_list'; + +import EmptyState from './components/empty'; +import Mention from './components/mention'; + +import type PostModel from '@typings/database/models/servers/post'; + +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); + +const EDGES: Edge[] = ['bottom', 'left', 'right']; + +type Props = { + currentTimezone: string | null; + currentUser: UserModel; + isTimezoneEnabled: boolean; + mentions: PostModel[]; +} + +const styles = StyleSheet.create({ + flex: { + flex: 1, + }, + empty: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, +}); + +const RecentMentionsScreen = ({mentions, currentUser, currentTimezone, isTimezoneEnabled}: Props) => { + const theme = useTheme(); + const route = useRoute(); + const isFocused = useIsFocused(); + const {formatMessage} = useIntl(); + const [refreshing, setRefreshing] = useState(false); + const [loading, setLoading] = useState(true); + const serverUrl = useServerUrl(); + + const params = route.params! as {direction: string}; + const toLeft = params.direction === 'left'; + const translateSide = toLeft ? -25 : 25; + const opacity = useSharedValue(isFocused ? 1 : 0); + const translateX = useSharedValue(isFocused ? 0 : translateSide); + + const title = formatMessage({id: 'screen.mentions.title', defaultMessage: 'Recent Mentions'}); + const subtitle = formatMessage({id: 'screen.mentions.subtitle', defaultMessage: 'Messages you\'ve been mentioned in'}); + const isLargeTitle = true; + + useEffect(() => { + setLoading(true); + getRecentMentions(serverUrl).finally(() => { + setLoading(false); + }); + }, [serverUrl]); + + const {scrollPaddingTop, scrollRef, scrollValue, onScroll} = useCollapsibleHeader>(isLargeTitle, Boolean(subtitle), false); + + const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop}), [scrollPaddingTop]); + + const posts = useMemo(() => selectOrderedPosts(mentions, 0, false, '', false, isTimezoneEnabled, currentTimezone, false).reverse(), [mentions]); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + await getRecentMentions(serverUrl); + setRefreshing(false); + }, [serverUrl]); + + useEffect(() => { + opacity.value = isFocused ? 1 : 0; + translateX.value = isFocused ? 0 : translateSide; + }, [isFocused]); + + const animated = useAnimatedStyle(() => { + return { + opacity: withTiming(opacity.value, {duration: 150}), + transform: [{translateX: withTiming(translateX.value, {duration: 150})}], + }; + }, []); + + const renderEmptyList = useCallback(() => ( + + {loading ? ( + + ) : ( + + )} + + ), [loading]); + + const renderItem = useCallback(({item}) => { + if (typeof item === 'string') { + if (isDateLine(item)) { + return ( + + ); + } + return null; + } + + return ( + + ); + }, [currentUser]); + + return ( + <> + + + + + + + + ); +}; + +export default RecentMentionsScreen; diff --git a/app/utils/helpers.ts b/app/utils/helpers.ts index 3e24cc50f..b88fb4867 100644 --- a/app/utils/helpers.ts +++ b/app/utils/helpers.ts @@ -82,6 +82,10 @@ export function isEmail(email: string): boolean { return (/^[^ ,@]+@[^ ,@]+$/).test(email); } +export function identity(arg: T): T { + return arg; +} + export function safeParseJSON(rawJson: string | Record | unknown[]) { let data = rawJson; try { diff --git a/app/utils/post/index.ts b/app/utils/post/index.ts index 2b023e523..2ceaa45a6 100644 --- a/app/utils/post/index.ts +++ b/app/utils/post/index.ts @@ -61,37 +61,13 @@ export function postUserDisplayName(post: PostModel, author?: UserModel, teammat } export const getMentionKeysForPost = (user: UserModel, post: PostModel, groups: GroupModel[] | null) => { - const keys: UserMentionKey[] = []; - - if (!user.notifyProps) { - return keys; - } - - if (user.notifyProps.mention_keys) { - const mentions = user.notifyProps.mention_keys.split(',').map((key) => ({key})); - keys.push(...mentions); - } - - if (user.notifyProps.first_name === 'true' && user.firstName) { - keys.push({key: user.firstName, caseSensitive: true}); - } - - if (user.notifyProps.channel === 'true' && !post.props?.mentionHighlightDisabled) { - keys.push( - {key: '@channel'}, - {key: '@all'}, - {key: '@here'}, - ); - } - - const usernameKey = `@${user.username}`; - if (keys.findIndex((item) => item.key === usernameKey) === -1) { - keys.push({key: usernameKey}); - } + const keys: UserMentionKey[] = user.mentionKeys; if (groups?.length) { for (const group of groups) { - keys.push({key: `@${group.name}`}); + if (group.name && group.name.trim()) { + keys.push({key: `@${group.name}`}); + } } } diff --git a/app/utils/post_list/index.ts b/app/utils/post_list/index.ts index 020926f48..2a333adae 100644 --- a/app/utils/post_list/index.ts +++ b/app/utils/post_list/index.ts @@ -168,7 +168,7 @@ function isJoinLeavePostForUsername(post: PostModel, currentUsername: string): b } // are we going to do something with selectedPostId as in v1? -function selectOrderedPosts( +export function selectOrderedPosts( posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUsername: string, showJoinLeave: boolean, timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false) { if (posts.length === 0) { diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index 148554f38..f556ddf44 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -12,7 +12,6 @@ import {toTitleCase} from '@utils/helpers'; import type GroupModel from '@typings/database/models/servers/group'; import type GroupMembershipModel from '@typings/database/models/servers/group_membership'; -import type {UserMentionKey} from '@typings/global/markdown'; import type {IntlShape} from 'react-intl'; export function displayUsername(user?: UserProfile | UserModel, locale?: string, teammateDisplayNameSetting?: string, useFallbackUsername = true) { @@ -105,33 +104,7 @@ export const getUsersByUsername = (users: UserModel[]) => { }; export const getUserMentionKeys = (user: UserModel, groups: GroupModel[], userGroups: GroupMembershipModel[]) => { - const keys: UserMentionKey[] = []; - - if (!user.notifyProps) { - return keys; - } - - if (user.notifyProps.mention_keys) { - const mentions = user.notifyProps.mention_keys.split(',').map((key) => ({key})); - keys.push(...mentions); - } - - if (user.notifyProps.first_name === 'true' && user.firstName) { - keys.push({key: user.firstName, caseSensitive: true}); - } - - if (user.notifyProps.channel === 'true') { - keys.push( - {key: '@channel'}, - {key: '@all'}, - {key: '@here'}, - ); - } - - const usernameKey = `@${user.username}`; - if (keys.findIndex((item) => item.key === usernameKey) === -1) { - keys.push({key: usernameKey}); - } + const keys = user.mentionKeys; if (groups.length && userGroups.length) { const groupMentions = userGroups.reduce((result: Array<{key: string}>, ug: GroupMembershipModel) => { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index a1c2ddd34..916f4d794 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -296,6 +296,8 @@ "post_info.system": "System", "post_message_view.edited": "(edited)", "posts_view.newMsg": "New Messages", + "screen.mentions.subtitle": "Messages you've been mentioned in", + "screen.mentions.title": "Recent Mentions", "screen.search.placeholder": "Search messages & files", "search_bar.search": "Search", "status_dropdown.set_away": "Away", diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index 285ee1387..eecc676f2 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -141,3 +141,8 @@ type MessageAttachmentField = { value: any; short: boolean; } + +type PostSearchParams = { + terms: string; + is_or_search: boolean; +}; diff --git a/types/database/database.d.ts b/types/database/database.d.ts index 63e63e23e..09fa04b0d 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -84,6 +84,7 @@ export type HandlePostsArgs = { order: string[]; previousPostId?: string; posts: Post[]; + prepareRecordsOnly?: boolean; }; export type SanitizeReactionsArgs = { diff --git a/types/database/models/servers/user.d.ts b/types/database/models/servers/user.d.ts index 40d7029c2..2f181cdd0 100644 --- a/types/database/models/servers/user.d.ts +++ b/types/database/models/servers/user.d.ts @@ -93,4 +93,10 @@ export default class UserModel extends Model { /** prepareStatus: Prepare the model to update the user status in a batch operation */ prepareStatus: (status: string) => void; + + /* user mentions keys may include @channel, @all, @here depending on notify_props */ + mentionKeys: UserMentionKey[]; + + /* user mentions keys always excluding @channel, @all, @here */ + userMentionKeys: UserMentionKey[]; }