From 675d8495b3cfeffecf99f3c2489b3a9717670cb8 Mon Sep 17 00:00:00 2001 From: Kyriakos Z <3829551+koox00@users.noreply.github.com> Date: Thu, 16 Dec 2021 12:26:43 +0200 Subject: [PATCH] [Gekidou]: MM-39757 - Recent mentions (#5823) * [Gekidou]: MM-39757 - Recent mentions * Refactor channel_info to a separate component * Fixes schema tests * Fixes channel_info theme color * Removes RECEIVED_MENTIONS table and model Removes RECEIVED_MENTIONS table and model and saves recent_mentions in the SYSTEM table under the recentMentions ID. * Cleanup recent_mentions handler * Adds i18n in recent_mentions screen * Observe changes on the post messages * Addresses review comments * Batches records * Addresses review comments * Addresses review comments * Addresses review comments * Addresses review comments * Fetches channels and users needed for mentions Fetching mentions from all teams might result in missing info like user profiles, and channels missing from the DB. This commit fetches all missing users and channels. * Adds empty state for recent mentions * Prepares all missing models for channels * Addresses review comments * Fixes mention keys for recent mentions User mention keys when asking for mentions should not include general purpose ones, like @channel, @all, @here. Fixes ActivityIndicator color in recent mentions screen. * Removes top margin of mention message * Addresses review comments * Fixes group.name undefined --- app/actions/local/post.ts | 24 +++ app/actions/remote/post.ts | 68 +++++-- app/actions/remote/search.ts | 139 ++++++++++++++ app/client/rest/posts.ts | 13 +- .../post_list/date_separator/index.tsx | 16 +- .../post_list/post/avatar/index.tsx | 3 +- .../post_list/post/header/header.tsx | 2 +- app/components/system_avatar/index.tsx | 3 +- app/constants/database.ts | 7 +- app/constants/screens.ts | 2 + app/database/models/server/index.ts | 8 +- app/database/models/server/user.ts | 42 +++++ .../server_data_operator/handlers/post.ts | 10 +- app/database/schema/server/index.ts | 2 +- .../schema/server/table_schemas/index.ts | 2 +- app/queries/servers/channel.ts | 32 ++++ .../components/channel_info/channel_info.tsx | 59 ++++++ .../components/channel_info/index.ts | 26 +++ .../home/recent_mentions/components/empty.tsx | 60 +++++++ .../components/mention/index.ts | 12 ++ .../components/mention/mention.tsx | 107 +++++++++++ .../components/mention_icon.svg | 10 ++ app/screens/home/recent_mentions/index.ts | 53 ++++++ app/screens/home/recent_mentions/index.tsx | 48 ----- .../home/recent_mentions/recent_mentions.tsx | 169 ++++++++++++++++++ app/utils/helpers.ts | 4 + app/utils/post/index.ts | 32 +--- app/utils/post_list/index.ts | 2 +- app/utils/user/index.ts | 29 +-- assets/base/i18n/en.json | 2 + types/api/posts.d.ts | 5 + types/database/database.d.ts | 1 + types/database/models/servers/user.d.ts | 6 + 33 files changed, 839 insertions(+), 159 deletions(-) create mode 100644 app/actions/remote/search.ts create mode 100644 app/screens/home/recent_mentions/components/channel_info/channel_info.tsx create mode 100644 app/screens/home/recent_mentions/components/channel_info/index.ts create mode 100644 app/screens/home/recent_mentions/components/empty.tsx create mode 100644 app/screens/home/recent_mentions/components/mention/index.ts create mode 100644 app/screens/home/recent_mentions/components/mention/mention.tsx create mode 100644 app/screens/home/recent_mentions/components/mention_icon.svg create mode 100644 app/screens/home/recent_mentions/index.ts delete mode 100644 app/screens/home/recent_mentions/index.tsx create mode 100644 app/screens/home/recent_mentions/recent_mentions.tsx 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[]; }