From 5700ce7c86a97da161b0330cc674326314915b45 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Mon, 6 Sep 2021 08:22:55 -0300 Subject: [PATCH] [Gekidou] post component tree and partial postList (#5637) --- app/actions/local/post.ts | 133 + app/actions/remote/apps.ts | 164 + app/actions/remote/channel.ts | 45 +- app/actions/remote/general.ts | 45 +- app/actions/remote/post.ts | 41 +- app/actions/remote/reactions.ts | 114 + app/actions/remote/systems.ts | 37 +- app/actions/remote/user.ts | 166 +- app/client/rest/posts.ts | 2 +- .../autocomplete_selector/index.tsx | 258 ++ .../__snapshots__/clear_button.test.tsx.snap | 37 + .../custom_status_emoji.test.tsx.snap | 21 + .../custom_status_text.test.tsx.snap | 37 + .../custom_status/clear_button.test.tsx | 37 + app/components/custom_status/clear_button.tsx | 52 + .../custom_status_emoji.test.tsx | 42 + .../custom_status/custom_status_emoji.tsx | 35 + .../custom_status/custom_status_text.test.tsx | 35 + .../custom_status/custom_status_text.tsx | 39 + app/components/emoji/index.tsx | 18 +- app/components/formatted_date/index.tsx | 31 + .../formatted_markdown_text/index.tsx | 125 + app/components/formatted_time/index.tsx | 39 + app/components/jumbo_emoji/index.tsx | 24 +- app/components/markdown/at_mention/index.tsx | 62 +- app/components/markdown/index.tsx | 35 +- .../markdown/markdown_image/index.tsx | 3 +- .../markdown/markdown_link/index.tsx | 6 +- .../markdown/markdown_table_image/index.tsx | 2 +- app/components/markdown/transform.test.ts | 3011 +++++++++++++++++ app/components/markdown/transform.ts | 4 +- .../combined_user_activity.tsx | 233 ++ .../post_list/combined_user_activity/index.ts | 69 + .../combined_user_activity/last_users.tsx | 104 + .../combined_user_activity/messages.ts | 192 ++ .../post_list/date_separator/index.tsx | 107 + app/components/post_list/index.tsx | 272 ++ .../post_list/new_message_line/index.tsx | 73 + .../post_list/post/avatar/index.tsx | 182 + .../post_list/post/body/add_members/index.tsx | 237 ++ .../button_binding/button_binding_text.tsx | 87 + .../button_binding/index.tsx | 145 + .../content/embedded_bindings/embed_text.tsx | 75 + .../content/embedded_bindings/embed_title.tsx | 56 + .../embedded_bindings/embedded_binding.tsx | 71 + .../embedded_sub_bindings.tsx | 50 + .../body/content/embedded_bindings/index.tsx | 38 + .../embedded_bindings/menu_binding/index.tsx | 118 + .../post/body/content/image_preview/index.tsx | 140 + .../post_list/post/body/content/index.tsx | 96 + .../attachment_footer.test.tsx.snap | 84 + .../action_button/action_button_text.tsx | 87 + .../action_button/index.tsx | 87 + .../message_attachments/action_menu/index.tsx | 52 + .../attachment_actions.tsx | 58 + .../message_attachments/attachment_author.tsx | 83 + .../message_attachments/attachment_fields.tsx | 128 + .../attachment_footer.test.tsx | 32 + .../message_attachments/attachment_footer.tsx | 60 + .../attachment_image/index.tsx | 100 + .../attachment_pretext.tsx | 52 + .../message_attachments/attachment_text.tsx | 75 + .../attachment_thumbnail.tsx | 36 + .../message_attachments/attachment_title.tsx | 95 + .../content/message_attachments/index.tsx | 45 + .../message_attachment.tsx | 142 + .../post/body/content/opengraph/index.tsx | 201 ++ .../opengraph/opengraph_image/index.tsx | 111 + .../post/body/content/youtube/index.tsx | 196 ++ .../post_list/post/body/failed/index.tsx | 90 + .../post/body/files/document_file.tsx | 203 ++ .../post_list/post/body/files/file.tsx | 133 + .../post_list/post/body/files/file_icon.tsx | 90 + .../post_list/post/body/files/file_info.tsx | 73 + .../post_list/post/body/files/image_file.tsx | 205 ++ .../post/body/files/image_file_overlay.tsx | 51 + .../post_list/post/body/files/index.tsx | 210 ++ app/components/post_list/post/body/index.tsx | 192 ++ .../post_list/post/body/message/index.ts | 42 + .../post_list/post/body/message/message.tsx | 113 + .../post/body/message/show_more_button.tsx | 125 + .../post_list/post/body/reactions/index.ts | 57 + .../post/body/reactions/reaction.tsx | 76 + .../post/body/reactions/reactions.tsx | 171 + .../post/header/commented_on/index.tsx | 50 + .../post/header/display_name/index.tsx | 148 + .../post_list/post/header/header.tsx | 147 + app/components/post_list/post/header/index.ts | 70 + .../post_list/post/header/reply/index.tsx | 81 + .../post_list/post/header/tag/index.tsx | 54 + app/components/post_list/post/index.ts | 147 + app/components/post_list/post/post.tsx | 286 ++ .../post_list/post/pre_header/index.tsx | 119 + .../system_message_helpers.test.js.snap | 358 ++ .../post_list/post/system_message/index.tsx | 14 + .../post/system_message/system_message.tsx | 271 ++ .../system_message_helpers.test.js | 202 ++ app/components/profile_picture/index.tsx | 10 +- app/components/progress_bar/index.tsx | 77 + app/components/progressive_image/index.tsx | 2 +- app/components/server_version/index.tsx | 4 +- app/components/system_avatar/index.tsx | 35 + app/components/system_header/index.tsx | 113 + app/components/tag/index.tsx | 92 + .../__snapshots__/user_status.test.js.snap | 53 + app/components/user_status/index.tsx | 48 + .../user_status/user_status.test.js | 56 + app/constants/apps.ts | 54 + app/constants/database.ts | 4 +- app/constants/emoji.ts | 10 + app/constants/index.ts | 4 + app/constants/network.ts | 3 + app/constants/post.ts | 49 + app/constants/screens.ts | 8 + app/context/theme/index.tsx | 4 +- app/database/manager/__mocks__/index.ts | 4 +- app/database/manager/index.ts | 4 +- .../models/server/group_membership.ts | 22 +- app/database/models/server/index.ts | 1 - app/database/models/server/post.ts | 33 +- app/database/models/server/post_metadata.ts | 27 - .../models/server/posts_in_channel.ts | 2 +- app/database/models/server/posts_in_thread.ts | 2 +- app/database/models/server/user.ts | 6 + .../server_data_operator/comparators/index.ts | 5 - .../handlers/post.test.ts | 49 - .../server_data_operator/handlers/post.ts | 65 +- .../handlers/posts_in_channel.ts | 49 +- .../handlers/posts_in_thread.ts | 16 +- .../handlers/user.test.ts | 1 + .../server_data_operator/handlers/user.ts | 20 +- .../transformers/post.test.ts | 44 - .../server_data_operator/transformers/post.ts | 32 +- app/database/schema/server/index.ts | 2 - .../schema/server/table_schemas/index.ts | 1 - .../schema/server/table_schemas/post.ts | 1 + .../server/table_schemas/post_metadata.ts | 15 - app/database/schema/server/test.ts | 12 +- app/helpers/api/general.ts | 30 + app/helpers/api/preference.ts | 8 +- app/helpers/database/groups.ts | 56 + app/hooks/show_more.ts | 25 + app/hooks/why_did_you_update.ts | 40 + app/queries/servers/post.ts | 10 + app/queries/servers/system.ts | 9 + app/queries/servers/user.ts | 20 +- app/screens/channel/index.tsx | 98 +- app/screens/channel/md.json | 19 - app/utils/apps.ts | 177 + app/utils/document/index.ts | 64 + app/utils/emoji/helpers.ts | 26 +- app/utils/file/index.ts | 126 +- app/utils/gallery/index.ts | 4 +- app/utils/message_attachment_colors.ts | 13 + app/utils/opengraph.js | 23 + app/utils/opengraph.ts | 28 + app/utils/post/index.ts | 99 + app/utils/post_list/index.ts | 375 ++ app/utils/role/index.ts | 67 + .../{supported_server.ts => index.ts} | 38 +- .../supported_server/supported_server.test.ts | 10 +- app/utils/user/index.ts | 49 + assets/base/i18n/en.json | 94 + ios/Mattermost.xcodeproj/project.pbxproj | 8 +- ios/Podfile | 6 + ios/Podfile.lock | 2 +- ios/patches/SessionDelegate.patch | 18 + ios/patches/XCDYouTubeVideoOperation.patch | 11 + package-lock.json | 17 +- package.json | 1 + patches/react-native-navigation+7.18.1.patch | 124 +- patches/react-native-youtube+2.0.2.patch | 19 +- test/intl-test-helper.tsx | 56 +- test/test_helper.js | 39 + types/api/config.d.ts | 2 + types/api/integrations.d.ts | 28 + types/api/posts.d.ts | 27 +- types/api/users.d.ts | 17 + types/database/database.d.ts | 6 +- .../models/servers/group_membership.d.ts | 20 +- types/database/models/servers/post.d.ts | 10 +- .../models/servers/post_metadata.d.ts | 19 - types/database/models/servers/user.d.ts | 3 + types/database/raw_values.d.ts | 2 +- types/global/markdown.d.ts | 7 - types/global/markdown.ts | 19 + 186 files changed, 14905 insertions(+), 650 deletions(-) create mode 100644 app/actions/local/post.ts create mode 100644 app/actions/remote/apps.ts create mode 100644 app/actions/remote/reactions.ts create mode 100644 app/components/autocomplete_selector/index.tsx create mode 100644 app/components/custom_status/__snapshots__/clear_button.test.tsx.snap create mode 100644 app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap create mode 100644 app/components/custom_status/__snapshots__/custom_status_text.test.tsx.snap create mode 100644 app/components/custom_status/clear_button.test.tsx create mode 100644 app/components/custom_status/clear_button.tsx create mode 100644 app/components/custom_status/custom_status_emoji.test.tsx create mode 100644 app/components/custom_status/custom_status_emoji.tsx create mode 100644 app/components/custom_status/custom_status_text.test.tsx create mode 100644 app/components/custom_status/custom_status_text.tsx create mode 100644 app/components/formatted_date/index.tsx create mode 100644 app/components/formatted_markdown_text/index.tsx create mode 100644 app/components/formatted_time/index.tsx create mode 100644 app/components/markdown/transform.test.ts create mode 100644 app/components/post_list/combined_user_activity/combined_user_activity.tsx create mode 100644 app/components/post_list/combined_user_activity/index.ts create mode 100644 app/components/post_list/combined_user_activity/last_users.tsx create mode 100644 app/components/post_list/combined_user_activity/messages.ts create mode 100644 app/components/post_list/date_separator/index.tsx create mode 100644 app/components/post_list/index.tsx create mode 100644 app/components/post_list/new_message_line/index.tsx create mode 100644 app/components/post_list/post/avatar/index.tsx create mode 100644 app/components/post_list/post/body/add_members/index.tsx create mode 100644 app/components/post_list/post/body/content/embedded_bindings/button_binding/button_binding_text.tsx create mode 100644 app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx create mode 100644 app/components/post_list/post/body/content/embedded_bindings/embed_text.tsx create mode 100644 app/components/post_list/post/body/content/embedded_bindings/embed_title.tsx create mode 100644 app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx create mode 100644 app/components/post_list/post/body/content/embedded_bindings/embedded_sub_bindings.tsx create mode 100644 app/components/post_list/post/body/content/embedded_bindings/index.tsx create mode 100644 app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx create mode 100644 app/components/post_list/post/body/content/image_preview/index.tsx create mode 100644 app/components/post_list/post/body/content/index.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/__snapshots__/attachment_footer.test.tsx.snap create mode 100644 app/components/post_list/post/body/content/message_attachments/action_button/action_button_text.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/action_button/index.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/action_menu/index.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/attachment_actions.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/attachment_author.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/attachment_fields.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/attachment_footer.test.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/attachment_footer.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/attachment_pretext.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/attachment_text.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/attachment_thumbnail.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/attachment_title.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/index.tsx create mode 100644 app/components/post_list/post/body/content/message_attachments/message_attachment.tsx create mode 100644 app/components/post_list/post/body/content/opengraph/index.tsx create mode 100644 app/components/post_list/post/body/content/opengraph/opengraph_image/index.tsx create mode 100644 app/components/post_list/post/body/content/youtube/index.tsx create mode 100644 app/components/post_list/post/body/failed/index.tsx create mode 100644 app/components/post_list/post/body/files/document_file.tsx create mode 100644 app/components/post_list/post/body/files/file.tsx create mode 100644 app/components/post_list/post/body/files/file_icon.tsx create mode 100644 app/components/post_list/post/body/files/file_info.tsx create mode 100644 app/components/post_list/post/body/files/image_file.tsx create mode 100644 app/components/post_list/post/body/files/image_file_overlay.tsx create mode 100644 app/components/post_list/post/body/files/index.tsx create mode 100644 app/components/post_list/post/body/index.tsx create mode 100644 app/components/post_list/post/body/message/index.ts create mode 100644 app/components/post_list/post/body/message/message.tsx create mode 100644 app/components/post_list/post/body/message/show_more_button.tsx create mode 100644 app/components/post_list/post/body/reactions/index.ts create mode 100644 app/components/post_list/post/body/reactions/reaction.tsx create mode 100644 app/components/post_list/post/body/reactions/reactions.tsx create mode 100644 app/components/post_list/post/header/commented_on/index.tsx create mode 100644 app/components/post_list/post/header/display_name/index.tsx create mode 100644 app/components/post_list/post/header/header.tsx create mode 100644 app/components/post_list/post/header/index.ts create mode 100644 app/components/post_list/post/header/reply/index.tsx create mode 100644 app/components/post_list/post/header/tag/index.tsx create mode 100644 app/components/post_list/post/index.ts create mode 100644 app/components/post_list/post/post.tsx create mode 100644 app/components/post_list/post/pre_header/index.tsx create mode 100644 app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap create mode 100644 app/components/post_list/post/system_message/index.tsx create mode 100644 app/components/post_list/post/system_message/system_message.tsx create mode 100644 app/components/post_list/post/system_message/system_message_helpers.test.js create mode 100644 app/components/progress_bar/index.tsx create mode 100644 app/components/system_avatar/index.tsx create mode 100644 app/components/system_header/index.tsx create mode 100644 app/components/tag/index.tsx create mode 100644 app/components/user_status/__snapshots__/user_status.test.js.snap create mode 100644 app/components/user_status/index.tsx create mode 100644 app/components/user_status/user_status.test.js create mode 100644 app/constants/apps.ts create mode 100644 app/constants/post.ts delete mode 100644 app/database/models/server/post_metadata.ts delete mode 100644 app/database/schema/server/table_schemas/post_metadata.ts create mode 100644 app/helpers/api/general.ts create mode 100644 app/helpers/database/groups.ts create mode 100644 app/hooks/show_more.ts create mode 100644 app/hooks/why_did_you_update.ts delete mode 100644 app/screens/channel/md.json create mode 100644 app/utils/apps.ts create mode 100644 app/utils/document/index.ts create mode 100644 app/utils/message_attachment_colors.ts create mode 100644 app/utils/opengraph.js create mode 100644 app/utils/opengraph.ts create mode 100644 app/utils/post/index.ts create mode 100644 app/utils/post_list/index.ts rename app/utils/supported_server/{supported_server.ts => index.ts} (56%) create mode 100644 ios/patches/SessionDelegate.patch create mode 100644 ios/patches/XCDYouTubeVideoOperation.patch delete mode 100644 types/database/models/servers/post_metadata.d.ts delete mode 100644 types/global/markdown.d.ts create mode 100644 types/global/markdown.ts diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts new file mode 100644 index 000000000..4620c5111 --- /dev/null +++ b/app/actions/local/post.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {postActionWithCookie} from '@actions/remote/post'; +import {ActionType, Post} from '@constants'; +import DatabaseManager from '@database/manager'; +import {queryPostById} from '@queries/servers/post'; +import {queryCurrentUserId} from '@queries/servers/system'; +import {generateId} from '@utils/general'; + +import type UserModel from '@typings/database/models/servers/user'; +import type PostModel from '@typings/database/models/servers/post'; + +export const sendAddToChannelEphemeralPost = async (serverUrl: string, user: UserModel, addedUsernames: string[], messages: string[], channeId: string, postRootId = '') => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const timestamp = Date.now(); + const posts = addedUsernames.map((addedUsername, index) => { + const message = messages[index]; + return { + id: generateId(), + user_id: user.id, + channel_id: channeId, + message, + type: Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL as PostType, + create_at: timestamp, + edit_at: 0, + update_at: timestamp, + delete_at: 0, + is_pinned: false, + original_id: '', + hashtags: '', + pending_post_id: '', + reply_count: 0, + metadata: {}, + participants: null, + root_id: postRootId, + props: { + username: user.username, + addedUsername, + }, + } as Post; + }); + + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: posts.map((p) => p.id), + posts, + }); + + return {posts}; +}; + +export const sendEphemeralPost = async (serverUrl: string, message: string, channeId: string, rootId = '', userId?: string) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + if (!channeId) { + return {error: 'channel Id not defined'}; + } + + let authorId = userId; + if (!authorId) { + authorId = await queryCurrentUserId(operator.database); + } + + const timestamp = Date.now(); + const post = { + id: generateId(), + user_id: authorId, + channel_id: channeId, + message, + type: Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL as PostType, + create_at: timestamp, + edit_at: 0, + update_at: timestamp, + delete_at: 0, + is_pinned: false, + original_id: '', + hashtags: '', + pending_post_id: '', + reply_count: 0, + metadata: {}, + participants: null, + root_id: rootId, + props: {}, + } as Post; + + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [post.id], + posts: [post], + }); + + return {post}; +}; + +export const removePost = async (serverUrl: string, post: PostModel) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + if (post.type === Post.POST_TYPES.COMBINED_USER_ACTIVITY && post.props?.system_post_ids) { + const systemPostIds = post.props.system_post_ids as string[]; + for await (const id of systemPostIds) { + const postModel = await queryPostById(operator.database, id); + if (postModel) { + await operator.database.write(async () => { + await postModel.destroyPermanently(); + }); + } + } + } else { + const postModel = await queryPostById(operator.database, post.id); + if (postModel) { + await operator.database.write(async () => { + await postModel.destroyPermanently(); + }); + } + } + + return {post}; +}; + +export const selectAttachmentMenuAction = (serverUrl: string, postId: string, actionId: string, selectedOption: string) => { + return postActionWithCookie(serverUrl, postId, actionId, '', selectedOption); +}; diff --git a/app/actions/remote/apps.ts b/app/actions/remote/apps.ts new file mode 100644 index 000000000..119462cf1 --- /dev/null +++ b/app/actions/remote/apps.ts @@ -0,0 +1,164 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {IntlShape} from 'react-intl'; + +import {sendEphemeralPost} from '@actions/local/post'; +import CompassIcon from '@components/compass_icon'; +import {Screens} from '@constants'; +import {AppCallResponseTypes, AppCallTypes} from '@constants/apps'; +import NetworkManager from '@init/network_manager'; +import {showModal} from '@screens/navigation'; +import EphemeralStore from '@store/ephemeral_store'; +import {makeCallErrorResponse} from '@utils/apps'; + +import type {Client} from '@client/rest'; +import type PostModel from '@typings/database/models/servers/post'; + +export async function doAppCall(serverUrl: string, call: AppCallRequest, type: AppCallType, intl: IntlShape, theme: Theme) { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const res = await client.executeAppCall(call, type) as AppCallResponse; + const responseType = res.type || AppCallResponseTypes.OK; + + switch (responseType) { + case AppCallResponseTypes.OK: + return {data: res}; + case AppCallResponseTypes.ERROR: + return {error: res}; + case AppCallResponseTypes.FORM: { + if (!res.form) { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.form.no_form', + defaultMessage: 'Response type is `form`, but no form was included in the response.', + }); + return {error: makeCallErrorResponse(errMsg)}; + } + + const screen = EphemeralStore.getNavigationTopComponentId(); + if (type === AppCallTypes.SUBMIT && screen !== Screens.APP_FORM) { + showAppForm(res.form, call, theme); + } + + return {data: res}; + } + case AppCallResponseTypes.NAVIGATE: + if (!res.navigate_to_url) { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.navigate.no_url', + defaultMessage: 'Response type is `navigate`, but no url was included in response.', + }); + return {error: makeCallErrorResponse(errMsg)}; + } + + if (type !== AppCallTypes.SUBMIT) { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.navigate.no_submit', + defaultMessage: 'Response type is `navigate`, but the call was not a submission.', + }); + return {error: makeCallErrorResponse(errMsg)}; + } + + // TODO: Add functionality to handle this + // handleGotoLocation(res.navigate_to_url, intl); + + return {data: res}; + default: { + const errMsg = intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: responseType, + }); + return {error: makeCallErrorResponse(errMsg)}; + } + } + } catch (error) { + const errMsg = error.message || intl.formatMessage({ + id: 'apps.error.responses.unexpected_error', + defaultMessage: 'Received an unexpected error.', + }); + return {error: makeCallErrorResponse(errMsg)}; + } +} + +export function postEphemeralCallResponseForPost(serverUrl: string, response: AppCallResponse, message: string, post: PostModel) { + return sendEphemeralPost( + serverUrl, + message, + post.channelId, + post.rootId, + response.app_metadata?.bot_user_id, + ); +} + +export function postEphemeralCallResponseForChannel(serverUrl: string, response: AppCallResponse, message: string, channelID: string) { + return sendEphemeralPost( + serverUrl, + message, + channelID, + '', + response.app_metadata?.bot_user_id, + ); +} + +export function postEphemeralCallResponseForContext(serverUrl: string, response: AppCallResponse, message: string, context: AppContext) { + return sendEphemeralPost( + serverUrl, + message, + context.channel_id!, + context.root_id || context.post_id, + response.app_metadata?.bot_user_id, + ); +} + +export function postEphemeralCallResponseForCommandArgs(serverUrl: string, response: AppCallResponse, message: string, args: CommandArgs) { + return sendEphemeralPost( + serverUrl, + message, + args.channel_id, + args.root_id, + response.app_metadata?.bot_user_id, + ); +} + +const showAppForm = async (form: AppForm, call: AppCallRequest, theme: Theme) => { + const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor); + + let submitButtons = [{ + id: 'submit-form', + showAsAction: 'always', + text: 'Submit', + }]; + if (form.submit_buttons) { + const options = form.fields.find((f) => f.name === form.submit_buttons)?.options; + const newButtons = options?.map((o) => { + return { + id: 'submit-form_' + o.value, + showAsAction: 'always', + text: o.label, + }; + }); + if (newButtons && newButtons.length > 0) { + submitButtons = newButtons; + } + } + const options = { + topBar: { + leftButtons: [{ + id: 'close-dialog', + icon: closeButton, + }], + rightButtons: submitButtons, + }, + }; + + const passProps = {form, call}; + showModal(Screens.APP_FORM, form.title || '', passProps, options); +}; diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 782f13a64..7a64e8e47 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -7,11 +7,13 @@ import NetworkManager from '@init/network_manager'; import {prepareMyChannelsForTeam, queryMyChannel} from '@queries/servers/channel'; import {displayGroupMessageName, displayUsername} from '@utils/user'; -import type {Model} from '@nozbe/watermelondb'; - import {fetchRolesIfNeeded} from './role'; import {forceLogoutIfNecessary} from './session'; -import {fetchProfilesPerChannels} from './user'; +import {fetchProfilesPerChannels, fetchUsersByIds} from './user'; + +import type {Model} from '@nozbe/watermelondb'; + +import type {Client} from '@client/rest'; export type MyChannelsRequest = { channels?: Channel[]; @@ -19,8 +21,39 @@ export type MyChannelsRequest = { error?: never; } +export const addMembersToChannel = async (serverUrl: string, channelId: string, userIds: string[], postRootId = '', fetchOnly = false) => { + 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}; + } + + try { + const promises = userIds.map((id) => client.addToChannel(id, channelId, postRootId)); + const channelMemberships: ChannelMembership[] = await Promise.all(promises); + await fetchUsersByIds(serverUrl, userIds, false); + + if (!fetchOnly) { + await operator.handleChannelMembership({ + channelMemberships, + prepareRecordsOnly: false, + }); + } + return {channelMemberships}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + export const fetchChannelByName = async (serverUrl: string, teamId: string, channelName: string, fetchOnly = false) => { - let client; + let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { @@ -45,7 +78,7 @@ export const fetchChannelByName = async (serverUrl: string, teamId: string, chan }; export const fetchMyChannelsForTeam = async (serverUrl: string, teamId: string, includeDeleted = true, since = 0, fetchOnly = false, excludeDirect = false): Promise => { - let client; + let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { @@ -140,7 +173,7 @@ export const joinChannel = async (serverUrl: string, userId: string, teamId: str return {error: `${serverUrl} database not found`}; } - let client; + let client: Client; try { client = NetworkManager.getClient(serverUrl); } catch (error) { diff --git a/app/actions/remote/general.ts b/app/actions/remote/general.ts index 20fc2f0da..374d72043 100644 --- a/app/actions/remote/general.ts +++ b/app/actions/remote/general.ts @@ -1,10 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import NetworkManager from '@init/network_manager'; - import type {ClientResponse} from '@mattermost/react-native-network-client'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@init/network_manager'; +import {queryExpandedLinks} from '@queries/servers/system'; + +import {forceLogoutIfNecessary} from './session'; + +import type {Client} from '@client/rest'; + export const doPing = async (serverUrl: string) => { const client = await NetworkManager.createClient(serverUrl); @@ -41,3 +48,37 @@ export const doPing = async (serverUrl: string) => { return {error: undefined}; }; +export const getRedirectLocation = async (serverUrl: string, link: string) => { + 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}; + } + + try { + const expandedLink = await client.getRedirectLocation(link); + if (expandedLink?.location) { + const storedLinks = await queryExpandedLinks(operator.database); + storedLinks[link] = expandedLink.location; + const expanded: IdValue = { + id: SYSTEM_IDENTIFIERS.EXPANDED_LINKS, + value: JSON.stringify(storedLinks), + }; + await operator.handleSystem({ + systems: [expanded], + prepareRecordsOnly: false, + }); + } + + return {expandedLink}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index 334b301e1..b6afc35e8 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. 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'; @@ -9,10 +10,10 @@ import {queryRecentPostsInChannel} from '@queries/servers/post'; import {queryCurrentUserId, queryCurrentChannelId} from '@queries/servers/system'; import {queryAllUsers} from '@queries/servers/user'; -import type {Client} from '@client/rest'; - import {forceLogoutIfNecessary} from './session'; +import type {Client} from '@client/rest'; + type PostsRequest = { error?: never; order?: string[]; @@ -186,9 +187,9 @@ export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOn const authors = result.flat(); if (!fetchOnly && authors.length) { - operator.handleUsers({ + await operator.handleUsers({ users: authors, - prepareRecordsOnly: true, + prepareRecordsOnly: false, }); } @@ -202,6 +203,38 @@ export const fetchPostAuthors = async (serverUrl: string, posts: Post[], fetchOn } }; +export const postActionWithCookie = async (serverUrl: string, postId: string, actionId: string, actionCookie: string, selectedOption = '') => { + 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}; + } + + try { + const data = await client.doPostActionWithCookie(postId, actionId, actionCookie, selectedOption); + if (data?.trigger_id) { + await operator.handleSystem({ + systems: [{ + id: SYSTEM_IDENTIFIERS.INTEGRATION_TRIGGER_ID, + value: data.trigger_id, + }], + prepareRecordsOnly: false, + }); + } + + return {data}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + 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[]; diff --git a/app/actions/remote/reactions.ts b/app/actions/remote/reactions.ts new file mode 100644 index 000000000..4e4f382dc --- /dev/null +++ b/app/actions/remote/reactions.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {Q} from '@nozbe/watermelondb'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@init/network_manager'; +import {queryCurrentUserId} from '@queries/servers/system'; + +import {forceLogoutIfNecessary} from './session'; + +import type {Client} from '@client/rest'; +import type SystemModel from '@typings/database/models/servers/system'; + +export const addReaction = async (serverUrl: string, postId: string, emojiName: string) => { + 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}; + } + + try { + const currentUserId = await queryCurrentUserId(operator.database); + const reaction = await client.addReaction(currentUserId, postId, emojiName); + + await operator.handleReactions({ + postsReactions: [{ + post_id: postId, + reactions: [reaction], + }], + prepareRecordsOnly: false, + }); + + addRecentReaction(serverUrl, emojiName); + + return {reaction}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +export const removeReaction = async (serverUrl: string, postId: string, emojiName: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const currentUserId = await queryCurrentUserId(database); + await client.removeReaction(currentUserId, postId, emojiName); + + // should return one or no reaction + const reaction = await database.get(MM_TABLES.SERVER.REACTION).query( + Q.where('emoji_name', emojiName), + Q.where('post_id', postId), + Q.where('user_id', currentUserId), + ).fetch(); + + if (reaction.length) { + await database.write(async () => { + await reaction[0].destroyPermanently(); + }); + } + + return {reaction}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +export const addRecentReaction = async (serverUrl: string, emojiName: string) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const recent = []; + try { + const emojis = await operator.database.get(MM_TABLES.SERVER.SYSTEM).find(SYSTEM_IDENTIFIERS.RECENT_REACTIONS) as SystemModel; + recent.push(...emojis.value); + } catch { + // no previous values.. continue + } + + try { + recent.unshift(emojiName); + await operator.handleSystem({ + systems: [{ + id: SYSTEM_IDENTIFIERS.RECENT_REACTIONS, + value: JSON.stringify(recent), + }], + prepareRecordsOnly: false, + }); + + return {error: undefined}; + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/remote/systems.ts b/app/actions/remote/systems.ts index 5c0d738b1..b774a94ea 100644 --- a/app/actions/remote/systems.ts +++ b/app/actions/remote/systems.ts @@ -1,12 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import deepEqual from 'deep-equal'; + import {logError} from '@actions/remote/error'; import {forceLogoutIfNecessary} from '@actions/remote/session'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {getServerCredentials} from '@init/credentials'; import NetworkManager from '@init/network_manager'; +import {queryCommonSystemValues} from '@queries/servers/system'; export type ConfigAndLicenseRequest = { config?: ClientConfig; @@ -68,19 +71,29 @@ export const fetchConfigAndLicense = async (serverUrl: string, fetchOnly = false const credentials = await getServerCredentials(serverUrl); const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (credentials && operator) { - const systems: IdValue[] = [{ - id: SYSTEM_IDENTIFIERS.CONFIG, - value: JSON.stringify(config), - }, { - id: SYSTEM_IDENTIFIERS.LICENSE, - value: JSON.stringify(license), - }]; - - operator.handleSystem({systems, prepareRecordsOnly: false}). - catch((error) => { - // eslint-disable-next-line no-console - console.log('An error ocurred while saving config & license', error); + const current = await queryCommonSystemValues(operator.database); + const systems: IdValue[] = []; + if (!deepEqual(config, current.config)) { + systems.push({ + id: SYSTEM_IDENTIFIERS.CONFIG, + value: JSON.stringify(config), }); + } + + if (!deepEqual(license, current.license)) { + systems.push({ + id: SYSTEM_IDENTIFIERS.LICENSE, + value: JSON.stringify(license), + }); + } + + if (systems.length) { + operator.handleSystem({systems, prepareRecordsOnly: false}). + catch((error) => { + // eslint-disable-next-line no-console + console.log('An error ocurred while saving config & license', error); + }); + } } } diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 0e3128d32..41750f698 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -1,13 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {logError} from '@actions/remote/error'; +import {Q} from '@nozbe/watermelondb'; import {fetchRolesIfNeeded} from '@actions/remote/role'; -import {Database} from '@constants'; +import {Database, General} from '@constants'; import DatabaseManager from '@database/manager'; +import {debounce} from '@helpers/api/general'; import analytics from '@init/analytics'; import NetworkManager from '@init/network_manager'; -import {prepareUsers} from '@queries/servers/user'; +import {prepareUsers, queryCurrentUser, queryUsersById, queryUsersByUsername} from '@queries/servers/user'; +import {queryCurrentUserId} from '@queries/servers/system'; import type {Client} from '@client/rest'; import type {LoadMeArgs} from '@typings/database/database'; @@ -244,7 +246,7 @@ export const updateMe = async (serverUrl: string, user: UserModel) => { try { data = await client.patchMe(user._raw); } catch (e) { - logError(e); + forceLogoutIfNecessary(serverUrl, e); return {error: e}; } @@ -259,3 +261,159 @@ export const updateMe = async (serverUrl: string, user: UserModel) => { return {data}; }; + +let ids: string[] = []; +const debouncedFetchStatusesByIds = debounce((serverUrl: string) => { + fetchStatusByIds(serverUrl, [...new Set(ids)]); +}, 200, false, () => { + ids = []; +}); + +export const fetchStatusInBatch = (serverUrl: string, id: string) => { + ids = [...ids, id]; + return debouncedFetchStatusesByIds.apply(null, [serverUrl]); +}; + +export const fetchStatusByIds = async (serverUrl: string, userIds: string[], fetchOnly = false) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + if (!userIds.length) { + return {statuses: []}; + } + + try { + const statuses = await client.getStatusesByIds(userIds); + + if (!fetchOnly && DatabaseManager.serverDatabases[serverUrl]) { + const {database, operator} = DatabaseManager.serverDatabases[serverUrl]; + if (operator) { + const users = await database.get(Database.MM_TABLES.SERVER.USER).query(Q.where('id', Q.oneOf(userIds))).fetch() as UserModel[]; + for (const user of users) { + const status = statuses.find((s) => s.user_id === user.id); + user.prepareSatus(status?.status || General.OFFLINE); + } + + await operator.batchRecords(users); + } + } + + return {statuses}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetchOnly = false) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + if (!userIds.length) { + return {users: []}; + } + + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + const currentUserId = await queryCurrentUserId(operator.database); + const exisingUsers = await queryUsersById(operator.database, userIds); + const usersToLoad = userIds.filter((id) => (id !== currentUserId && !exisingUsers.find((u) => u.id === id))); + const users = await client.getProfilesByIds([...new Set(usersToLoad)]); + + if (!fetchOnly) { + await operator.handleUsers({ + users, + prepareRecordsOnly: false, + }); + } + + return {users}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +export const fetchUsersByUsernames = async (serverUrl: string, usernames: string[], fetchOnly = false) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + if (!usernames.length) { + return {users: []}; + } + + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + const currentUser = await queryCurrentUser(operator.database); + const exisingUsers = await queryUsersByUsername(operator.database, usernames); + const usersToLoad = usernames.filter((username) => (username !== currentUser?.username && !exisingUsers.find((u) => u.username === username))); + const users = await client.getProfilesByUsernames([...new Set(usersToLoad)]); + + if (!fetchOnly) { + await operator.handleUsers({ + users, + prepareRecordsOnly: false, + }); + } + + return {users}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +export const fetchMissinProfilesByIds = async (serverUrl: string, userIds: string[]) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + const {users} = await fetchUsersByIds(serverUrl, userIds); + if (users) { + const statusToLoad = users.map((u) => u.id); + fetchStatusByIds(serverUrl, statusToLoad); + } + return {users}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +export const fetchMissinProfilesByUsernames = async (serverUrl: string, usernames: string[]) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + const {users} = await fetchUsersByUsernames(serverUrl, usernames); + if (users) { + const statusToLoad = users.map((u) => u.id); + fetchStatusByIds(serverUrl, statusToLoad); + } + return {users}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index 0d0a763e5..fe8d14b36 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -22,7 +22,7 @@ export interface ClientPostsMix { markPostAsUnread: (userId: string, postId: string) => Promise; pinPost: (postId: string) => Promise; unpinPost: (postId: string) => Promise; - addReaction: (userId: string, postId: string, emojiName: string) => Promise; + 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; diff --git a/app/components/autocomplete_selector/index.tsx b/app/components/autocomplete_selector/index.tsx new file mode 100644 index 000000000..242552fdb --- /dev/null +++ b/app/components/autocomplete_selector/index.tsx @@ -0,0 +1,258 @@ +// 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 React, {ReactNode, useCallback, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Text, View} from 'react-native'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import CompasIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Preferences, Screens, View as ViewConstants} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useTheme} from '@context/theme'; +import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; +import {goToScreen} from '@screens/navigation'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {displayUsername} from '@utils/user'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PreferenceModel from '@typings/database/models/servers/preference'; +import type SystemModel from '@typings/database/models/servers/system'; + +type AutoCompleteSelectorArgs = { + config: SystemModel; + license: SystemModel; + preferences: PreferenceModel[]; +} + +type AutoCompleteSelectorProps = { + dataSource?: string; + disabled?: boolean; + errorText?: ReactNode; + getDynamicOptions?: (userInput?: string) => Promise; + helpText?: ReactNode; + label?: string; + onSelected?: (selectedItem?: PostActionOption) => Promise; + optional?: boolean; + options?: PostActionOption[]; + placeholder: string; + roundedBorders?: boolean; + selected?: PostActionOption; + showRequiredAsterisk?: boolean; + teammateNameDisplay: string; +} + +const {SERVER: {PREFERENCE, SYSTEM}} = MM_TABLES; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + const input = { + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.1), + backgroundColor: changeOpacity(theme.centerChannelBg, 0.9), + paddingLeft: 10, + paddingRight: 30, + paddingVertical: 7, + height: 40, + }; + + return { + container: { + width: '100%', + marginBottom: 2, + marginRight: 8, + marginTop: 10, + }, + roundedInput: { + ...input, + borderRadius: 5, + }, + input, + dropdownPlaceholder: { + top: 3, + marginLeft: 5, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + dropdownSelected: { + top: 3, + marginLeft: 5, + color: theme.centerChannelColor, + }, + icon: { + position: 'absolute', + top: 13, + right: 12, + }, + labelContainer: { + flexDirection: 'row', + marginTop: 15, + marginBottom: 10, + }, + label: { + fontSize: 14, + color: theme.centerChannelColor, + marginLeft: 15, + }, + optional: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 14, + marginLeft: 5, + }, + helpText: { + fontSize: 12, + color: changeOpacity(theme.centerChannelColor, 0.5), + marginHorizontal: 15, + marginVertical: 10, + }, + errorText: { + fontSize: 12, + color: theme.errorTextColor, + marginHorizontal: 15, + marginVertical: 10, + }, + asterisk: { + color: theme.errorTextColor, + fontSize: 14, + }, + disabled: { + opacity: 0.5, + }, + }; +}); + +const AutoCompleteSelector = ({ + dataSource, disabled, errorText, getDynamicOptions, helpText, label, onSelected, optional = false, + options, placeholder, roundedBorders = true, selected, showRequiredAsterisk = false, teammateNameDisplay, +}: AutoCompleteSelectorProps) => { + const intl = useIntl(); + const theme = useTheme(); + const [itemText, setItemText] = useState(selected?.text); + const style = getStyleSheet(theme); + const title = placeholder || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'}); + + const goToSelectorScreen = useCallback(preventDoubleTap(() => { + const screen = Screens.INTEGRATION_SELECTOR; + goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions}); + }), [dataSource, options, getDynamicOptions]); + + const handleSelect = useCallback((item?: any) => { + if (!item) { + return; + } + + let selectedText; + let selectedValue; + if (dataSource === ViewConstants.DATA_SOURCE_USERS) { + selectedText = displayUsername(item, undefined, teammateNameDisplay); + selectedValue = item.id; + } else if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) { + selectedText = item.display_name; + selectedValue = item.id; + } else { + selectedText = item.text; + selectedValue = item.value; + } + + setItemText(selectedText); + + if (onSelected) { + onSelected({text: selectedText, value: selectedValue}); + } + }, []); + + let text = title; + let selectedStyle = style.dropdownPlaceholder; + + if (itemText) { + text = itemText; + selectedStyle = style.dropdownSelected; + } + + let inputStyle = style.input; + if (roundedBorders) { + inputStyle = style.roundedInput; + } + + let optionalContent; + let asterisk; + if (optional) { + optionalContent = ( + + ); + } else if (showRequiredAsterisk) { + asterisk = {' *'}; + } + + let labelContent; + if (label) { + labelContent = ( + + + {label} + + {asterisk} + {optionalContent} + + ); + } + + let helpTextContent; + if (helpText) { + helpTextContent = {helpText}; + } + + let errorTextContent; + if (errorText) { + errorTextContent = {errorText}; + } + + return ( + + {labelContent} + + + + {text} + + + + + {helpTextContent} + {errorTextContent} + + ); +}; + +const withPreferences = withObservables([], ({database}: WithDatabaseArgs) => ({ + config: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(switchMap((cfg: SystemModel) => cfg.value)), + license: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE), + preferences: database.get(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe(), +})); + +const withTeammateNameDisplay = withObservables(['preferences', 'config', 'license'], ({config, license, preferences}: AutoCompleteSelectorArgs) => ({ + teammateNameDisplay: of$(getTeammateNameDisplaySetting(preferences, config.value, license.value)), +})); + +export default withDatabase(withPreferences(withTeammateNameDisplay(AutoCompleteSelector))); diff --git a/app/components/custom_status/__snapshots__/clear_button.test.tsx.snap b/app/components/custom_status/__snapshots__/clear_button.test.tsx.snap new file mode 100644 index 000000000..e5c14873a --- /dev/null +++ b/app/components/custom_status/__snapshots__/clear_button.test.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/custom_status/clear_button should match snapshot 1`] = ` + + + +`; diff --git a/app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap b/app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap new file mode 100644 index 000000000..ade473c70 --- /dev/null +++ b/app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/custom_status/custom_status_emoji should match snapshot 1`] = ` + + + + + +`; + +exports[`components/custom_status/custom_status_emoji should match snapshot with props 1`] = ` + + + + + +`; diff --git a/app/components/custom_status/__snapshots__/custom_status_text.test.tsx.snap b/app/components/custom_status/__snapshots__/custom_status_text.test.tsx.snap new file mode 100644 index 000000000..dc146df19 --- /dev/null +++ b/app/components/custom_status/__snapshots__/custom_status_text.test.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/custom_status/custom_status_text should match snapshot 1`] = ` + + In a meeting + +`; + +exports[`components/custom_status/custom_status_text should match snapshot with empty text 1`] = ` + + + +`; diff --git a/app/components/custom_status/clear_button.test.tsx b/app/components/custom_status/clear_button.test.tsx new file mode 100644 index 000000000..fd5952dd0 --- /dev/null +++ b/app/components/custom_status/clear_button.test.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import ClearButton from '@components/custom_status/clear_button'; +import Preferences from '@constants/preferences'; +import {fireEvent, render} from '@test/intl-test-helper'; + +describe('components/custom_status/clear_button', () => { + const baseProps = { + handlePress: jest.fn(), + testID: 'clear_custom_status.button', + theme: Preferences.THEMES.denim, + }; + + it('should match snapshot', () => { + const wrapper = render( + , + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should call handlePress when press event is fired', () => { + const {getByTestId} = render( + , + ); + + fireEvent.press(getByTestId(baseProps.testID)); + expect(baseProps.handlePress).toBeCalled(); + }); +}); diff --git a/app/components/custom_status/clear_button.tsx b/app/components/custom_status/clear_button.tsx new file mode 100644 index 000000000..fcdf24103 --- /dev/null +++ b/app/components/custom_status/clear_button.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {TouchableOpacity} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +interface Props { + handlePress: () => void; + size?: number; + containerSize?: number; + theme: Theme; + testID?: string; + iconName?: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + button: { + borderRadius: 1000, + color: changeOpacity(theme.centerChannelColor, 0.52), + }, + }; +}); + +const ClearButton = ({handlePress, iconName = 'close-circle', size = 20, containerSize = 40, theme, testID}: Props) => { + const style = getStyleSheet(theme); + + return ( + + + + ); +}; + +export default ClearButton; diff --git a/app/components/custom_status/custom_status_emoji.test.tsx b/app/components/custom_status/custom_status_emoji.test.tsx new file mode 100644 index 000000000..ce65dd0ce --- /dev/null +++ b/app/components/custom_status/custom_status_emoji.test.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; +import Database from '@nozbe/watermelondb/Database'; + +describe('components/custom_status/custom_status_emoji', () => { + let database: Database | undefined; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + const customStatus: UserCustomStatus = { + emoji: 'calendar', + text: 'In a meeting', + duration: '' as CustomStatusDuration.DONT_CLEAR, + }; + it('should match snapshot', () => { + const wrapper = renderWithEverything( + , + {database}, + ); + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot with props', () => { + const wrapper = renderWithEverything( + , + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/custom_status/custom_status_emoji.tsx b/app/components/custom_status/custom_status_emoji.tsx new file mode 100644 index 000000000..382d5b5b9 --- /dev/null +++ b/app/components/custom_status/custom_status_emoji.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, TextStyle} from 'react-native'; + +import Emoji from '@components/emoji'; + +interface ComponentProps { + customStatus: UserCustomStatus; + emojiSize?: number; + style?: TextStyle; + testID?: string; +} + +const CustomStatusEmoji = ({customStatus, emojiSize, style, testID}: ComponentProps) => { + const testIdPrefix = testID ? `${testID}.` : ''; + return ( + + + + ); +}; + +CustomStatusEmoji.defaultProps = { + emojiSize: 16, +}; + +export default CustomStatusEmoji; diff --git a/app/components/custom_status/custom_status_text.test.tsx b/app/components/custom_status/custom_status_text.test.tsx new file mode 100644 index 000000000..720050c92 --- /dev/null +++ b/app/components/custom_status/custom_status_text.test.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; + +import CustomStatusText from '@components/custom_status/custom_status_text'; +import Preferences from '@constants/preferences'; +import {render} from '@test/intl-test-helper'; + +describe('components/custom_status/custom_status_text', () => { + const baseProps = { + text: 'In a meeting', + theme: Preferences.THEMES.denim, + }; + + it('should match snapshot', () => { + const wrapper = render( + , + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot with empty text', () => { + const wrapper = render( + , + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/custom_status/custom_status_text.tsx b/app/components/custom_status/custom_status_text.tsx new file mode 100644 index 000000000..f3a864bcd --- /dev/null +++ b/app/components/custom_status/custom_status_text.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, TextStyle} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +interface ComponentProps { + text: string | typeof FormattedText; + theme: Theme; + textStyle?: TextStyle; + ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip'; + numberOfLines?: number; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + label: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 17, + textAlignVertical: 'center', + includeFontPadding: false, + }, + }; +}); + +const CustomStatusText = ({text, theme, textStyle, ellipsizeMode, numberOfLines}: ComponentProps) => ( + + {text} + +); + +export default CustomStatusText; diff --git a/app/components/emoji/index.tsx b/app/components/emoji/index.tsx index b824cffc1..874a6e230 100644 --- a/app/components/emoji/index.tsx +++ b/app/components/emoji/index.tsx @@ -14,7 +14,8 @@ import { View, } from 'react-native'; import FastImage, {ImageStyle} from 'react-native-fast-image'; -import {of} from 'rxjs'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {useServerUrl} from '@context/server_url'; @@ -117,7 +118,7 @@ const Emoji = (props: Props) => { return null; } return ( - + { const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null; return ( - + { }; const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({ - config: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG), + enableCustomEmoji: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((config: SystemModel) => of$(config.value.EnableCustomEmoji)), + ), })); -const withCustomEmojis = withObservables(['config', 'emojiName'], ({config, database, emojiName}: WithDatabaseArgs & {config: SystemModel; emojiName: string}) => { - const cfg: ClientConfig = config.value; - const displayTextOnly = cfg.EnableCustomEmoji !== 'true'; +const withCustomEmojis = withObservables(['enableCustomEmoji', 'emojiName'], ({enableCustomEmoji, database, emojiName}: WithDatabaseArgs & {enableCustomEmoji: string; emojiName: string}) => { + const displayTextOnly = enableCustomEmoji !== 'true'; return { - displayTextOnly: of(displayTextOnly), + displayTextOnly: of$(displayTextOnly), customEmojis: database.get(MM_TABLES.SERVER.CUSTOM_EMOJI).query(Q.where('name', emojiName)).observe(), }; }); diff --git a/app/components/formatted_date/index.tsx b/app/components/formatted_date/index.tsx new file mode 100644 index 000000000..d65c0279f --- /dev/null +++ b/app/components/formatted_date/index.tsx @@ -0,0 +1,31 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment-timezone'; +import React from 'react'; +import {Text, TextProps} from 'react-native'; + +type FormattedDateProps = TextProps & { + format: string; + timezone?: string | UserTimezone | null; + value: number | string | Date; +} + +const FormattedDate = ({format, timezone, value, ...props}: FormattedDateProps) => { + let formattedDate = moment(value).format(format); + if (timezone) { + let zone = timezone as string; + if (typeof timezone === 'object') { + zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone; + } + formattedDate = moment.tz(value, zone).format(format); + } + + return {formattedDate}; +}; + +FormattedDate.defaultProps = { + format: 'ddd, MMM DD, YYYY', +}; + +export default FormattedDate; diff --git a/app/components/formatted_markdown_text/index.tsx b/app/components/formatted_markdown_text/index.tsx new file mode 100644 index 000000000..75e89387f --- /dev/null +++ b/app/components/formatted_markdown_text/index.tsx @@ -0,0 +1,125 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Parser} from 'commonmark'; +import Renderer from 'commonmark-react-renderer'; +import React, {ReactElement, useRef} from 'react'; +import {useIntl} from 'react-intl'; +import {GestureResponderEvent, StyleProp, Text, TextStyle} from 'react-native'; + +import AtMention from '@components/markdown/at_mention'; +import MarkdownLink from '@components/markdown/markdown_link'; +import {useTheme} from '@context/theme'; +import {getMarkdownTextStyles} from '@utils/markdown'; +import {concatStyles, changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme'; + +import type {PrimitiveType} from 'intl-messageformat'; + +type Props = { + baseTextStyle: StyleProp; + defaultMessage: string; + id: string; + onPostPress?: (e: GestureResponderEvent) => void; + style?: StyleProp; + textStyles: { + [key: string]: TextStyle; + }; + values?: Record; +}; + +const TARGET_BLANK_URL_PREFIX = '!'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + message: { + color: changeOpacity(theme.centerChannelColor, 0.8), + fontSize: 15, + lineHeight: 22, + }, + atMentionOpacity: { + opacity: 1, + }, + }; +}); + +const FormattedMarkdownText = ({baseTextStyle, defaultMessage, id, onPostPress, style, textStyles, values}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const messageDescriptor = {id, defaultMessage}; + const message = intl.formatMessage(messageDescriptor, values); + const txtStyles = getMarkdownTextStyles(theme); + + const createRenderer = () => { + const renderers: any = { + text: renderText, + emph: Renderer.forwardChildren, + strong: Renderer.forwardChildren, + code: renderCodeSpan, + link: renderLink, + hardBreak: renderBreak, + softBreak: renderBreak, + paragraph: renderParagraph, + del: Renderer.forwardChildren, + html_inline: renderHTML, + html_block: renderHTML, + atMention: renderAtMention, + }; + + return new Renderer({ + renderers, + renderParagraphsInLists: true, + }); + }; + + const computeTextStyle = (base: StyleProp, context: string[]) => { + return concatStyles(base, context.map((type) => txtStyles[type])); + }; + + const renderAtMention = ({context, mentionName}: {context: string[]; mentionName: string}) => { + return ( + + ); + }; + + const renderBreak = () => { + return {'\n'}; + }; + + const renderCodeSpan = ({context, literal}: {context: string[]; literal: string}) => { + const computed = computeTextStyle([styles.message, txtStyles.code], context); + return {literal}; + }; + + const renderHTML = (props: never) => { + console.warn(`HTML used in FormattedMarkdownText component with id ${id}`); // eslint-disable-line no-console + return renderText(props); + }; + + const renderLink = ({children, href}: {children: ReactElement; href: string}) => { + const url = href[0] === TARGET_BLANK_URL_PREFIX ? href.substring(1, href.length) : href; + return {children}; + }; + + const renderParagraph = ({children}: {children: ReactElement}) => { + return {children}; + }; + + const renderText = ({context, literal}: {context: string[]; literal: string}) => { + const computed = computeTextStyle(style || styles.message, context); + return {literal}; + }; + + const parser = useRef(new Parser()).current; + const renderer = useRef(createRenderer()).current; + const ast = parser.parse(message); + + return renderer.render(ast) as ReactElement; +}; + +export default FormattedMarkdownText; diff --git a/app/components/formatted_time/index.tsx b/app/components/formatted_time/index.tsx new file mode 100644 index 000000000..0243efb77 --- /dev/null +++ b/app/components/formatted_time/index.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment-timezone'; +import React from 'react'; +import {Text, TextProps} from 'react-native'; + +type FormattedTimeProps = TextProps & { + isMilitaryTime: boolean; + timezone: UserTimezone | string; + value: number | string | Date; +} + +const FormattedTime = ({isMilitaryTime, timezone, value, ...props}: FormattedTimeProps) => { + const getFormattedTime = () => { + let format = 'H:mm'; + if (!isMilitaryTime) { + const localeFormat = moment.localeData().longDateFormat('LT'); + format = localeFormat?.includes('A') ? localeFormat : 'h:mm A'; + } + + let zone = timezone as string; + if (typeof timezone === 'object') { + zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone; + } + + return timezone ? moment.tz(value, zone).format(format) : moment(value).format(format); + }; + + const formattedTime = getFormattedTime(); + + return ( + + {formattedTime} + + ); +}; + +export default FormattedTime; diff --git a/app/components/jumbo_emoji/index.tsx b/app/components/jumbo_emoji/index.tsx index be541d7fd..bffec1be4 100644 --- a/app/components/jumbo_emoji/index.tsx +++ b/app/components/jumbo_emoji/index.tsx @@ -4,12 +4,12 @@ import {Node, Parser} from 'commonmark'; import Renderer from 'commonmark-react-renderer'; import React, {ReactElement, useRef} from 'react'; -import {Platform, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native'; +import {Platform, StyleProp, Text, TextStyle, View} from 'react-native'; import Emoji from '@components/emoji'; import FormattedText from '@components/formatted_text'; import {useTheme} from '@context/theme'; -import {blendColors, concatStyles, makeStyleSheetFromTheme} from '@utils/theme'; +import {blendColors, makeStyleSheetFromTheme} from '@utils/theme'; type JumboEmojiProps = { baseTextStyle: StyleProp; @@ -51,17 +51,15 @@ const JumboEmoji = ({baseTextStyle, isEdited, value}: JumboEmojiProps) => { const style = getStyleSheet(theme); const renderEmoji = ({emojiName, literal}: {context: string[]; emojiName: string; literal: string}) => { - const flat = StyleSheet.flatten(style.jumboEmoji); - const size = flat.lineHeight - flat.fontSize; - return ( - + + + ); }; @@ -125,7 +123,7 @@ const JumboEmoji = ({baseTextStyle, isEdited, value}: JumboEmojiProps) => { if (isEdited) { const editIndicatorNode = new Node('edited_indicator'); if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) { - ast.lastChild.appendChild(editIndicatorNode); + ast.appendChild(editIndicatorNode); } else { const node = new Node('paragraph'); node.appendChild(editIndicatorNode); diff --git a/app/components/markdown/at_mention/index.tsx b/app/components/markdown/at_mention/index.tsx index ee2ffbc58..e61ca8682 100644 --- a/app/components/markdown/at_mention/index.tsx +++ b/app/components/markdown/at_mention/index.tsx @@ -9,7 +9,7 @@ import Clipboard from '@react-native-community/clipboard'; import React, {useCallback, useMemo} from 'react'; import {useIntl} from 'react-intl'; import {DeviceEventEmitter, GestureResponderEvent, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native'; -import {of} from 'rxjs'; +import {of as of$} from 'rxjs'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import {useTheme} from '@context/theme'; @@ -29,21 +29,23 @@ import type SystemModel from '@typings/database/models/servers/system'; import type UserModelType from '@typings/database/models/servers/user'; type AtMentionProps = { - currentUserId: SystemModel; + currentUserId: string; database: Database; + disableAtChannelMentionHighlight?: boolean; groups: GroupModel[]; isSearchResult?: boolean; - mentionKeys: Array<{key: string }>; + mentionKeys?: Array<{key: string }>; mentionName: string; mentionStyle: TextStyle; myGroups: GroupMembershipModel[]; onPostPress?: (e: GestureResponderEvent) => void; teammateNameDisplay: string; - textStyle: StyleProp; + textStyle?: StyleProp; users: UserModelType[]; } type AtMentionArgs = { + currentUserId: SystemModel; config: SystemModel; license: SystemModel; preferences: PreferenceModel[]; @@ -61,6 +63,7 @@ const style = StyleSheet.create({ const AtMention = ({ currentUserId, database, + disableAtChannelMentionHighlight, groups, isSearchResult, mentionName, @@ -96,15 +99,15 @@ const AtMention = ({ return new UserModel(database.get(USER), {username: ''}); }, [users, mentionName]); const userMentionKeys = useMemo(() => { - if (user.id !== currentUserId.value) { - return []; - } - if (mentionKeys) { return mentionKeys; } + + if (user.id !== currentUserId) { + return []; + } return getUserMentionKeys(user, groups, myGroups); - }, [currentUserId.value, groups, mentionKeys, myGroups, user]); + }, [currentUserId, groups, mentionKeys, myGroups, user]); const getGroupFromMentionName = () => { const mentionNameTrimmed = mentionName.toLowerCase().replace(/[._-]*$/, ''); @@ -210,7 +213,7 @@ const AtMention = ({ const pattern = new RegExp(/\b(all|channel|here)(?:\.\B|_\b|\b)/, 'i'); const mentionMatch = pattern.exec(mentionName); - if (mentionMatch) { + if (mentionMatch && !disableAtChannelMentionHighlight) { mention = mentionMatch.length > 1 ? mentionMatch[1] : mentionMatch[0]; suffix = mentionName.replace(mention, ''); isMention = true; @@ -264,24 +267,27 @@ const withPreferences = withObservables([], ({database}: WithDatabaseArgs) => ({ preferences: database.get(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe(), })); -const withAtMention = withObservables(['mentionName', 'preferences', 'config', 'license'], ({database, mentionName, preferences, config, license}: WithDatabaseArgs & AtMentionArgs) => { - let mn = mentionName.toLowerCase(); - if ((/[._-]$/).test(mn)) { - mn = mn.substring(0, mn.length - 1); - } +const withAtMention = withObservables( + ['currentUserId', 'mentionName', 'preferences', 'config', 'license'], + ({currentUserId, database, mentionName, preferences, config, license}: WithDatabaseArgs & AtMentionArgs) => { + let mn = mentionName.toLowerCase(); + if ((/[._-]$/).test(mn)) { + mn = mn.substring(0, mn.length - 1); + } - const teammateNameDisplay = of(getTeammateNameDisplaySetting(preferences, config.value, license.value)); + const teammateNameDisplay = of$(getTeammateNameDisplaySetting(preferences, config.value, license.value)); - return { - groups: database.get(GROUP).query(Q.where('delete_at', Q.eq(0))).observe(), - myGroups: database.get(GROUP_MEMBERSHIP).query().observe(), - teammateNameDisplay, - users: database.get(USER).query( - Q.where('username', Q.like( - `%${Q.sanitizeLikeString(mn)}%`, - )), - ).observeWithColumns(['username']), - }; -}); + return { + currentUserId: of$(currentUserId.value), + groups: database.get(GROUP).query(Q.where('delete_at', Q.eq(0))).observe(), + myGroups: database.get(GROUP_MEMBERSHIP).query().observe(), + teammateNameDisplay, + users: database.get(USER).query( + Q.where('username', Q.like( + `%${Q.sanitizeLikeString(mn)}%`, + )), + ).observeWithColumns(['username']), + }; + }); -export default withDatabase(withPreferences(withAtMention(AtMention))); +export default withDatabase(withPreferences(withAtMention(React.memo(AtMention)))); diff --git a/app/components/markdown/index.tsx b/app/components/markdown/index.tsx index 50c0042d6..5a956d6e9 100644 --- a/app/components/markdown/index.tsx +++ b/app/components/markdown/index.tsx @@ -4,7 +4,7 @@ import {Parser, Node} from 'commonmark'; import Renderer from 'commonmark-react-renderer'; import React, {PureComponent, ReactElement} from 'react'; -import {GestureResponderEvent, Platform, StyleProp, StyleSheet, Text, TextStyle, View, ViewStyle} from 'react-native'; +import {GestureResponderEvent, Platform, StyleProp, Text, TextStyle, View} from 'react-native'; import Emoji from '@components/emoji'; import FormattedText from '@components/formatted_text'; @@ -26,16 +26,15 @@ import MarkdownTableRow, {MarkdownTableRowProps} from './markdown_table_row'; import MarkdownTableCell, {MarkdownTableCellProps} from './markdown_table_cell'; import {addListItemIndices, combineTextNodes, highlightMentions, pullOutImages} from './transform'; +import type {MarkdownBlockStyles, MarkdownTextStyles, UserMentionKey} from '@typings/global/markdown'; + type MarkdownProps = { autolinkedUrlSchemes?: string[]; baseTextStyle: StyleProp; - blockStyles: { - adjacentParagraph: ViewStyle; - horizontalRule: ViewStyle; - quoteBlockIcon: TextStyle; - }; + blockStyles: MarkdownBlockStyles; channelMentions?: ChannelMentions; disableAtMentions?: boolean; + disableAtChannelMentionHighlight?: boolean; disableChannelLink?: boolean; disableGallery?: boolean; disableHashtags?: boolean; @@ -47,9 +46,7 @@ type MarkdownProps = { minimumHashtagLength?: number; onPostPress?: (event: GestureResponderEvent) => void; postId?: string; - textStyles: { - [key: string]: TextStyle; - }; + textStyles: MarkdownTextStyles; theme: Theme; value: string | number; } @@ -60,11 +57,11 @@ class Markdown extends PureComponent { blockStyles: {}, disableHashtags: false, disableAtMentions: false, + disableAtChannelMentionHighlight: false, disableChannelLink: false, disableGallery: false, value: '', minimumHashtagLength: 3, - mentionKeys: [], }; private parser: Parser; @@ -223,12 +220,13 @@ class Markdown extends PureComponent { return ( ); }; @@ -249,25 +247,12 @@ class Markdown extends PureComponent { }; renderEmoji = ({context, emojiName, literal}: {context: string[]; emojiName: string; literal: string}) => { - let customEmojiStyle; - - if (Platform.OS === 'android') { - const flat = StyleSheet.flatten(this.props.baseTextStyle); - - if (flat) { - const size = Math.abs(((flat.lineHeight || 0) - (flat.fontSize || 0))) / 2; - if (size > 0) { - customEmojiStyle = {marginRight: size, top: size}; - } - } - } return ( ); }; @@ -469,7 +454,7 @@ class Markdown extends PureComponent { if (this.props.isEdited) { const editIndicatorNode = new Node('edited_indicator'); if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) { - ast.lastChild.appendChild(editIndicatorNode); + ast.appendChild(editIndicatorNode); } else { const node = new Node('paragraph'); node.appendChild(editIndicatorNode); diff --git a/app/components/markdown/markdown_image/index.tsx b/app/components/markdown/markdown_image/index.tsx index f75c6e9f4..b8ac1aed7 100644 --- a/app/components/markdown/markdown_image/index.tsx +++ b/app/components/markdown/markdown_image/index.tsx @@ -157,7 +157,7 @@ const MarkdownImage = ({ if (failed) { return ( ); @@ -205,6 +205,7 @@ const MarkdownImage = ({ {image} diff --git a/app/components/markdown/markdown_link/index.tsx b/app/components/markdown/markdown_link/index.tsx index 144419a1a..79842ca33 100644 --- a/app/components/markdown/markdown_link/index.tsx +++ b/app/components/markdown/markdown_link/index.tsx @@ -8,7 +8,7 @@ import withObservables from '@nozbe/with-observables'; import React, {Children, ReactElement, useCallback} from 'react'; import {useIntl} from 'react-intl'; import {Alert, DeviceEventEmitter, StyleSheet, Text, View} from 'react-native'; -import {of} from 'rxjs'; +import {of as of$} from 'rxjs'; import urlParse from 'url-parse'; import {switchToChannelByName} from '@actions/local/channel'; @@ -176,8 +176,8 @@ const withConfigValues = withObservables(['config'], ({config}: {config: SystemM const cfg: ClientConfig = config.value; return { - experimentalNormalizeMarkdownLinks: of(cfg.ExperimentalNormalizeMarkdownLinks), - siteURL: of(cfg.SiteURL), + experimentalNormalizeMarkdownLinks: of$(cfg.ExperimentalNormalizeMarkdownLinks), + siteURL: of$(cfg.SiteURL), }; }); diff --git a/app/components/markdown/markdown_table_image/index.tsx b/app/components/markdown/markdown_table_image/index.tsx index 3ce4a66cd..f90ddd453 100644 --- a/app/components/markdown/markdown_table_image/index.tsx +++ b/app/components/markdown/markdown_table_image/index.tsx @@ -89,7 +89,7 @@ const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: M if (failed) { image = ( ); diff --git a/app/components/markdown/transform.test.ts b/app/components/markdown/transform.test.ts new file mode 100644 index 000000000..ab487325a --- /dev/null +++ b/app/components/markdown/transform.test.ts @@ -0,0 +1,3011 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; + +import {Node, Parser} from 'commonmark'; + +import { + addListItemIndices, + combineTextNodes, + getFirstMention, + highlightMentions, + highlightTextNode, + pullOutImages, +} from '@components/markdown/transform'; + +/* eslint-disable max-lines, no-console, no-underscore-dangle */ + +describe('Components.Markdown.transform', () => { + const parser = new Parser(); + + describe('combineTextNodes', () => { + const tests = [{ + name: 'no text nodes', + input: { + type: 'document', + children: [{ + type: 'thematic_break', + }], + }, + expected: { + type: 'document', + children: [{ + type: 'thematic_break', + }], + }, + }, { + name: 'one text node', + input: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a sentence', + }], + }], + }, + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a sentence', + }], + }], + }, + }, { + name: 'multiple text nodes', + input: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This ', + }, { + type: 'text', + literal: 'is a', + }, { + type: 'text', + literal: ' sen', + }, { + type: 'text', + literal: 'tence', + }], + }], + }, + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a sentence', + }], + }], + }, + }, { + name: 'mixed formatting', + input: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This ', + }, { + type: 'emph', + children: [{ + type: 'text', + literal: 'is a', + }], + }, { + type: 'text', + literal: ' sen', + }, { + type: 'text', + literal: 'tence', + }], + }], + }, + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This ', + }, { + type: 'emph', + children: [{ + type: 'text', + literal: 'is a', + }], + }, { + type: 'text', + literal: ' sentence', + }], + }], + }, + }, { + name: 'multiple paragraphs', + input: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a ', + }, { + type: 'text', + literal: 'paragraph', + }], + }, { + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is', + }, { + type: 'text', + literal: ' another ', + }, { + type: 'text', + literal: 'paragraph', + }], + }], + }, + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a paragraph', + }], + }, { + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is another paragraph', + }], + }], + }, + }, { + name: 'MM-12880 merging text followed by non-text', + input: 'test: *italics*', + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'test: ', + }, { + type: 'emph', + children: [{ + type: 'text', + literal: 'italics', + }], + }], + }], + }, + }, { + name: 'MM-12880 merging text followed by image', + input: 'test: ![image](https://example.com/image)', + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'test: ', + }, { + type: 'image', + destination: 'https://example.com/image', + title: '', + children: [{ + type: 'text', + literal: 'image', + }], + }], + }], + }, + }]; + + for (const test of tests) { + it(test.name, () => { + const input = typeof test.input === 'string' ? parser.parse(test.input) : makeAst(test.input); + const expected = makeAst(test.expected); + const actual = combineTextNodes(input); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(astToString(actual), astToString(expected)); + assert.deepStrictEqual(stripUnusedFields(actual), stripUnusedFields(expected)); + }); + } + }); + + describe('addListItemIndices', () => { + it('unordered list', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'bullet', + listTight: true, + children: [{ + type: 'item', + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'one', + }], + }], + }, { + type: 'item', + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'two', + }], + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'bullet', + listTight: true, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'one', + }], + }], + }, { + type: 'item', + index: 2, + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'two', + }], + }], + }], + }], + }); + const actual = addListItemIndices(input); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(actual, expected); + }); + + it('ordered list', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'bullet', + listTight: true, + listStart: 7, + listDelimiter: 'period', + children: [{ + type: 'item', + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'one', + }], + }], + }, { + type: 'item', + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'two', + }], + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'bullet', + listTight: true, + listStart: 7, + listDelimiter: 'period', + children: [{ + type: 'item', + index: 7, + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'one', + }], + }], + }, { + type: 'item', + index: 8, + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'two', + }], + }], + }], + }], + }); + const actual = addListItemIndices(input); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(actual, expected); + }); + + it('nested lists', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'bullet', + listTight: true, + listStart: 7, + listDelimiter: 'period', + children: [{ + type: 'item', + children: [{ + type: 'list', + listType: 'bullet', + listTight: true, + listStart: 3, + listDelimiter: 'period', + children: [{ + type: 'item', + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'one', + }], + }], + }, { + type: 'item', + children: [{ + type: 'list', + listTight: true, + children: [{ + type: 'item', + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'one', + }], + }], + }, { + type: 'item', + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'two', + }], + }], + }], + }], + }], + }], + }, { + type: 'item', + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'two', + }], + }], + }], + }, { + type: 'list', + listTight: true, + children: [{ + type: 'item', + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'one', + }], + }], + }, { + type: 'item', + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'two', + }], + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'bullet', + listTight: true, + listStart: 7, + listDelimiter: 'period', + children: [{ + type: 'item', + index: 7, + children: [{ + type: 'list', + listType: 'bullet', + listTight: true, + listStart: 3, + listDelimiter: 'period', + children: [{ + type: 'item', + index: 3, + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'one', + }], + }], + }, { + type: 'item', + index: 4, + children: [{ + type: 'list', + listTight: true, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'one', + }], + }], + }, { + type: 'item', + index: 2, + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'two', + }], + }], + }], + }], + }], + }], + }, { + type: 'item', + index: 8, + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'two', + }], + }], + }], + }, { + type: 'list', + listTight: true, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'one', + }], + }], + }, { + type: 'item', + index: 2, + children: [{ + type: 'pargraph', + children: [{ + type: 'text', + literal: 'two', + }], + }], + }], + }], + }); + const actual = addListItemIndices(input); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(actual, expected); + }); + }); + + describe('pullOutImages', () => { + it('simple example with no images', () => { + const input = parser.parse('test'); + const expected = parser.parse('test'); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('complex example with no images', () => { + const inputString = '- abc\n 1. def\n\n 2. ghi\n\n3. jkl\n- mno\n 1. pqr\n---\n# vwx\n\nyz'; + const input = parser.parse(inputString); + const expected = parser.parse(inputString); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('paragraph', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('paragraph with surrounding text', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text with ', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: ' in it', + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text with ', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: ' in it', + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('paragraph with multiple images', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'image', + destination: 'http://example.com/image2', + children: [{ + type: 'text', + literal: 'another image', + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'image', + destination: 'http://example.com/image2', + children: [{ + type: 'text', + literal: 'another image', + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('headings', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'heading', + level: 1, + children: [{ + type: 'text', + literal: 'This is the start 1', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image 1', + }], + }], + }, { + type: 'heading', + level: 4, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image 2', + }], + }, { + type: 'text', + literal: 'This is the end 2', + }], + }, { + type: 'heading', + level: 2, + children: [{ + type: 'text', + literal: 'This is the start 3', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image 3', + }], + }, { + type: 'text', + literal: 'This is the end 3', + }], + }, { + type: 'heading', + level: 3, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image 4a', + }], + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image 4b', + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'heading', + level: 1, + children: [{ + type: 'text', + literal: 'This is the start 1', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image 1', + }], + }], + }, { + type: 'heading', + level: 4, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image 2', + }], + }, { + type: 'text', + literal: 'This is the end 2', + }], + }, { + type: 'heading', + level: 2, + children: [{ + type: 'text', + literal: 'This is the start 3', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image 3', + }], + }, { + type: 'text', + literal: 'This is the end 3', + }], + }, { + type: 'heading', + level: 3, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image 4a', + }], + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image 4b', + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('block quote', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'block_quote', + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'block_quote', + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('block quote with other text', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'block_quote', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: ' in a sentence', + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'block_quote', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: ' in a sentence', + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('unordered list', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'bullet', + listTight: true, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 2, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }, { + type: 'item', + index: 3, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is moretext', + }], + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'bullet', + listTight: true, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 2, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }, { + type: 'item', + index: 3, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is moretext', + }], + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('ordered list', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'ordered', + listTight: false, + children: [{ + type: 'item', + index: 7, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 8, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'ordered', + listTight: false, + children: [{ + type: 'item', + index: 7, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 8, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('complicated list', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'ordered', + listTight: false, + children: [{ + type: 'item', + index: 7, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 8, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 9, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }, { + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 10, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }, { + type: 'item', + index: 11, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'ordered', + listTight: false, + children: [{ + type: 'item', + index: 7, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 8, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 9, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }, { + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 10, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }, { + type: 'item', + index: 11, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }], + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('nested lists', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'ordered', + listTight: true, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'list', + listType: 'bulleted', + listTight: false, + children: [{ + type: 'item', + index: 3, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }, { + type: 'item', + index: 4, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: 'This is text', + }], + }], + }], + }], + }, { + type: 'item', + index: 2, + children: [{ + type: 'list', + listType: 'ordered', + listTight: true, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 2, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }], + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'ordered', + listTight: true, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'list', + listType: 'bulleted', + listTight: false, + children: [{ + type: 'item', + index: 3, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }, { + type: 'item', + index: 4, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: 'This is text', + }], + }], + }], + }], + }, { + type: 'item', + index: 2, + children: [{ + type: 'list', + listType: 'ordered', + listTight: true, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: 'This is text', + }], + }], + }, { + type: 'item', + index: 2, + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is text', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }], + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('complex example with images', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'bulleted', + listTight: true, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/abc', + children: [{ + type: 'text', + literal: 'abc', + }], + }], + }, { + type: 'list', + listType: 'numbered', + listTight: false, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/def', + children: [{ + type: 'text', + literal: 'def', + }], + }], + }], + }, { + type: 'item', + index: 2, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/ghi', + children: [{ + type: 'text', + literal: 'ghi', + }], + }], + }], + }], + }], + + }], + }, { + type: 'list', + listType: 'ordered', + listTight: true, + children: [{ + type: 'item', + index: 3, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/jkl', + children: [{ + type: 'text', + literal: 'jkl', + }], + }], + }], + }], + }, { + type: 'block_quote', + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/mno', + children: [{ + type: 'text', + literal: 'mno', + }], + }, { + type: 'softbreak', + }, { + type: 'image', + destination: 'http://example.com/pqr', + children: [{ + type: 'text', + literal: 'pqr', + }], + }], + }], + }, { + type: 'thematic_break', + }, { + type: 'heading', + level: 1, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'vw', + }], + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'x', + }], + }], + }, { + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'yz', + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'list', + listType: 'bulleted', + listTight: true, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/abc', + children: [{ + type: 'text', + literal: 'abc', + }], + }], + }, { + type: 'list', + listType: 'numbered', + listTight: false, + children: [{ + type: 'item', + index: 1, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/def', + children: [{ + type: 'text', + literal: 'def', + }], + }], + }], + }, { + type: 'item', + index: 2, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/ghi', + children: [{ + type: 'text', + literal: 'ghi', + }], + }], + }], + }], + }], + + }], + }, { + type: 'list', + listType: 'ordered', + listTight: true, + children: [{ + type: 'item', + index: 3, + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/jkl', + children: [{ + type: 'text', + literal: 'jkl', + }], + }], + }], + }], + }, { + type: 'block_quote', + children: [{ + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/mno', + children: [{ + type: 'text', + literal: 'mno', + }], + }, { + type: 'softbreak', + }, { + type: 'image', + destination: 'http://example.com/pqr', + children: [{ + type: 'text', + literal: 'pqr', + }], + }], + }], + }, { + type: 'thematic_break', + }, { + type: 'heading', + level: 1, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'vw', + }], + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'x', + }], + }], + }, { + type: 'paragraph', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'yz', + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('simple link', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'link', + destination: 'http://example.com', + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'link', + destination: 'http://example.com', + children: [{ + type: 'image', + destination: 'http://example.com/image', + linkDestination: 'http://example.com', + children: [{ + type: 'text', + literal: 'an image', + }], + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('link in sentence', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'link', + destination: 'http://example.com', + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: ' in a sentence.', + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'link', + destination: 'http://example.com', + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'image', + destination: 'http://example.com/image', + linkDestination: 'http://example.com', + children: [{ + type: 'text', + literal: 'an image', + }], + }, { + type: 'text', + literal: ' in a sentence.', + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('table', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'table', + children: [{ + type: 'table_row', + isHeading: true, + children: [{ + type: 'table_cell', + isHeading: true, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1', + }], + }], + }, { + type: 'table_cell', + isHeading: true, + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1', + }], + }, { + type: 'text', + literal: ' in a sentence.', + }], + }], + }, { + type: 'table_row', + isHeading: true, + children: [{ + type: 'table_cell', + isHeading: true, + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1', + }], + }, { + type: 'text', + literal: ' in a sentence.', + }], + }, { + type: 'table_cell', + isHeading: true, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1', + }], + }], + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'table', + children: [{ + type: 'table_row', + isHeading: true, + children: [{ + type: 'table_cell', + isHeading: true, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1', + }], + }], + }, { + type: 'table_cell', + isHeading: true, + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1', + }], + }, { + type: 'text', + literal: ' in a sentence.', + }], + }], + }, { + type: 'table_row', + isHeading: true, + children: [{ + type: 'table_cell', + isHeading: true, + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1', + }], + }, { + type: 'text', + literal: ' in a sentence.', + }], + }, { + type: 'table_cell', + isHeading: true, + children: [{ + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'image1', + }], + }], + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + + it('adjacent images and text', () => { + const input = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'First:', + }, { + type: 'softbreak', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'Image', + }], + }, { + type: 'softbreak', + }, { + type: 'text', + literal: 'Second:', + }, { + type: 'softbreak', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'Image', + }], + }], + }], + }); + const expected = makeAst({ + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'First:', + }, { + type: 'softbreak', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'Image', + }], + }, { + type: 'softbreak', + }, { + type: 'text', + literal: 'Second:', + }, { + type: 'softbreak', + }, { + type: 'image', + destination: 'http://example.com/image', + children: [{ + type: 'text', + literal: 'Image', + }], + }], + }], + }); + const actual = pullOutImages(input); + + assert.ok(verifyAst(actual)); + assert.equal(astToString(actual), astToString(expected)); + assert.deepStrictEqual(actual, expected); + }); + }); + + describe('highlightMentions', () => { + const tests = [{ + name: 'no mentions', + input: 'These are words', + mentionKeys: [], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are words', + }], + }], + }, + }, { + name: 'not an at-mention', + input: 'These are words', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }], + }], + }, + }, { + name: 'at-mention for another user', + input: 'This is @user', + mentionKeys: [{key: '@words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'at_mention', + _mentionName: 'user', + children: [{ + type: 'text', + literal: '@user', + }], + }], + }], + }, + }, { + name: 'at-mention', + input: 'These are @words', + mentionKeys: [{key: '@words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'at_mention', + _mentionName: 'words', + children: [{ + type: 'text', + literal: '@words', + }], + }], + }], + }], + }, + }, { + name: 'at-mention and non-at-mention for same word', + input: 'These are @words', + mentionKeys: [{key: 'words'}, {key: '@words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'at_mention', + _mentionName: 'words', + children: [{ + type: 'text', + literal: '@words', + }], + }], + }], + }], + }, + }, { + name: 'case insensitive mentions', + input: 'These are Words and wORDS', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'Words', + }], + }, { + type: 'text', + literal: ' and ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'wORDS', + }], + }], + }], + }, + }, { + name: 'case sesitive mentions', + input: 'These are Words and wORDS', + mentionKeys: [{key: 'Words', caseSensitive: true}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'Words', + }], + }, { + type: 'text', + literal: ' and wORDS', + }], + }], + }, + }, { + name: 'bold', + input: 'These are **words** in a sentence', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'strong', + children: [{ + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }], + }, { + type: 'text', + literal: ' in a sentence', + }], + }], + }, + }, { + name: 'italics', + input: 'These _are Words in_ a sentence', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These ', + }, { + type: 'emph', + children: [{ + type: 'text', + literal: 'are ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'Words', + }], + }, { + type: 'text', + literal: ' in', + }], + }, { + type: 'text', + literal: ' a sentence', + }], + }], + }, + }, { + name: 'code span', + input: 'These are `words`', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'code', + literal: 'words', + }], + }], + }, + }, { + name: 'code block', + input: '```\nThese are\nwords\n```', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'code_block', + literal: 'These are\nwords\n', + }], + }, + }, { + name: 'link text', + input: 'These are [words words](https://example.com)', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'These are ', + }, { + type: 'link', + destination: 'https://example.com', + title: '', + children: [{ + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: ' ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }], + }], + }], + }, + }, { + name: 'link url', + input: 'This is [a link](https://example.com/words)', + mentionKeys: [{key: 'example'}, {key: 'com'}, {key: 'https'}, {key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is ', + }, { + type: 'link', + destination: 'https://example.com/words', + title: '', + children: [{ + type: 'text', + literal: 'a link', + }], + }], + }], + }, + }, { + name: 'autolinked url', + input: 'https://example.com/words', + mentionKeys: [{key: 'example'}, {key: 'com'}, {key: 'https'}, {key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'link', + destination: 'https://example.com/words', + title: '', + children: [{ + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'https', + }], + }, { + type: 'text', + literal: '://', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'example', + }], + }, { + type: 'text', + literal: '.', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'com', + }], + }, { + type: 'text', + literal: '/', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }], + }], + }], + }, + }, { + name: 'words with punctuation', + input: 'words. (words) words/words/words words:words', + mentionKeys: [{key: 'words'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: '. (', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: ') ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: '/', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: '/', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: ' ', + }, { + type: 'link', + destination: 'words:words', + title: '', + children: [{ + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }, { + type: 'text', + literal: ':', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: 'words', + }], + }], + }], + }], + }, + }, { + name: 'multibyte keyword', + input: '我爱吃番茄炒饭', + mentionKeys: [{key: '番茄'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: '我爱吃', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: '番茄', + }], + }, { + type: 'text', + literal: '炒饭', + }], + }], + }, + }, { + name: 'multiple multibyte keywords', + input: 'CJK is 中國日本한국.', + mentionKeys: [{key: '中國'}, {key: '日本'}, {key: '한국'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'CJK is ', + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: '中國', + }], + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: '日本', + }], + }, { + type: 'mention_highlight', + children: [{ + type: 'text', + literal: '한국', + }], + }, { + type: 'text', + literal: '.', + }], + }], + }, + }, { + name: 'Mention followed by a period', + input: 'This is a mention for @channel.', + mentionKeys: [{key: 'channel'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a mention for ', + }, { + type: 'mention_highlight', + children: [{ + type: 'at_mention', + _mentionName: 'channel.', + children: [{ + type: 'text', + literal: '@channel.', + }], + }], + }], + }], + }, + }, { + name: 'Do not mention partial keys', + input: 'This is a mention for @Gvn.', + mentionKeys: [{key: 'gv'}], + expected: { + type: 'document', + children: [{ + type: 'paragraph', + children: [{ + type: 'text', + literal: 'This is a mention for ', + }, { + type: 'at_mention', + _mentionName: 'Gvn.', + children: [{ + type: 'text', + literal: '@Gvn.', + }], + }], + }], + }, + }]; + + for (const test of tests) { + it(test.name, () => { + const input = combineTextNodes(parser.parse(test.input)); + const expected = makeAst(test.expected); + const actual = highlightMentions(input, test.mentionKeys); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(stripUnusedFields(actual), stripUnusedFields(expected)); + }); + } + }); + + describe('getFirstMention', () => { + const tests = [{ + name: 'no mention keys', + input: 'apple banana orange', + mentionKeys: [], + expected: {index: -1, mention: null}, + }, { + name: 'single mention', + input: 'apple banana orange', + mentionKeys: [{key: 'banana'}], + expected: {index: 6, mention: {key: 'banana'}}, + }, { + name: 'multiple mentions', + input: 'apple banana orange', + mentionKeys: [{key: 'apple'}, {key: 'orange'}], + expected: {index: 0, mention: {key: 'apple'}}, + }, { + name: 'case sensitive', + input: 'apple APPLE Apple aPPle', + mentionKeys: [{key: 'Apple', caseSensitive: true}], + expected: {index: 12, mention: {key: 'Apple', caseSensitive: true}}, + }, { + name: 'followed by period', + input: 'banana.', + mentionKeys: [{key: 'banana'}], + expected: {index: 0, mention: {key: 'banana'}}, + }, { + name: 'followed by underscores', + input: 'banana__', + mentionKeys: [{key: 'banana'}], + expected: {index: 0, mention: {key: 'banana'}}, + }, { + name: 'in brackets', + input: '(banana)', + mentionKeys: [{key: 'banana'}], + expected: {index: 1, mention: {key: 'banana'}}, + }, { + name: 'following punctuation', + input: ':banana', + mentionKeys: [{key: 'banana'}], + expected: {index: 1, mention: {key: 'banana'}}, + }, { + name: 'not part of another word', + input: 'pineapple', + mentionKeys: [{key: 'apple'}], + expected: {index: -1, mention: null}, + }, { + name: 'no error from weird mention keys', + input: 'apple banana orange', + mentionKeys: [{key: '*\\3_.'}], + expected: {index: -1, mention: null}, + }, { + name: 'no blank mention keys', + input: 'apple banana orange', + mentionKeys: [{key: ''}], + expected: {index: -1, mention: null}, + }, { + name: 'multibyte key', + input: '좋은 하루 되세요.', + mentionKeys: [{key: '하루'}], + expected: {index: 3, mention: {key: '하루'}}, + }]; + + for (const test of tests) { + it(test.name, () => { + const actual = getFirstMention(test.input, test.mentionKeys); + + assert.deepStrictEqual(actual, test.expected); + }); + } + }); + + describe('highlightTextNode', () => { + const type = 'paragraph'; + const literal = 'This is a sentence'; + + const tests = [{ + name: 'highlight entire text', + start: 0, + end: literal.length, + expected: [{ + type, + children: [{ + type: 'text', + literal, + }], + }], + }, { + name: 'highlight start of text', + start: 0, + end: 6, + expected: [{ + type, + children: [{ + type: 'text', + literal: 'This i', + }], + }, { + type: 'text', + literal: 's a sentence', + }], + }, { + name: 'highlight end of text', + start: 8, + end: literal.length, + expected: [{ + type: 'text', + literal: 'This is ', + }, { + type, + children: [{ + type: 'text', + literal: 'a sentence', + }], + }], + }, { + name: 'highlight middle of text', + start: 5, + end: 12, + expected: [{ + type: 'text', + literal: 'This ', + }, { + type, + children: [{ + type: 'text', + literal: 'is a se', + }], + }, { + type: 'text', + literal: 'ntence', + }], + }]; + + for (const test of tests) { + it(test.name + ', without siblings', () => { + const actual = makeAst({ + type: 'parent', + children: [{ + type: 'text', + literal, + }], + }); + const expected = makeAst({ + type: 'parent', + children: test.expected, + }); + + const node = actual.firstChild; + assert.equal(node!.type, 'text'); + + const highlighted = highlightTextNode(node!, test.start, test.end, type); + assert.equal(highlighted.type, type); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(actual, expected); + }); + + it(test.name + ', with siblings', () => { + const actual = makeAst({ + type: 'parent', + children: [{ + type: 'previous', + }, { + type: 'text', + literal, + }, { + type: 'next', + }], + }); + const expected = makeAst({ + type: 'parent', + children: [ + { + type: 'previous', + }, + ...test.expected, + { + type: 'next', + }, + ], + }); + + const node = actual.firstChild!.next; + assert.equal(node!.type, 'text'); + + const highlighted = highlightTextNode(node!, test.start, test.end, type); + assert.equal(highlighted.type, type); + + assert.ok(verifyAst(actual)); + assert.deepStrictEqual(actual, expected); + }); + } + }); +}); + +// Testing and debugging functions + +// Confirms that all parent, child, and sibling linkages are correct and go both ways. +function verifyAst(node: Node) { + if (node.prev && node.prev.next !== node) { + console.error('node is not linked properly to prev'); //eslint-disable-line no-console + return false; + } + + if (node.next && node.next.prev !== node) { + console.error('node is not linked properly to next'); //eslint-disable-line no-console + return false; + } + + if (!node.firstChild && node.lastChild) { + console.error('node has children, but is not linked to first child'); //eslint-disable-line no-console + return false; + } + + if (node.firstChild && !node.lastChild) { + console.error('node has children, but is not linked to last child'); //eslint-disable-line no-console + return false; + } + + for (let child = node.firstChild; child; child = child.next) { + if (child.parent !== node) { + console.error('node is not linked properly to child'); //eslint-disable-line no-console + return false; + } + + if (!verifyAst(child)) { + return false; + } + + if (!child.next && child !== node.lastChild) { + console.error('node children are not linked correctly'); //eslint-disable-line no-console + return false; + } + } + + if (node.firstChild && node.firstChild.prev) { + console.error('node\'s first child has previous sibling'); //eslint-disable-line no-console + return false; + } + + if (node.lastChild && node.lastChild.next) { + console.error('node\'s last child has next sibling'); //eslint-disable-line no-console + return false; + } + + return true; +} + +function astToString(node: Node, indent = '') { // eslint-disable-line no-unused-vars + if (!node) { + return ''; + } + + let out = ''; + + out += indent + nodeToString(node) + '\n'; + + for (let child = node.firstChild; child !== null; child = child.next) { + out += astToString(child, indent + ' '); + } + + return out; +} + +const neighbours = ['parent', 'prev', 'next', 'firstChild', 'lastChild']; +const importantFields = ['literal', 'destination', 'title', 'level', 'listType', 'listTight', 'listDelimiter', 'mentionName', 'channelName', 'emojiName', 'continue', 'index']; +function nodeToString(node: any) { + let out = node.type; + + for (const neighbour of neighbours) { + if (node[neighbour]) { + out += ' ' + neighbour + '=`' + node[neighbour].type; + if (node[neighbour].type === 'text') { + out += ':' + node[neighbour].literal; + } + out += '`'; + } + } + + for (const field of importantFields) { + if (node[field]) { + out += ' ' + field + '=`' + node[field] + '`'; + } + } + + return out; +} + +const ignoredKeys = {_sourcepos: true, _lastLineBlank: true, _open: true, _string_content: true, _info: true, _isFenced: true, _fenceChar: true, _fenceLength: true, _fenceOffset: true, _onEnter: true, _onExit: true}; +function astToJson(node: any, visited = [], indent = '') { + let out = '{'; + + const myVisited: any[] = [...visited]; + myVisited.push(node); + + // @ts-expect-error keys + const keys = Object.keys(node).filter((key) => !ignoredKeys[key]); + if (keys.length > 0) { + out += '\n'; + } + + for (const [i, key] of keys.entries()) { + out += indent + ' "' + key + '":'; + + const value = node[key]; + if (myVisited.indexOf(value) !== -1) { + out += '[Circular]'; + } else if (value === null) { + out += 'null'; + } else if (typeof value === 'number') { + out += value; + } else if (typeof value === 'string') { + out += '"' + value + '"'; + } else if (typeof value === 'boolean') { + out += String(value); + } else if (typeof value === 'object') { + out += astToJson(value, myVisited as never[], indent + ' '); // eslint-disable-line @typescript-eslint/no-unused-vars + } + + if (i !== keys.length - 1) { + out += ',\n'; + } + } + + if (keys.length > 0) { + out += '\n' + indent; + } + + out += '}'; + + return out; +} + +// Converts an AST represented as a JavaScript object into a full Commonmark-compatitle AST. +// This function is intended for use while testing. An example of input would be: +// { +// type: 'document', +// children: [{ +// type: 'heading', +// level: 2, +// children: [{ +// type: 'text', +// literal: 'This is a heading' +// }] +// }, { +// type: 'paragraph', +// children: [{ +// type: 'text', +// literal: 'This is a paragraph' +// }] +// }] +// } +function makeAst(input: any) { + const {type, children, ...other} = input; + + const node = new Node(type); + + for (const key of Object.keys(other)) { + // @ts-expect-error key + node[key] = other[key]; + } + + if (children) { + for (const child of children) { + node.appendChild(makeAst(child)); + } + } + + return node; +} + +// Remove any fields from the AST that are only used while parsing to make testing equality easier. +function stripUnusedFields(node: any) { + const walker = node.walker(); + + let e; + while ((e = walker.next())) { + e.node._open = false; + e.node._size = null; + e.node._sourcepos = null; + + e.node._fenceChar = null; + e.node._fenceLength = 0; + e.node._fenceOffset = 0; + e.node._info = ''; + e.node._isFenced = false; + } + + return node; +} diff --git a/app/components/markdown/transform.ts b/app/components/markdown/transform.ts index a476134eb..18aaa2096 100644 --- a/app/components/markdown/transform.ts +++ b/app/components/markdown/transform.ts @@ -5,6 +5,8 @@ import {Node, NodeType} from 'commonmark'; import {escapeRegex} from '@utils/markdown'; +import type {UserMentionKey} from '@typings/global/markdown'; + /* eslint-disable no-underscore-dangle */ const cjkPattern = /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf\uac00-\ud7a3]/; @@ -136,7 +138,7 @@ export function highlightMentions(ast: Node, mentionKeys: UserMentionKey[]) { const matches = mentionKeys.some((mention) => { const mentionName = '@' + node.mentionName; const flags = mention.caseSensitive ? '' : 'i'; - const pattern = new RegExp(`${escapeRegex(mention.key)}\\.?`, flags); + const pattern = new RegExp(`@${escapeRegex(mention.key.replace('@', ''))}\\.?\\b`, flags); return pattern.test(mentionName); }); diff --git a/app/components/post_list/combined_user_activity/combined_user_activity.tsx b/app/components/post_list/combined_user_activity/combined_user_activity.tsx new file mode 100644 index 000000000..dddabe1c6 --- /dev/null +++ b/app/components/post_list/combined_user_activity/combined_user_activity.tsx @@ -0,0 +1,233 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect} from 'react'; +import {useIntl} from 'react-intl'; +import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'; + +import {fetchMissinProfilesByIds, fetchMissinProfilesByUsernames} from '@actions/remote/user'; +import Markdown from '@components/markdown'; +import SystemAvatar from '@app/components/system_avatar'; +import SystemHeader from '@app/components/system_header'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Post as PostConstants} from '@constants'; +import {useServerUrl} from '@context/server_url'; +import {showModalOverCurrentContext} from '@screens/navigation'; +import {emptyFunction} from '@utils/general'; +import {getMarkdownTextStyles} from '@utils/markdown'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import LastUsers from './last_users'; +import {postTypeMessages} from './messages'; + +type Props = { + canDelete: boolean; + currentUserId?: string; + currentUsername?: string; + post: Post; + showJoinLeave: boolean; + testID?: string; + theme: Theme; + usernamesById: Record; + style?: StyleProp; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + baseText: { + color: changeOpacity(theme.centerChannelColor, 0.6), + fontSize: 15, + }, + body: { + flex: 1, + paddingBottom: 2, + paddingTop: 2, + }, + container: { + flexDirection: 'row', + }, + content: { + flex: 1, + flexDirection: 'column', + marginRight: 12, + }, + }; +}); + +const CombinedUserActivity = ({ + canDelete, currentUserId, currentUsername, + post, showJoinLeave, testID, theme, usernamesById = {}, style, +}: Props) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const itemTestID = `${testID}.${post.id}`; + const textStyles = getMarkdownTextStyles(theme); + const {allUserIds, allUsernames, messageData} = post.props.user_activity; + const styles = getStyleSheet(theme); + const content = []; + const removedUserIds: string[] = []; + + const loadUserProfiles = () => { + if (allUserIds.length) { + fetchMissinProfilesByIds(serverUrl, allUserIds); + } + + if (allUsernames.length) { + fetchMissinProfilesByUsernames(serverUrl, allUsernames); + } + }; + + const getUsernames = (userIds: string[]) => { + const someone = intl.formatMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'}); + const you = intl.formatMessage({id: 'combined_system_message.you', defaultMessage: 'You'}); + const usernames = userIds.reduce((acc: string[], id: string) => { + if (id !== currentUserId && id !== currentUsername) { + const name = usernamesById[id]; + acc.push(name ? `@${name}` : someone); + } + return acc; + }, []); + + if (currentUserId && userIds.includes(currentUserId)) { + usernames.unshift(you); + } else if (currentUsername && userIds.includes(currentUsername)) { + usernames.unshift(you); + } + + return usernames; + }; + + const onLongPress = () => { + if (!canDelete) { + return; + } + + const screen = 'PostOptions'; + const passProps = { + canDelete, + isSystemMessage: true, + post, + }; + Keyboard.dismiss(); + requestAnimationFrame(() => { + showModalOverCurrentContext(screen, passProps); + }); + }; + + const renderMessage = (postType: string, userIds: string[], actorId: string) => { + let actor = ''; + if (usernamesById[actorId]) { + actor = `@${usernamesById[actorId]}`; + } + + if (actor && (actorId === currentUserId || actorId === currentUsername)) { + actor = intl.formatMessage({id: 'combined_system_message.you', defaultMessage: 'You'}).toLowerCase(); + } + + const usernames = getUsernames(userIds); + const numOthers = usernames.length - 1; + + if (numOthers > 1) { + return ( + + ); + } + + const firstUser = usernames[0]; + const secondUser = usernames[1]; + let localeHolder; + if (numOthers === 0) { + localeHolder = postTypeMessages[postType].one; + + if ( + (userIds[0] === currentUserId || userIds[0] === currentUsername) && + postTypeMessages[postType].one_you + ) { + localeHolder = postTypeMessages[postType].one_you; + } + } else { + localeHolder = postTypeMessages[postType].two; + } + + const formattedMessage = intl.formatMessage(localeHolder, {firstUser, secondUser, actor}); + return ( + + ); + }; + + useEffect(() => { + loadUserProfiles(); + }, [allUserIds, allUsernames]); + + for (const message of messageData) { + const {postType, actorId} = message; + let userIds = message.userIds; + + if (!showJoinLeave && actorId !== currentUserId) { + const affectsCurrentUser = userIds.indexOf(currentUserId) !== -1; + + if (affectsCurrentUser) { + // Only show the message that the current user was added, etc + userIds = [currentUserId]; + } else { + // Not something the current user did or was affected by + continue; + } + } + + if (postType === PostConstants.POST_TYPES.REMOVE_FROM_CHANNEL) { + removedUserIds.push(...userIds); + continue; + } + + content.push(renderMessage(postType, userIds, actorId)); + } + + if (removedUserIds.length > 0) { + const uniqueRemovedUserIds = removedUserIds.filter((id, index, arr) => arr.indexOf(id) === index); + content.push(renderMessage(PostConstants.POST_TYPES.REMOVE_FROM_CHANNEL, uniqueRemovedUserIds, currentUserId || '')); + } + + return ( + + + + + + + + {content} + + + + + + ); +}; + +export default CombinedUserActivity; diff --git a/app/components/post_list/combined_user_activity/index.ts b/app/components/post_list/combined_user_activity/index.ts new file mode 100644 index 000000000..f2589c8dd --- /dev/null +++ b/app/components/post_list/combined_user_activity/index.ts @@ -0,0 +1,69 @@ +// 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 {from as from$, of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Permissions} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {generateCombinedPost, getPostIdsForCombinedUserActivityPost} from '@utils/post_list'; +import {hasPermissionForPost} from '@utils/role'; + +import CombinedUserActivity from './combined_user_activity'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +type CombinedPostsInput = WithDatabaseArgs & { + currentUser: UserModel; + postId: string; + posts: PostModel[]; +} + +const {SERVER: {POST, SYSTEM, USER}} = MM_TABLES; + +const withPostId = withObservables(['postId'], ({database, postId}: WithDatabaseArgs & {postId: string}) => { + const postIds = getPostIdsForCombinedUserActivityPost(postId); + + return { + currentUser: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap((currentUserId: SystemModel) => database.get(USER).findAndObserve(currentUserId.value)), + ), + posts: database.get(POST).query( + Q.where('id', Q.oneOf(postIds)), + ).observe(), + }; +}); + +const withCombinedPosts = withObservables(['currentUser', 'postId', 'posts'], ({currentUser, database, postId, posts}: CombinedPostsInput) => { + const post = generateCombinedPost(postId, posts); + const canDelete = from$(hasPermissionForPost(posts[0], currentUser, Permissions.DELETE_OTHERS_POSTS, false)); + const usernamesById = database.get(USER).query( + Q.or( + Q.where('id', Q.oneOf(post.props.user_activity.allUserIds)), + Q.where('username', Q.oneOf(post.props.user_activity.allUsernames)), + ), + ).observe().pipe( + switchMap((users: UserModel[]) => { + // eslint-disable-next-line max-nested-callbacks + return of$(users.reduce((acc, user) => { + acc[user.id] = user.username; + return acc; + }, {} as Record)); + }), + ); + + return { + canDelete, + currentUserId: of$(currentUser.id), + post: of$(post), + usernamesById, + }; +}); + +export default withDatabase(withPostId(withCombinedPosts(CombinedUserActivity))); diff --git a/app/components/post_list/combined_user_activity/last_users.tsx b/app/components/post_list/combined_user_activity/last_users.tsx new file mode 100644 index 000000000..50c5d8937 --- /dev/null +++ b/app/components/post_list/combined_user_activity/last_users.tsx @@ -0,0 +1,104 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Text} from 'react-native'; + +import FormattedMarkdownText from '@components/formatted_markdown_text'; +import FormattedText from '@components/formatted_text'; +import Markdown from '@components/markdown'; +import {getMarkdownTextStyles} from '@utils/markdown'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import {postTypeMessages, systemMessages} from './messages'; + +type LastUsersProps = { + actor: string; + postType: string; + usernames: string[]; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + baseText: { + color: theme.centerChannelColor, + opacity: 0.6, + fontSize: 15, + }, + linkText: { + color: theme.linkColor, + opacity: 0.8, + fontSize: 15, + }, + }; +}); + +const LastUsers = ({actor, postType, theme, usernames}: LastUsersProps) => { + const [expanded, setExpanded] = useState(false); + const intl = useIntl(); + const style = getStyleSheet(theme); + const textStyles = getMarkdownTextStyles(theme); + + const onPress = () => { + setExpanded(true); + }; + + if (expanded) { + const lastIndex = usernames.length - 1; + const lastUser = usernames[lastIndex]; + const expandedMessage = postTypeMessages[postType].many_expanded; + const formattedMessage = intl.formatMessage(expandedMessage, { + users: usernames.slice(0, lastIndex).join(', '), + lastUser, + actor, + }); + + return ( + + ); + } + + const firstUser = usernames[0]; + const numOthers = usernames.length - 1; + + return ( + + + {' '} + + + + + + ); +}; + +export default LastUsers; diff --git a/app/components/post_list/combined_user_activity/messages.ts b/app/components/post_list/combined_user_activity/messages.ts new file mode 100644 index 000000000..8c0ea0b9e --- /dev/null +++ b/app/components/post_list/combined_user_activity/messages.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Post} from '@constants'; +import {t} from '@i18n'; + +const { + JOIN_CHANNEL, ADD_TO_CHANNEL, REMOVE_FROM_CHANNEL, LEAVE_CHANNEL, + JOIN_TEAM, ADD_TO_TEAM, REMOVE_FROM_TEAM, LEAVE_TEAM, +} = Post.POST_TYPES; + +export const postTypeMessages = { + [JOIN_CHANNEL]: { + one: { + id: t('combined_system_message.joined_channel.one'), + defaultMessage: '{firstUser} **joined the channel**.', + }, + one_you: { + id: t('combined_system_message.joined_channel.one_you'), + defaultMessage: 'You **joined the channel**.', + }, + two: { + id: t('combined_system_message.joined_channel.two'), + defaultMessage: '{firstUser} and {secondUser} **joined the channel**.', + }, + many_expanded: { + id: t('combined_system_message.joined_channel.many_expanded'), + defaultMessage: '{users} and {lastUser} **joined the channel**.', + }, + }, + [ADD_TO_CHANNEL]: { + one: { + id: t('combined_system_message.added_to_channel.one'), + defaultMessage: '{firstUser} **added to the channel** by {actor}.', + }, + one_you: { + id: t('combined_system_message.added_to_channel.one_you'), + defaultMessage: 'You were **added to the channel** by {actor}.', + }, + two: { + id: t('combined_system_message.added_to_channel.two'), + defaultMessage: '{firstUser} and {secondUser} **added to the channel** by {actor}.', + }, + many_expanded: { + id: t('combined_system_message.added_to_channel.many_expanded'), + defaultMessage: '{users} and {lastUser} were **added to the channel** by {actor}.', + }, + }, + [REMOVE_FROM_CHANNEL]: { + one: { + id: t('combined_system_message.removed_from_channel.one'), + defaultMessage: '{firstUser} was **removed from the channel**.', + }, + one_you: { + id: t('combined_system_message.removed_from_channel.one_you'), + defaultMessage: 'You were **removed from the channel**.', + }, + two: { + id: t('combined_system_message.removed_from_channel.two'), + defaultMessage: '{firstUser} and {secondUser} were **removed from the channel**.', + }, + many_expanded: { + id: t('combined_system_message.removed_from_channel.many_expanded'), + defaultMessage: '{users} and {lastUser} were **removed from the channel**.', + }, + }, + [LEAVE_CHANNEL]: { + one: { + id: t('combined_system_message.left_channel.one'), + defaultMessage: '{firstUser} **left the channel**.', + }, + one_you: { + id: t('combined_system_message.left_channel.one_you'), + defaultMessage: 'You **left the channel**.', + }, + two: { + id: t('combined_system_message.left_channel.two'), + defaultMessage: '{firstUser} and {secondUser} **left the channel**.', + }, + many_expanded: { + id: t('combined_system_message.left_channel.many_expanded'), + defaultMessage: '{users} and {lastUser} **left the channel**.', + }, + }, + [JOIN_TEAM]: { + one: { + id: t('combined_system_message.joined_team.one'), + defaultMessage: '{firstUser} **joined the team**.', + }, + one_you: { + id: t('combined_system_message.joined_team.one_you'), + defaultMessage: 'You **joined the team**.', + }, + two: { + id: t('combined_system_message.joined_team.two'), + defaultMessage: '{firstUser} and {secondUser} **joined the team**.', + }, + many_expanded: { + id: t('combined_system_message.joined_team.many_expanded'), + defaultMessage: '{users} and {lastUser} **joined the team**.', + }, + }, + [ADD_TO_TEAM]: { + one: { + id: t('combined_system_message.added_to_team.one'), + defaultMessage: '{firstUser} **added to the team** by {actor}.', + }, + one_you: { + id: t('combined_system_message.added_to_team.one_you'), + defaultMessage: 'You were **added to the team** by {actor}.', + }, + two: { + id: t('combined_system_message.added_to_team.two'), + defaultMessage: '{firstUser} and {secondUser} **added to the team** by {actor}.', + }, + many_expanded: { + id: t('combined_system_message.added_to_team.many_expanded'), + defaultMessage: '{users} and {lastUser} were **added to the team** by {actor}.', + }, + }, + [REMOVE_FROM_TEAM]: { + one: { + id: t('combined_system_message.removed_from_team.one'), + defaultMessage: '{firstUser} was **removed from the team**.', + }, + one_you: { + id: t('combined_system_message.removed_from_team.one_you'), + defaultMessage: 'You were **removed from the team**.', + }, + two: { + id: t('combined_system_message.removed_from_team.two'), + defaultMessage: '{firstUser} and {secondUser} were **removed from the team**.', + }, + many_expanded: { + id: t('combined_system_message.removed_from_team.many_expanded'), + defaultMessage: '{users} and {lastUser} were **removed from the team**.', + }, + }, + [LEAVE_TEAM]: { + one: { + id: t('combined_system_message.left_team.one'), + defaultMessage: '{firstUser} **left the team**.', + }, + one_you: { + id: t('combined_system_message.left_team.one_you'), + defaultMessage: 'You **left the team**.', + }, + two: { + id: t('combined_system_message.left_team.two'), + defaultMessage: '{firstUser} and {secondUser} **left the team**.', + }, + many_expanded: { + id: t('combined_system_message.left_team.many_expanded'), + defaultMessage: '{users} and {lastUser} **left the team**.', + }, + }, +}; + +export const systemMessages = { + [ADD_TO_CHANNEL]: { + id: t('last_users_message.added_to_channel.type'), + defaultMessage: 'were **added to the channel** by {actor}.', + }, + [JOIN_CHANNEL]: { + id: t('last_users_message.joined_channel.type'), + defaultMessage: '**joined the channel**.', + }, + [LEAVE_CHANNEL]: { + id: t('last_users_message.left_channel.type'), + defaultMessage: '**left the channel**.', + }, + [REMOVE_FROM_CHANNEL]: { + id: t('last_users_message.removed_from_channel.type'), + defaultMessage: 'were **removed from the channel**.', + }, + [ADD_TO_TEAM]: { + id: t('last_users_message.added_to_team.type'), + defaultMessage: 'were **added to the team** by {actor}.', + }, + [JOIN_TEAM]: { + id: t('last_users_message.joined_team.type'), + defaultMessage: '**joined the team**.', + }, + [LEAVE_TEAM]: { + id: t('last_users_message.left_team.type'), + defaultMessage: '**left the team**.', + }, + [REMOVE_FROM_TEAM]: { + id: t('last_users_message.removed_from_team.type'), + defaultMessage: 'were **removed from the team**.', + }, +}; diff --git a/app/components/post_list/date_separator/index.tsx b/app/components/post_list/date_separator/index.tsx new file mode 100644 index 000000000..602bf4e9f --- /dev/null +++ b/app/components/post_list/date_separator/index.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 {StyleProp, View, ViewStyle} from 'react-native'; + +import FormattedDate from '@components/formatted_date'; +import FormattedText from '@components/formatted_text'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type DateSeparatorProps = { + date: number | Date; + style?: StyleProp; + theme: Theme; + timezone?: UserTimezone | null; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + alignItems: 'center', + flexDirection: 'row', + height: 28, + marginTop: 8, + }, + dateContainer: { + marginHorizontal: 15, + }, + line: { + flex: 1, + height: 1, + backgroundColor: theme.centerChannelColor, + opacity: 0.2, + }, + date: { + color: theme.centerChannelColor, + fontSize: 14, + fontWeight: '600', + }, + }; +}); + +export function isSameDay(a: Date, b: Date) { + return a.getDate() === b.getDate() && a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear(); +} + +export function isToday(date: Date) { + const now = new Date(); + + return isSameDay(date, now); +} + +export function isYesterday(date: Date) { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + return isSameDay(date, yesterday); +} + +const RecentDate = (props: DateSeparatorProps) => { + const {date, ...otherProps} = props; + const when = new Date(date); + + if (isToday(when)) { + return ( + + ); + } else if (isYesterday(when)) { + return ( + + ); + } + + return ( + + ); +}; + +const DateSeparator = (props: DateSeparatorProps) => { + const styles = getStyleSheet(props.theme); + + return ( + + + + + + + + ); +}; + +export default DateSeparator; diff --git a/app/components/post_list/index.tsx b/app/components/post_list/index.tsx new file mode 100644 index 000000000..62d692b03 --- /dev/null +++ b/app/components/post_list/index.tsx @@ -0,0 +1,272 @@ +// 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 React, {ReactElement, useCallback} from 'react'; +import {DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleSheet, ViewToken} from 'react-native'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import Post from '@components/post_list/post'; +import CombinedUserActivity from '@app/components/post_list/combined_user_activity'; +import DateSeparator from '@components/post_list/date_separator'; +import NewMessagesLine from '@components/post_list/new_message_line'; +import {Preferences} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useTheme} from '@context/theme'; +import {getPreferenceAsBool} from '@helpers/api/preference'; +import {emptyFunction} from '@utils/general'; +import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList} from '@utils/post_list'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type PostModel from '@typings/database/models/servers/post'; +import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel'; +import type PreferenceModel from '@typings/database/models/servers/preference'; +import type SystemModel from '@typings/database/models/servers/system'; +import UserModel from '@typings/database/models/servers/user'; + +type RefreshProps = { + children: ReactElement; + enabled: boolean; + onRefresh: () => void; + refreshing: boolean; +} + +type Props = { + currentTimezone: UserTimezone | null; + currentUsername: string; + isTimezoneEnabled: boolean; + lastViewedAt: number; + posts: PostModel[]; + shouldShowJoinLeaveMessages: boolean; + testID: string; +} + +type ViewableItemsChanged = { + viewableItems: ViewToken[]; + changed: ViewToken[]; +} + +const style = StyleSheet.create({ + container: { + flex: 1, + scaleY: -1, + }, + scale: { + ...Platform.select({ + android: { + scaleY: -1, + }, + }), + }, +}); + +const {SERVER: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, PREFERENCE, SYSTEM, USER}} = MM_TABLES; + +export const VIEWABILITY_CONFIG = { + itemVisiblePercentThreshold: 1, + minimumViewTime: 100, +}; + +const PostListRefreshControl = ({children, enabled, onRefresh, refreshing}: RefreshProps) => { + const props = { + onRefresh, + refreshing, + }; + + if (Platform.OS === 'android') { + return ( + + {children} + + ); + } + + const refreshControl = ; + + return React.cloneElement( + children, + {refreshControl, inverted: true}, + ); +}; + +const PostList = ({currentTimezone, currentUsername, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages, testID}: Props) => { + const theme = useTheme(); + const orderedPosts = preparePostList(posts, lastViewedAt, true, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, false); + + const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => { + if (!viewableItems.length) { + return; + } + + const viewableItemsMap = viewableItems.reduce((acc: Record, {item, isViewable}) => { + if (isViewable) { + acc[item.id] = true; + } + return acc; + }, {}); + + DeviceEventEmitter.emit('scrolled', viewableItemsMap); + }, []); + + const renderItem = useCallback(({item, index}) => { + if (typeof item === 'string') { + if (isStartOfNewMessages(item)) { + // postIds includes a date item after the new message indicator so 2 + // needs to be added to the index for the length check to be correct. + const moreNewMessages = orderedPosts.length === index + 2; + + // The date line and new message line each count for a line. So the + // goal of this is to check for the 3rd previous, which for the start + // of a thread would be null as it doesn't exist. + const checkForPostId = index < orderedPosts.length - 3; + + return ( + + ); + } else if (isDateLine(item)) { + return ( + + ); + } + + if (isCombinedUserActivityPost(item)) { + const postProps = { + currentUsername, + postId: item, + style: Platform.OS === 'ios' ? style.scale : style.container, + testID: `${testID}.combined_user_activity`, + showJoinLeave: shouldShowJoinLeaveMessages, + theme, + }; + + return (); + } + } + + let previousPost: PostModel|undefined; + let nextPost: PostModel|undefined; + if (index < posts.length - 1) { + const prev = orderedPosts.slice(index + 1).find((v) => typeof v !== 'string'); + if (prev) { + previousPost = prev as PostModel; + } + } + + if (index > 0) { + const next = orderedPosts.slice(0, index); + for (let i = next.length - 1; i >= 0; i--) { + const v = next[i]; + if (typeof v !== 'string') { + nextPost = v; + break; + } + } + } + + const postProps = { + highlightPinnedOrFlagged: true, + location: 'Channel', + nextPost, + previousPost, + shouldRenderReplyButton: true, + }; + + return ( + + ); + }, [orderedPosts, theme]); + + return ( + + (typeof item === 'string' ? item : item.id)} + style={{flex: 1}} + contentContainerStyle={{paddingTop: 5}} + initialNumToRender={10} + maxToRenderPerBatch={Platform.select({android: 5})} + removeClippedSubviews={true} + onViewableItemsChanged={onViewableItemsChanged} + viewabilityConfig={VIEWABILITY_CONFIG} + windowSize={30} + scrollEventThrottle={60} + /> + + ); +}; + +const withPosts = withObservables(['channelId'], ({database, channelId}: {channelId: string} & WithDatabaseArgs) => { + const currentUser = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap((currentUserId: SystemModel) => database.get(USER).findAndObserve(currentUserId.value)), + ); + + return { + currentTimezone: currentUser.pipe((switchMap((user: UserModel) => of$(user.timezone)))), + currentUsername: currentUser.pipe((switchMap((user: UserModel) => of$(user.username)))), + isTimezoneEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((config: SystemModel) => of$(config.value.ExperimentalTimezone === 'true')), + ), + lastViewedAt: database.get(MY_CHANNEL).findAndObserve(channelId).pipe( + switchMap((myChannel: MyChannelModel) => of$(myChannel.lastViewedAt)), + ), + posts: database.get(POSTS_IN_CHANNEL).query( + Q.where('channel_id', channelId), + Q.experimentalSortBy('latest', Q.desc), + ).observe().pipe( + switchMap((postsInChannel: PostsInChannelModel[]) => { + if (!postsInChannel.length) { + return of$([]); + } + + const {earliest, latest} = postsInChannel[0]; + return database.get(POST).query( + Q.and( + Q.where('delete_at', 0), + Q.where('channel_id', channelId), + Q.where('create_at', Q.between(earliest, latest)), + ), + Q.experimentalSortBy('create_at', Q.desc), + ).observe(); + }), + ), + shouldShowJoinLeaveMessages: database.get(PREFERENCE).query( + Q.where('category', Preferences.CATEGORY_ADVANCED_SETTINGS), + Q.where('name', Preferences.ADVANCED_FILTER_JOIN_LEAVE), + ).observe().pipe( + switchMap((preferences: PreferenceModel[]) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true))), + ), + }; +}); + +export default withDatabase(withPosts(React.memo(PostList))); diff --git a/app/components/post_list/new_message_line/index.tsx b/app/components/post_list/new_message_line/index.tsx new file mode 100644 index 000000000..30a5a6783 --- /dev/null +++ b/app/components/post_list/new_message_line/index.tsx @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, View, ViewStyle} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type NewMessagesLineProps = { + moreMessages: boolean; + style?: StyleProp; + theme: Theme; + testID?: string; +} + +function NewMessagesLine({moreMessages, style, testID, theme}: NewMessagesLineProps) { + const styles = getStyleFromTheme(theme); + + let text = ( + + ); + + if (moreMessages) { + text = ( + + ); + } + + return ( + + + + {text} + + + + ); +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + alignItems: 'center', + flexDirection: 'row', + height: 28, + }, + textContainer: { + marginHorizontal: 15, + }, + line: { + flex: 1, + height: 1, + backgroundColor: theme.newMessageSeparator, + }, + text: { + fontSize: 14, + color: theme.newMessageSeparator, + }, + }; +}); + +export default NewMessagesLine; diff --git a/app/components/post_list/post/avatar/index.tsx b/app/components/post_list/post/avatar/index.tsx new file mode 100644 index 000000000..2fdb4f03d --- /dev/null +++ b/app/components/post_list/post/avatar/index.tsx @@ -0,0 +1,182 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import React, {ReactNode, useRef} from 'react'; +import {useIntl} from 'react-intl'; +import {Keyboard, Platform, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; +import FastImage from 'react-native-fast-image'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import CompassIcon from '@components/compass_icon'; +import SystemAvatar from '@components/system_avatar'; +import ProfilePicture from '@components/profile_picture'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {View as ViewConstant} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useServerUrl} from '@context/server_url'; +import {useTheme} from '@context/theme'; +import NetworkManager from '@init/network_manager'; +import {showModal} from '@screens/navigation'; +import {preventDoubleTap} from '@utils/tap'; + +import type {ImageSource} from 'react-native-vector-icons/Icon'; + +import type {Client} from '@client/rest'; +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; +import type SystemModel from '@typings/database/models/servers/system'; + +type AvatarProps = { + author: UserModel; + enablePostIconOverride?: boolean; + isAutoReponse: boolean; + isSystemPost: boolean; + pendingPostStyle?: StyleProp; + post: PostModel; +} + +const style = StyleSheet.create({ + buffer: { + marginRight: Platform.select({android: 2, ios: 3}), + }, + profilePictureContainer: { + marginBottom: 5, + marginLeft: 12, + marginRight: Platform.select({android: 11, ios: 10}), + marginTop: 10, + }, +}); + +const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, pendingPostStyle, post}: AvatarProps) => { + const closeButton = useRef(); + const intl = useIntl(); + const theme = useTheme(); + const serverUrl = useServerUrl(); + let client: Client | undefined; + try { + client = NetworkManager.getClient(serverUrl); + } catch { + // do nothing, client is not set + } + + if (isSystemPost && !isAutoReponse && !author.isBot) { + return (); + } + + const fromWebHook = post.props?.from_webhook === 'true'; + const iconOverride = enablePostIconOverride && post.props?.use_user_icon !== 'true'; + if (fromWebHook && iconOverride) { + const isEmoji = Boolean(post.props?.override_icon_emoji); + const frameSize = ViewConstant.PROFILE_PICTURE_SIZE; + const pictureSize = isEmoji ? ViewConstant.PROFILE_PICTURE_EMOJI_SIZE : ViewConstant.PROFILE_PICTURE_SIZE; + const borderRadius = isEmoji ? 0 : ViewConstant.PROFILE_PICTURE_SIZE / 2; + const overrideIconUrl = client?.getAbsoluteUrl(post.props?.override_icon_url); + + let iconComponent: ReactNode; + if (overrideIconUrl) { + const source = {uri: overrideIconUrl}; + iconComponent = ( + + ); + } else { + iconComponent = ( + + ); + } + + return ( + + + {iconComponent} + + + ); + } + + const onViewUserProfile = preventDoubleTap(() => { + const screen = 'UserProfile'; + const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}); + const passProps = {author}; + + if (!closeButton.current) { + closeButton.current = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + } + + const options = { + topBar: { + leftButtons: [{ + id: 'close-settings', + icon: closeButton.current, + testID: 'close.settings.button', + }], + }, + }; + + Keyboard.dismiss(); + showModal(screen, title, passProps, options); + }); + + let component = ( + + ); + + if (!fromWebHook) { + component = ( + + {component} + + ); + } + + return ( + + {component} + + ); +}; + +const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({ + enablePostIconOverride: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((cfg: SystemModel) => of$(cfg.value.EnablePostIconOverride === 'true')), + ), +})); + +const withPost = withObservables(['post', 'enablePostIconOverride'], ({post, enablePostIconOverride}: {post: PostModel; enablePostIconOverride: boolean}) => { + return { + author: post.author.observe(), + enablePostIconOverride: of$(enablePostIconOverride), + }; +}); + +export default withDatabase(withSystemIds(withPost(React.memo(Avatar)))); diff --git a/app/components/post_list/post/body/add_members/index.tsx b/app/components/post_list/post/body/add_members/index.tsx new file mode 100644 index 000000000..4658bce87 --- /dev/null +++ b/app/components/post_list/post/body/add_members/index.tsx @@ -0,0 +1,237 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import React, {ReactNode} from 'react'; +import {useIntl} from 'react-intl'; +import {Text} from 'react-native'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {removePost, sendAddToChannelEphemeralPost} from '@actions/local/post'; +import {addMembersToChannel} from '@actions/remote/channel'; +import AtMention from '@components/markdown/at_mention'; +import FormattedText from '@components/formatted_text'; +import {General} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useServerUrl} from '@context/server_url'; +import {t} from '@i18n'; +import {getMarkdownTextStyles} from '@utils/markdown'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; +import SystemModel from '@typings/database/models/servers/system'; + +type AddMembersProps = { + channelType: string | null; + currentUser: UserModel; + post: PostModel; + theme: Theme; +} + +const {SERVER: {SYSTEM, USER}} = MM_TABLES; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + message: { + color: changeOpacity(theme.centerChannelColor, 0.6), + fontSize: 15, + lineHeight: 20, + }, + }; +}); + +const AddMembers = ({channelType, currentUser, post, theme}: AddMembersProps) => { + const intl = useIntl(); + const styles = getStyleSheet(theme); + const textStyles = getMarkdownTextStyles(theme); + const serverUrl = useServerUrl(); + const postId: string = post.props.add_channel_member?.post_id; + const noGroupsUsernames = post.props.add_channel_member?.not_in_groups_usernames; + let userIds: string[] = post.props.add_channel_member?.not_in_channel_user_ids; + let usernames: string[] = post.props.add_channel_member?.not_in_channel_usernames; + + if (!postId || !channelType) { + return null; + } + + if (!userIds) { + userIds = post.props.add_channel_member?.user_ids; + } + if (!usernames) { + usernames = post.props.add_channel_member?.usernames; + } + + const handleAddChannelMember = () => { + if (post && post.channelId) { + addMembersToChannel(serverUrl, post.channelId, userIds, post.rootId, false); + if (post.rootId) { + const messages = usernames.map((addedUsername) => { + return intl.formatMessage( + { + id: 'api.channel.add_member.added', + defaultMessage: '{addedUsername} added to the channel by {username}.', + }, + { + username: currentUser.username, + addedUsername, + }, + ); + }); + sendAddToChannelEphemeralPost(serverUrl, currentUser, usernames, messages, post.channelId, post.rootId); + } + + removePost(serverUrl, post); + } + }; + + const generateAtMentions = (names: string[]) => { + if (names.length === 1) { + return ( + + ); + } else if (names.length > 1) { + function andSeparator(key: string) { + return ( + + ); + } + + function commaSeparator(key: string) { + return {', '}; + } + + return ( + + { + names.map((username: string) => { + return ( + + ); + }).reduce((acc: ReactNode[], el: ReactNode, idx: number, arr: ReactNode[]) => { + if (idx === 0) { + return [el]; + } else if (idx === arr.length - 1) { + return [...acc, andSeparator(`separator-${idx}`), el]; + } + + return [...acc, commaSeparator(`commma-separator-${idx}`), el]; + }, []) + } + + ); + } + + return ''; + }; + + let linkId = ''; + let linkText = ''; + if (channelType === General.PRIVATE_CHANNEL) { + linkId = t('post_body.check_for_out_of_channel_mentions.link.private'); + linkText = 'add them to this private channel'; + } else if (channelType === General.OPEN_CHANNEL) { + linkId = t('post_body.check_for_out_of_channel_mentions.link.public'); + linkText = 'add them to the channel'; + } + + let outOfChannelMessageID = ''; + let outOfChannelMessageText = ''; + const outOfChannelAtMentions = generateAtMentions(usernames); + if (usernames.length === 1) { + outOfChannelMessageID = t('post_body.check_for_out_of_channel_mentions.message.one'); + outOfChannelMessageText = 'was mentioned but is not in the channel. Would you like to '; + } else if (usernames.length > 1) { + outOfChannelMessageID = t('post_body.check_for_out_of_channel_mentions.message.multiple'); + outOfChannelMessageText = 'were mentioned but they are not in the channel. Would you like to '; + } + + let outOfGroupsMessageID = ''; + let outOfGroupsMessageText = ''; + const outOfGroupsAtMentions = generateAtMentions(noGroupsUsernames); + if (noGroupsUsernames?.length) { + outOfGroupsMessageID = t('post_body.check_for_out_of_channel_groups_mentions.message'); + outOfGroupsMessageText = 'did not get notified by this mention because they are not in the channel. They are also not a member of the groups linked to this channel.'; + } + + let outOfChannelMessage = null; + if (usernames.length) { + outOfChannelMessage = ( + + {outOfChannelAtMentions} + {' '} + + + + + + + ); + } + + let outOfGroupsMessage = null; + if (noGroupsUsernames?.length) { + outOfGroupsMessage = ( + + {outOfGroupsAtMentions} + {' '} + + + ); + } + + return ( + <> + {outOfChannelMessage} + {outOfGroupsMessage} + + ); +}; + +const withChannelType = withObservables(['post'], ({database, post}: WithDatabaseArgs & {post: PostModel}) => ({ + currentUser: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap((currentUserId: SystemModel) => database.get(USER).findAndObserve(currentUserId.value)), + ), + channelType: post.channel.observe().pipe( + switchMap( + (channel: ChannelModel) => (channel ? of$(channel.type) : of$(null)), + ), + ), +})); + +export default withDatabase(withChannelType(React.memo(AddMembers))); diff --git a/app/components/post_list/post/body/content/embedded_bindings/button_binding/button_binding_text.tsx b/app/components/post_list/post/body/content/embedded_bindings/button_binding/button_binding_text.tsx new file mode 100644 index 000000000..baad9dcfc --- /dev/null +++ b/app/components/post_list/post/body/content/embedded_bindings/button_binding/button_binding_text.tsx @@ -0,0 +1,87 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, View, StyleSheet, StyleProp, TextStyle} from 'react-native'; + +import Emoji from '@components/emoji'; +import {reEmoji, reEmoticon, reMain} from '@constants/emoji'; +import {getEmoticonName} from '@utils/emoji/helpers'; + +type Props = { + message: string; + style: StyleProp; +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'flex-start', + flexDirection: 'row', + flexWrap: 'wrap', + }, +}); + +const ButtonBindingText = ({message, style}: Props) => { + const components = [] as React.ReactNode[]; + + let text = message; + while (text) { + let match; + + // See if the text starts with an emoji + if ((match = text.match(reEmoji))) { + components.push( + , + ); + text = text.substring(match[0].length); + continue; + } + + // Or an emoticon + if ((match = text.match(reEmoticon))) { + const emoticonName = getEmoticonName(match[0]); + if (emoticonName) { + components.push( + , + ); + text = text.substring(match[0].length); + continue; + } + } + + // This is plain text, so capture as much text as possible until we hit the next possible emoji. Note that + // reMain always captures at least one character, so text will always be getting shorter + match = text.match(reMain); + if (!match) { + continue; + } + + components.push( + + {match[0]} + , + ); + text = text.substring(match[0].length); + } + + return ( + + {components} + + ); +}; + +export default ButtonBindingText; diff --git a/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx new file mode 100644 index 000000000..c84cd1fb1 --- /dev/null +++ b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx @@ -0,0 +1,145 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; +import React, {useCallback, useRef} from 'react'; +import {useIntl} from 'react-intl'; +import Button from 'react-native-button'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {doAppCall, postEphemeralCallResponseForPost} from '@actions/remote/apps'; +import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@constants/apps'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useServerUrl} from '@context/server_url'; +import {createCallContext, createCallRequest} from '@utils/apps'; +import {getStatusColors} from '@utils/message_attachment_colors'; +import {preventDoubleTap} from '@utils/tap'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; + +import ButtonBindingText from './button_binding_text'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type PostModel from '@typings/database/models/servers/post'; +import type SystemModel from '@typings/database/models/servers/system'; + +type Props = { + currentTeamId: string; + binding: AppBinding; + post: PostModel; + teamID?: string; + theme: Theme; +} + +const {SERVER: {SYSTEM}} = MM_TABLES; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + const STATUS_COLORS = getStatusColors(theme); + return { + button: { + borderRadius: 4, + borderColor: changeOpacity(STATUS_COLORS.default, 0.25), + borderWidth: 2, + opacity: 1, + alignItems: 'center', + marginTop: 12, + justifyContent: 'center', + height: 36, + }, + buttonDisabled: {backgroundColor: changeOpacity(theme.buttonBg, 0.3)}, + text: { + color: STATUS_COLORS.default, + fontSize: 15, + fontWeight: '600', + lineHeight: 17, + }, + }; +}); + +const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) => { + const pressed = useRef(false); + const intl = useIntl(); + const serverUrl = useServerUrl(); + const style = getStyleSheet(theme); + + const onPress = useCallback(preventDoubleTap(async () => { + if (!binding.call || pressed.current) { + return; + } + + pressed.current = true; + + const context = createCallContext( + binding.app_id, + AppBindingLocations.IN_POST + binding.location, + post.channelId, + teamID || currentTeamId, + post.id, + ); + + const call = createCallRequest( + binding.call, + context, + {post: AppExpandLevels.EXPAND_ALL}, + ); + + const res = await doAppCall(serverUrl, call, AppCallTypes.SUBMIT, intl, theme); + pressed.current = false; + + if (res.error) { + const errorResponse = res.error; + const errorMessage = errorResponse.error || intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error occurred.', + }); + postEphemeralCallResponseForPost(serverUrl, errorResponse, errorMessage, post); + return; + } + + const callResp = res.data!; + + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.markdown) { + postEphemeralCallResponseForPost(serverUrl, callResp, callResp.markdown, post); + } + return; + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.FORM: + return; + default: { + const errorMessage = intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResp.type, + }); + postEphemeralCallResponseForPost(serverUrl, callResp, errorMessage, post); + } + } + }), []); + + return ( + + ); + + return null; +}; + +const withTeamId = withObservables(['post'], ({post}: {post: PostModel}) => ({ + teamID: post.channel.observe().pipe(switchMap((channel: ChannelModel) => of$(channel.teamId))), + currentTeamId: post.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe( + switchMap((currentTeamId: SystemModel) => of$(currentTeamId.value)), + ), +})); + +export default withTeamId(React.memo(ButtonBinding)); diff --git a/app/components/post_list/post/body/content/embedded_bindings/embed_text.tsx b/app/components/post_list/post/body/content/embedded_bindings/embed_text.tsx new file mode 100644 index 000000000..030be658d --- /dev/null +++ b/app/components/post_list/post/body/content/embedded_bindings/embed_text.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState} from 'react'; +import {LayoutChangeEvent, useWindowDimensions, ScrollView, View} from 'react-native'; +import Animated from 'react-native-reanimated'; + +import Markdown from '@components/markdown'; +import ShowMoreButton from '@app/components/post_list/post/body/message/show_more_button'; +import {useShowMoreAnimatedStyle} from '@hooks/show_more'; +import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + theme: Theme; + value: string; +} + +const SHOW_MORE_HEIGHT = 54; + +const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ + message: { + color: theme.centerChannelColor, + fontSize: 15, + lineHeight: 20, + }, +})); + +const EmbedText = ({theme, value}: Props) => { + const [open, setOpen] = useState(false); + const [height, setHeight] = useState(); + const dimensions = useWindowDimensions(); + const maxHeight = Math.round((dimensions.height * 0.4) + SHOW_MORE_HEIGHT); + const animatedStyle = useShowMoreAnimatedStyle(height, maxHeight, open); + const blockStyles = getMarkdownBlockStyles(theme); + const textStyles = getMarkdownTextStyles(theme); + const style = getStyles(theme); + + const onLayout = useCallback((event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), []); + const onPress = () => setOpen(!open); + + return ( + <> + + + + + + + + {(height || 0) > maxHeight && + + } + + ); +}; + +export default EmbedText; diff --git a/app/components/post_list/post/body/content/embedded_bindings/embed_title.tsx b/app/components/post_list/post/body/content/embedded_bindings/embed_title.tsx new file mode 100644 index 000000000..4ae871fa3 --- /dev/null +++ b/app/components/post_list/post/body/content/embedded_bindings/embed_title.tsx @@ -0,0 +1,56 @@ +// 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 Markdown from '@components/markdown'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + theme: Theme; + value: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + marginTop: 3, + flex: 1, + flexDirection: 'row', + }, + title: { + color: theme.centerChannelColor, + fontWeight: '600', + marginBottom: 5, + fontSize: 14, + lineHeight: 20, + }, + link: { + color: theme.linkColor, + }, + }; +}); + +const EmbedTitle = ({theme, value}: Props) => { + const style = getStyleSheet(theme); + + return ( + + + + ); +}; + +export default EmbedTitle; diff --git a/app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx b/app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx new file mode 100644 index 000000000..405ca4286 --- /dev/null +++ b/app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx @@ -0,0 +1,71 @@ +// 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 {copyAndFillBindings} from '@utils/apps'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import EmbedText from './embed_text'; +import EmbedTitle from './embed_title'; +import EmbedSubBindings from './embedded_sub_bindings'; + +import type PostModel from '@typings/database/models/servers/post'; + +type Props = { + embed: AppBinding; + post: PostModel; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.15), + borderLeftColor: changeOpacity(theme.linkColor, 0.6), + borderRightColor: changeOpacity(theme.centerChannelColor, 0.15), + borderTopColor: changeOpacity(theme.centerChannelColor, 0.15), + borderBottomWidth: 1, + borderLeftWidth: 3, + borderRightWidth: 1, + borderTopWidth: 1, + marginTop: 5, + padding: 12, + }, + }; +}); + +const EmbeddedBinding = ({embed, post, theme}: Props) => { + const style = getStyleSheet(theme); + + const bindings: AppBinding[] | undefined = copyAndFillBindings(embed)?.bindings; + + return ( + <> + + {Boolean(embed.label) && + + } + {Boolean(embed.description) && + + } + {Boolean(bindings?.length) && + + } + + + ); +}; + +export default EmbeddedBinding; diff --git a/app/components/post_list/post/body/content/embedded_bindings/embedded_sub_bindings.tsx b/app/components/post_list/post/body/content/embedded_bindings/embedded_sub_bindings.tsx new file mode 100644 index 000000000..98d895405 --- /dev/null +++ b/app/components/post_list/post/body/content/embedded_bindings/embedded_sub_bindings.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import ButtonBinding from './button_binding'; +import BindingMenu from './menu_binding'; + +import type PostModel from '@typings/database/models/servers/post'; + +type Props = { + bindings: AppBinding[]; + post: PostModel; + theme: Theme; +} + +const EmbeddedSubBindings = ({bindings, post, theme}: Props) => { + const content = [] as React.ReactNode[]; + + bindings.forEach((binding) => { + if (!binding.app_id || !binding.call) { + return; + } + + if (binding.bindings?.length) { + content.push( + , + ); + return; + } + + content.push( + , + ); + }); + + return content.length ? (<>{content}) : null; +}; + +export default EmbeddedSubBindings; diff --git a/app/components/post_list/post/body/content/embedded_bindings/index.tsx b/app/components/post_list/post/body/content/embedded_bindings/index.tsx new file mode 100644 index 000000000..f3770434c --- /dev/null +++ b/app/components/post_list/post/body/content/embedded_bindings/index.tsx @@ -0,0 +1,38 @@ +// 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 EmbeddedBinding from './embedded_binding'; + +import type PostModel from '@typings/database/models/servers/post'; + +type Props = { + post: PostModel; + theme: Theme; +} + +const EmbeddedBindings = ({post, theme}: Props) => { + const content = [] as React.ReactNode[]; + const embeds: AppBinding[] = post.props.app_bindings; + + embeds.forEach((embed, i) => { + content.push( + , + ); + }); + + return ( + + {content} + + ); +}; + +export default EmbeddedBindings; diff --git a/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx new file mode 100644 index 000000000..7c9fbde04 --- /dev/null +++ b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx @@ -0,0 +1,118 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; +import React, {useCallback, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {doAppCall, postEphemeralCallResponseForPost} from '@actions/remote/apps'; +import AutocompleteSelector from '@components/autocomplete_selector'; +import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@constants/apps'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useServerUrl} from '@context/server_url'; +import {createCallContext, createCallRequest} from '@utils/apps'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type PostModel from '@typings/database/models/servers/post'; +import type SystemModel from '@typings/database/models/servers/system'; + +type Props = { + binding: AppBinding; + currentTeamId: string; + post: PostModel; + teamID?: string; + theme: Theme; +} + +const {SERVER: {SYSTEM}} = MM_TABLES; + +const MenuBinding = ({binding, currentTeamId, post, teamID, theme}: Props) => { + const [selected, setSelected] = useState(); + const intl = useIntl(); + const serverUrl = useServerUrl(); + + const onSelect = useCallback(async (picked?: PostActionOption) => { + if (!picked) { + return; + } + setSelected(picked); + + const bind = binding.bindings?.find((b) => b.location === picked.value); + if (!bind) { + console.debug('Trying to select element not present in binding.'); //eslint-disable-line no-console + return; + } + + if (!bind.call) { + return; + } + + const context = createCallContext( + bind.app_id, + AppBindingLocations.IN_POST + bind.location, + post.channelId, + teamID || currentTeamId, + post.id, + ); + + const call = createCallRequest( + bind.call, + context, + {post: AppExpandLevels.EXPAND_ALL}, + ); + + const res = await doAppCall(serverUrl, call, AppCallTypes.SUBMIT, intl, theme); + if (res.error) { + const errorResponse = res.error; + const errorMessage = errorResponse.error || intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error occurred.', + }); + postEphemeralCallResponseForPost(serverUrl, res.error, errorMessage, post); + return; + } + + const callResp = res.data!; + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.markdown) { + postEphemeralCallResponseForPost(serverUrl, callResp, callResp.markdown, post); + } + return; + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.FORM: + return; + default: { + const errorMessage = intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResp.type, + }); + postEphemeralCallResponseForPost(serverUrl, callResp, errorMessage, post); + } + } + }, []); + + const options = binding.bindings?.map((b: AppBinding) => ({text: b.label, value: b.location || ''})); + + return ( + + ); +}; + +const withTeamId = withObservables(['post'], ({post}: {post: PostModel}) => ({ + teamID: post.channel.observe().pipe(switchMap((channel: ChannelModel) => of$(channel.teamId))), + currentTeamId: post.collections.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe( + switchMap((currentTeamId: SystemModel) => of$(currentTeamId.value)), + ), +})); + +export default withTeamId(React.memo(MenuBinding)); diff --git a/app/components/post_list/post/body/content/image_preview/index.tsx b/app/components/post_list/post/body/content/image_preview/index.tsx new file mode 100644 index 000000000..7f65309ec --- /dev/null +++ b/app/components/post_list/post/body/content/image_preview/index.tsx @@ -0,0 +1,140 @@ +// 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 React, {useCallback, useEffect, useRef, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {getRedirectLocation} from '@actions/remote/general'; +import FileIcon from '@app/components/post_list/post/body/files/file_icon'; +import ProgressiveImage from '@components/progressive_image'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Device} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useServerUrl} from '@context/server_url'; +import useDidUpdate from '@hooks/did_update'; +import {useSplitView} from '@hooks/device'; +import {generateId} from '@utils/general'; +import {openGallerWithMockFile} from '@utils/gallery'; +import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images'; +import {changeOpacity} from '@utils/theme'; +import {isImageLink, isValidUrl} from '@utils/url'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type SystemModel from '@typings/database/models/servers/system'; + +type ImagePreviewProps = { + expandedLink?: string; + isReplyPost: boolean; + link: string; + metadata: PostMetadata; + postId: string; + theme: Theme; +} + +const styles = StyleSheet.create({ + imageContainer: { + alignItems: 'flex-start', + justifyContent: 'flex-start', + marginBottom: 6, + marginTop: 10, + }, + image: { + alignItems: 'center', + borderRadius: 3, + justifyContent: 'center', + marginVertical: 1, + }, +}); + +const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme}: ImagePreviewProps) => { + const [error, setError] = useState(false); + const serverUrl = useServerUrl(); + const fileId = useRef(generateId()).current; + const [imageUrl, setImageUrl] = useState(expandedLink || link); + const splitView = useSplitView(); + const tabletOffset = !splitView && Device.IS_TABLET; + const imageProps = metadata.images![link]; + const dimensions = calculateDimensions(imageProps.height, imageProps.width, getViewPortWidth(isReplyPost, tabletOffset)); + + const onError = useCallback(() => { + setError(true); + }, []); + + const onPress = useCallback(() => { + openGallerWithMockFile(imageUrl, postId, imageProps.height, imageProps.width, fileId); + }, [imageUrl]); + + useEffect(() => { + if (!isImageLink(link) && expandedLink === undefined) { + getRedirectLocation(serverUrl, link); + } + }, [link]); + + useDidUpdate(() => { + if (expandedLink) { + setImageUrl(expandedLink); + } else if (link !== imageUrl) { + setImageUrl(link); + } + }, [link]); + + useEffect(() => { + if (expandedLink && expandedLink !== imageUrl) { + setImageUrl(expandedLink); + } + }, [expandedLink]); + + if (error || !isValidUrl(expandedLink || link) || isGifTooLarge(imageProps)) { + return ( + + + + + + ); + } + + // Note that the onPress prop of TouchableWithoutFeedback only works if its child is a View + return ( + + + + + + ); +}; + +const withExpandedLink = withObservables(['metadata'], ({database, metadata}: WithDatabaseArgs & {metadata: PostMetadata}) => { + const link = metadata.embeds?.[0].url; + + return { + expandedLink: database.get(MM_TABLES.SERVER.SYSTEM).query( + Q.where('id', SYSTEM_IDENTIFIERS.EXPANDED_LINKS), + ).observe().pipe( + switchMap((values: SystemModel[]) => ( + (link && values.length) ? of$((values[0].value as Record)[link]) : of$(undefined)), + ), + ), + link: of$(link), + }; +}); + +export default withDatabase(withExpandedLink(React.memo(ImagePreview))); diff --git a/app/components/post_list/post/body/content/index.tsx b/app/components/post_list/post/body/content/index.tsx new file mode 100644 index 000000000..e25324455 --- /dev/null +++ b/app/components/post_list/post/body/content/index.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {isYoutubeLink} from '@utils/url'; + +import EmbeddedBindings from './embedded_bindings'; +import ImagePreview from './image_preview'; +import MessageAttachments from './message_attachments'; +import Opengraph from './opengraph'; +import YouTube from './youtube'; + +import type PostModel from '@typings/database/models/servers/post'; + +type ContentProps = { + isReplyPost: boolean; + post: PostModel; + theme: Theme; +} + +const contentType: Record = { + app_bindings: 'app_bindings', + image: 'image', + message_attachment: 'message_attachment', + opengraph: 'opengraph', + youtube: 'youtube', +}; + +const Content = ({isReplyPost, post, theme}: ContentProps) => { + let type: string = post.metadata?.embeds?.[0].type as string; + if (!type && post.props?.attachments?.length) { + type = contentType.app_bindings; + } + + if (!type) { + return null; + } + + switch (contentType[type]) { + case contentType.image: + return ( + + ); + case contentType.opengraph: + if (isYoutubeLink(post.metadata!.embeds![0].url)) { + return ( + + ); + } + + return ( + + ); + case contentType.message_attachment: + if (post.props.attachments?.length) { + return ( + + ); + } + break; + case contentType.app_bindings: + if (post.props.app_bindings?.length) { + return ( + + ); + } + break; + } + + return null; +}; + +export default Content; diff --git a/app/components/post_list/post/body/content/message_attachments/__snapshots__/attachment_footer.test.tsx.snap b/app/components/post_list/post/body/content/message_attachments/__snapshots__/attachment_footer.test.tsx.snap new file mode 100644 index 000000000..d70c8e891 --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/__snapshots__/attachment_footer.test.tsx.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AttachmentFooter it matches snapshot when both footer and footer_icon are provided 1`] = ` + + + + + + This is the footer! + + +`; + +exports[`AttachmentFooter it matches snapshot when footer text is provided 1`] = ` + + + This is the footer! + + +`; diff --git a/app/components/post_list/post/body/content/message_attachments/action_button/action_button_text.tsx b/app/components/post_list/post/body/content/message_attachments/action_button/action_button_text.tsx new file mode 100644 index 000000000..79272e67e --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/action_button/action_button_text.tsx @@ -0,0 +1,87 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, View, StyleSheet, StyleProp, TextStyle} from 'react-native'; + +import Emoji from '@components/emoji'; +import {reEmoji, reEmoticon, reMain} from '@constants/emoji'; +import {getEmoticonName} from '@utils/emoji/helpers'; + +type Props = { + message: string; + style: StyleProp; +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'flex-start', + flexDirection: 'row', + flexWrap: 'wrap', + }, +}); + +const ActionButtonText = ({message, style}: Props) => { + const components = [] as React.ReactNode[]; + + let text = message; + while (text) { + let match; + + // See if the text starts with an emoji + if ((match = text.match(reEmoji))) { + components.push( + , + ); + text = text.substring(match[0].length); + continue; + } + + // Or an emoticon + if ((match = text.match(reEmoticon))) { + const emoticonName = getEmoticonName(match[0]); + if (emoticonName) { + components.push( + , + ); + text = text.substring(match[0].length); + continue; + } + } + + // This is plain text, so capture as much text as possible until we hit the next possible emoji. Note that + // reMain always captures at least one character, so text will always be getting shorter + match = text.match(reMain); + if (!match) { + continue; + } + + components.push( + + {match[0]} + , + ); + text = text.substring(match[0].length); + } + + return ( + + {components} + + ); +}; + +export default ActionButtonText; diff --git a/app/components/post_list/post/body/content/message_attachments/action_button/index.tsx b/app/components/post_list/post/body/content/message_attachments/action_button/index.tsx new file mode 100644 index 000000000..c9fb5c36e --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/action_button/index.tsx @@ -0,0 +1,87 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useRef} from 'react'; +import Button from 'react-native-button'; + +import {postActionWithCookie} from '@actions/remote/post'; +import {useServerUrl} from '@context/server_url'; +import {getStatusColors} from '@utils/message_attachment_colors'; +import {preventDoubleTap} from '@utils/tap'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; + +import ActionButtonText from './action_button_text'; + +type Props = { + buttonColor?: string; + cookie?: string; + disabled?: boolean; + id: string; + name: string; + postId: string; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + const STATUS_COLORS = getStatusColors(theme); + return { + button: { + borderRadius: 4, + borderColor: changeOpacity(STATUS_COLORS.default, 0.25), + borderWidth: 2, + opacity: 1, + alignItems: 'center', + marginTop: 12, + justifyContent: 'center', + height: 36, + }, + buttonDisabled: { + backgroundColor: changeOpacity(theme.buttonBg, 0.3), + }, + text: { + color: STATUS_COLORS.default, + fontSize: 15, + fontWeight: '600', + lineHeight: 17, + }, + }; +}); + +const ActionButton = ({buttonColor, cookie, disabled, id, name, postId, theme}: Props) => { + const presssed = useRef(false); + const serverUrl = useServerUrl(); + const style = getStyleSheet(theme); + let customButtonStyle; + let customButtonTextStyle; + + const onPress = useCallback(preventDoubleTap(async () => { + if (!presssed.current) { + presssed.current = true; + await postActionWithCookie(serverUrl, postId, id, cookie || ''); + presssed.current = false; + } + }), [id, postId, cookie]); + + if (buttonColor) { + const STATUS_COLORS = getStatusColors(theme); + const hexColor = STATUS_COLORS[buttonColor] || theme[buttonColor] || buttonColor; + customButtonStyle = {borderColor: changeOpacity(hexColor, 0.25), backgroundColor: '#ffffff'}; + customButtonTextStyle = {color: hexColor}; + } + + return ( + + ); +}; + +export default ActionButton; diff --git a/app/components/post_list/post/body/content/message_attachments/action_menu/index.tsx b/app/components/post_list/post/body/content/message_attachments/action_menu/index.tsx new file mode 100644 index 000000000..13c7e8816 --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/action_menu/index.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState} from 'react'; + +import {selectAttachmentMenuAction} from '@actions/local/post'; +import {useServerUrl} from '@context/server_url'; + +import AutocompleteSelector from '@components/autocomplete_selector'; + +type Props = { + dataSource?: string; + defaultOption?: string; + disabled?: boolean; + id: string; + name: string; + options?: PostActionOption[]; + postId: string; +} + +const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, postId}: Props) => { + let isSelected: PostActionOption | undefined; + const serverUrl = useServerUrl(); + if (defaultOption && options) { + isSelected = options.find((option) => option.value === defaultOption); + } + const [selected, setSelected] = useState(isSelected); + + const handleSelect = useCallback(async (selectedItem?: PostActionOption) => { + if (!selectedItem) { + return; + } + + const result = await selectAttachmentMenuAction(serverUrl, postId, id, selectedItem.value); + if (result.data?.trigger_id) { + setSelected(selectedItem); + } + }, []); + + return ( + + ); +}; + +export default ActionMenu; diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_actions.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_actions.tsx new file mode 100644 index 000000000..7fcdd3090 --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/attachment_actions.tsx @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import ActionButton from './action_button'; +import ActionMenu from './action_menu'; + +type Props = { + actions: PostAction[]; + postId: string; + theme: Theme; +} +const AttachmentActions = ({actions, postId, theme}: Props) => { + const content = [] as React.ReactNode[]; + + actions.forEach((action) => { + if (!action.id || !action.name) { + return; + } + + switch (action.type) { + case 'select': + content.push( + , + ); + break; + case 'button': + default: + content.push( + , + ); + break; + } + }); + + return content.length ? (<>{content}) : null; +}; + +export default AttachmentActions; diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_author.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_author.tsx new file mode 100644 index 000000000..f531b9441 --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/attachment_author.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {Alert, Text, View} from 'react-native'; +import FastImage from 'react-native-fast-image'; + +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {tryOpenURL} from '@utils/url'; + +type Props = { + icon?: string; + link?: string; + name?: string; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + flexDirection: 'row', + }, + icon: { + height: 12, + marginRight: 3, + width: 12, + }, + name: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 11, + }, + link: {color: changeOpacity(theme.linkColor, 0.5)}, + }; +}); + +const AttachmentAuthor = ({icon, link, name, theme}: Props) => { + const intl = useIntl(); + const style = getStyleSheet(theme); + + const openLink = () => { + if (link) { + const onError = () => { + Alert.alert( + intl.formatMessage({ + id: 'mobile.link.error.title', + defaultMessage: 'Error', + }), + intl.formatMessage({ + id: 'mobile.link.error.text', + defaultMessage: 'Unable to open the link.', + }), + ); + }; + + tryOpenURL(link, onError); + } + }; + + return ( + + {Boolean(icon) && + + } + {Boolean(name) && + + {name} + + } + + ); +}; + +export default AttachmentAuthor; diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_fields.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_fields.tsx new file mode 100644 index 000000000..0c6522726 --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/attachment_fields.tsx @@ -0,0 +1,128 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, Text, TextStyle, View} from 'react-native'; + +import Markdown from '@components/markdown'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import type {MarkdownBlockStyles, MarkdownTextStyles} from '@typings/global/markdown'; + +type Props = { + baseTextStyle: StyleProp; + blockStyles?: MarkdownBlockStyles; + fields: MessageAttachmentField[]; + metadata?: PostMetadata; + textStyles?: MarkdownTextStyles; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + field: { + alignSelf: 'stretch', + flexDirection: 'row', + }, + flex: { + flex: 1, + }, + headingContainer: { + alignSelf: 'stretch', + flexDirection: 'row', + marginBottom: 5, + marginTop: 10, + }, + heading: { + color: theme.centerChannelColor, + fontWeight: '600', + }, + table: { + flex: 1, + flexDirection: 'row', + }, + }; +}); + +const AttachmentFields = ({baseTextStyle, blockStyles, fields, metadata, textStyles, theme}: Props) => { + const style = getStyleSheet(theme); + const fieldTables = []; + + let fieldInfos = [] as React.ReactNode[]; + let rowPos = 0; + let lastWasLong = false; + let nrTables = 0; + + fields.forEach((field, i) => { + if (rowPos === 2 || !(field.short === true) || lastWasLong) { + fieldTables.push( + + {fieldInfos} + , + ); + fieldInfos = []; + rowPos = 0; + nrTables += 1; + lastWasLong = false; + } + + fieldInfos.push( + + {Boolean(field.title) && ( + + + + {field.title} + + + + )} + + + + , + ); + + rowPos += 1; + lastWasLong = !(field.short === true); + }); + + if (fieldInfos.length > 0) { // Flush last fields + fieldTables.push( + + {fieldInfos} + , + ); + } + + return ( + + {fieldTables} + + ); +}; + +export default AttachmentFields; diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_footer.test.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_footer.test.tsx new file mode 100644 index 000000000..80953cc7e --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/attachment_footer.test.tsx @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import Preferences from '@constants/preferences'; +import {render} from '@test/intl-test-helper'; + +import AttachmentFooter from './attachment_footer'; + +describe('AttachmentFooter', () => { + const baseProps = { + text: 'This is the footer!', + icon: 'https://images.com/image.png', + theme: Preferences.THEMES.denim, + }; + + test('it matches snapshot when footer text is provided', () => { + const props = { + ...baseProps, + icon: undefined, + }; + + const wrapper = render(); + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + test('it matches snapshot when both footer and footer_icon are provided', () => { + const wrapper = render(); + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_footer.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_footer.tsx new file mode 100644 index 000000000..3b966cb0f --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/attachment_footer.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 {Text, View, Platform} from 'react-native'; +import FastImage from 'react-native-fast-image'; + +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + icon?: string; + text: string; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + flexDirection: 'row', + marginTop: 5, + }, + icon: { + height: 12, + width: 12, + marginRight: 5, + marginTop: Platform.select({android: 2, ios: 1}), + }, + text: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 11, + }, + }; +}); + +const AttachmentFooter = ({icon, text, theme}: Props) => { + const style = getStyleSheet(theme); + + return ( + + {Boolean(icon) && + + } + + {text} + + + ); +}; + +export default AttachmentFooter; diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx new file mode 100644 index 000000000..896d22f84 --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useRef, useState} from 'react'; +import {View} from 'react-native'; + +import FileIcon from '@app/components/post_list/post/body/files/file_icon'; +import ProgressiveImage from '@components/progressive_image'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Device} from '@constants'; +import {useSplitView} from '@hooks/device'; +import {generateId} from '@utils/general'; +import {openGallerWithMockFile} from '@utils/gallery'; +import {isGifTooLarge, calculateDimensions, getViewPortWidth} from '@utils/images'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {isValidUrl} from '@utils/url'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + attachmentMargin: { + marginTop: 2.5, + marginLeft: 2.5, + marginBottom: 5, + marginRight: 5, + }, + container: {marginTop: 5}, + imageContainer: { + borderColor: changeOpacity(theme.centerChannelColor, 0.1), + borderWidth: 1, + borderRadius: 2, + flex: 1, + }, + image: { + alignItems: 'center', + borderRadius: 3, + justifyContent: 'center', + marginVertical: 1, + }, + }; +}); + +export type Props = { + imageMetadata: PostImage; + imageUrl: string; + postId: string; + theme: Theme; +} + +const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => { + const [error, setError] = useState(false); + const fileId = useRef(generateId()).current; + const splitView = useSplitView(); + const tabletOffset = !splitView && Device.IS_TABLET; + const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, getViewPortWidth(false, tabletOffset)); + const style = getStyleSheet(theme); + + const onError = useCallback(() => { + setError(true); + }, []); + + const onPress = useCallback(() => { + openGallerWithMockFile(imageUrl, postId, imageMetadata.height, imageMetadata.width); + }, [imageUrl]); + + if (error || !isValidUrl(imageUrl) || isGifTooLarge(imageMetadata)) { + return ( + + + + + + ); + } + + return ( + + + + + + ); +}; + +export default AttachmentImage; diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_pretext.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_pretext.tsx new file mode 100644 index 000000000..adbaa46c7 --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/attachment_pretext.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, StyleSheet, TextStyle, View} from 'react-native'; + +import Markdown from '@components/markdown'; + +import type {MarkdownBlockStyles, MarkdownTextStyles} from '@typings/global/markdown'; + +type Props = { + baseTextStyle: StyleProp; + blockStyles?: MarkdownBlockStyles; + metadata?: PostMetadata; + textStyles?: MarkdownTextStyles; + theme: Theme; + value?: string; +} + +const style = StyleSheet.create({ + container: { + marginTop: 5, + }, +}); + +export default function AttachmentPreText(props: Props) { + const { + baseTextStyle, + blockStyles, + metadata, + value, + textStyles, + } = props; + + if (!value) { + return null; + } + + return ( + + + + ); +} diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_text.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_text.tsx new file mode 100644 index 000000000..c734bfa20 --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/attachment_text.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState} from 'react'; +import {LayoutChangeEvent, useWindowDimensions, ScrollView, StyleProp, StyleSheet, TextStyle, View} from 'react-native'; +import Animated from 'react-native-reanimated'; + +import Markdown from '@components/markdown'; +import ShowMoreButton from '@app/components/post_list/post/body/message/show_more_button'; +import {useShowMoreAnimatedStyle} from '@hooks/show_more'; + +import type {MarkdownBlockStyles, MarkdownTextStyles} from '@typings/global/markdown'; + +type Props = { + baseTextStyle: StyleProp; + blockStyles?: MarkdownBlockStyles; + hasThumbnail?: boolean; + metadata?: PostMetadata; + textStyles?: MarkdownTextStyles; + theme: Theme; + value?: string; +} + +const SHOW_MORE_HEIGHT = 54; +const style = StyleSheet.create({ + container: { + paddingRight: 12, + }, +}); + +const AttachmentText = ({baseTextStyle, blockStyles, hasThumbnail, metadata, textStyles, theme, value}: Props) => { + const [open, setOpen] = useState(false); + const [height, setHeight] = useState(); + const dimensions = useWindowDimensions(); + const maxHeight = Math.round((dimensions.height * 0.4) + SHOW_MORE_HEIGHT); + const animatedStyle = useShowMoreAnimatedStyle(height, maxHeight, open); + + const onLayout = useCallback((event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), []); + const onPress = () => setOpen(!open); + + return ( + + + + + + + + + {(height || 0) > maxHeight && + + } + + ); +}; + +export default AttachmentText; diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_thumbnail.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_thumbnail.tsx new file mode 100644 index 000000000..18b3314a3 --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/attachment_thumbnail.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; +import FastImage from 'react-native-fast-image'; + +type Props = { + uri: string; +} + +const style = StyleSheet.create({ + container: { + position: 'absolute', + right: 10, + top: 10, + }, + image: { + height: 45, + width: 45, + }, +}); + +const AttachmentThumbnail = ({uri}: Props) => { + return ( + + + + ); +}; + +export default AttachmentThumbnail; diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx new file mode 100644 index 000000000..e177a237e --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx @@ -0,0 +1,95 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {Alert, Text, View} from 'react-native'; + +import Markdown from '@components/markdown'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {tryOpenURL} from '@utils/url'; + +type Props = { + link?: string; + theme: Theme; + value?: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + flexDirection: 'row', + marginTop: 3, + }, + link: {color: theme.linkColor}, + title: { + color: theme.centerChannelColor, + fontSize: 14, + fontWeight: '600', + lineHeight: 20, + marginBottom: 5, + }, + }; +}); + +const AttachmentTitle = ({link, theme, value}: Props) => { + const intl = useIntl(); + const style = getStyleSheet(theme); + + const openLink = () => { + if (link) { + const onError = () => { + Alert.alert( + intl.formatMessage({ + id: 'mobile.link.error.title', + defaultMessage: 'Error', + }), + intl.formatMessage({ + id: 'mobile.link.error.text', + defaultMessage: 'Unable to open the link.', + }), + ); + }; + + tryOpenURL(link, onError); + } + }; + + let title; + if (link) { + title = ( + + {value} + + ); + } else { + title = ( + + ); + } + + return ( + + {title} + + ); +}; + +export default AttachmentTitle; diff --git a/app/components/post_list/post/body/content/message_attachments/index.tsx b/app/components/post_list/post/body/content/message_attachments/index.tsx new file mode 100644 index 000000000..9657afa84 --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/index.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +import MessageAttachment from './message_attachment'; + +type Props = { + attachments: MessageAttachment[]; + postId: string; + metadata?: PostMetadata; + theme: Theme; +} + +const styles = StyleSheet.create({ + content: { + flex: 1, + flexDirection: 'column', + }, +}); + +const MessageAttachments = ({attachments, metadata, postId, theme}: Props) => { + const content = [] as React.ReactNode[]; + + attachments.forEach((attachment, i) => { + content.push( + , + ); + }); + + return ( + + {content} + + ); +}; + +export default MessageAttachments; diff --git a/app/components/post_list/post/body/content/message_attachments/message_attachment.tsx b/app/components/post_list/post/body/content/message_attachments/message_attachment.tsx new file mode 100644 index 000000000..a79fba1f0 --- /dev/null +++ b/app/components/post_list/post/body/content/message_attachments/message_attachment.tsx @@ -0,0 +1,142 @@ +// 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 {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown'; +import {getStatusColors} from '@utils/message_attachment_colors'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {isValidUrl} from '@utils/url'; + +import AttachmentActions from './attachment_actions'; +import AttachmentAuthor from './attachment_author'; +import AttachmentFields from './attachment_fields'; +import AttachmentFooter from './attachment_footer'; +import AttachmentImage from './attachment_image'; +import AttachmentPreText from './attachment_pretext'; +import AttachmentText from './attachment_text'; +import AttachmentThumbnail from './attachment_thumbnail'; +import AttachmentTitle from './attachment_title'; + +type Props = { + attachment: MessageAttachment; + metadata?: PostMetadata; + postId: string; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.15), + borderRightColor: changeOpacity(theme.centerChannelColor, 0.15), + borderTopColor: changeOpacity(theme.centerChannelColor, 0.15), + borderBottomWidth: 1, + borderRightWidth: 1, + borderTopWidth: 1, + marginTop: 5, + padding: 12, + }, + border: { + borderLeftColor: changeOpacity(theme.linkColor, 0.6), + borderLeftWidth: 3, + }, + message: { + color: theme.centerChannelColor, + fontSize: 15, + lineHeight: 20, + }, + }; +}); + +export default function MessageAttachment({attachment, metadata, postId, theme}: Props) { + const style = getStyleSheet(theme); + const blockStyles = getMarkdownBlockStyles(theme); + const textStyles = getMarkdownTextStyles(theme); + const STATUS_COLORS = getStatusColors(theme); + let borderStyle; + if (attachment.color) { + if (attachment.color[0] === '#') { + borderStyle = {borderLeftColor: attachment.color}; + } else if (STATUS_COLORS[attachment.color]) { + borderStyle = {borderLeftColor: STATUS_COLORS[attachment.color]}; + } + } + + return ( + <> + + + {Boolean(attachment.author_icon && attachment.author_name) && + + } + {Boolean(attachment.title) && + + } + {isValidUrl(attachment.thumb_url) && + + } + {Boolean(attachment.text) && + + } + {Boolean(attachment.fields?.length) && + + } + {Boolean(attachment.footer) && + + } + {Boolean(attachment.actions?.length) && + + } + {Boolean(metadata?.images?.[attachment.image_url]) && + + } + + + ); +} diff --git a/app/components/post_list/post/body/content/opengraph/index.tsx b/app/components/post_list/post/body/content/opengraph/index.tsx new file mode 100644 index 000000000..0e0c8199f --- /dev/null +++ b/app/components/post_list/post/body/content/opengraph/index.tsx @@ -0,0 +1,201 @@ +// 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 React from 'react'; +import {useIntl} from 'react-intl'; +import {Alert, Text, View} from 'react-native'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Preferences} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {getPreferenceAsBool} from '@helpers/api/preference'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {tryOpenURL} from '@utils/url'; + +import OpengraphImage from './opengraph_image'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PreferenceModel from '@typings/database/models/servers/preference'; +import type SystemModel from '@typings/database/models/servers/system'; + +type OpengraphProps = { + isReplyPost: boolean; + metadata: PostMetadata; + postId: string; + showLinkPreviews: boolean; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRadius: 3, + borderWidth: 1, + flex: 1, + marginTop: 10, + padding: 10, + }, + flex: {flex: 1}, + siteDescription: { + color: changeOpacity(theme.centerChannelColor, 0.7), + fontSize: 13, + marginBottom: 10, + }, + siteName: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 12, + marginBottom: 10, + }, + siteTitle: { + color: theme.linkColor, + fontSize: 14, + marginBottom: 10, + }, + }; +}); + +const selectOpenGraphData = (url: string, metadata: PostMetadata) => { + if (!metadata.embeds) { + return undefined; + } + + return metadata.embeds.find((embed) => { + return embed.type === 'opengraph' && embed.url === url ? embed.data : undefined; + })?.data; +}; + +const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => { + const intl = useIntl(); + const link = metadata.embeds![0]!.url; + const openGraphData = selectOpenGraphData(link, metadata); + + if (!showLinkPreviews || !openGraphData) { + return null; + } + + const style = getStyleSheet(theme); + const hasImage = Boolean( + openGraphData?.images && + openGraphData.images instanceof Array && + openGraphData.images.length && + metadata.images); + + const goToLink = () => { + const onError = () => { + Alert.alert( + intl.formatMessage({ + id: 'mobile.link.error.title', + defaultMessage: 'Error', + }), + intl.formatMessage({ + id: 'mobile.link.error.text', + defaultMessage: 'Unable to open the link.', + }), + ); + }; + + tryOpenURL(link, onError); + }; + + let siteName; + if (openGraphData.site_name) { + siteName = ( + + + {openGraphData.site_name as string} + + + ); + } + + const title = openGraphData.title || openGraphData.url || link; + let siteTitle; + if (title) { + siteTitle = ( + + + + {title as string} + + + + ); + } + + let siteDescription; + if (openGraphData.description) { + siteDescription = ( + + + {openGraphData.description as string} + + + ); + } + + return ( + + {siteName} + {siteTitle} + {siteDescription} + {hasImage && + + } + + ); +}; + +const withOpenGraphInput = withObservables( + ['removeLinkPreview'], ({database, removeLinkPreview}: WithDatabaseArgs & {removeLinkPreview: boolean}) => { + if (removeLinkPreview) { + return {showLinkPreviews: of$(false)}; + } + + const showLinkPreviews = database.get(MM_TABLES.SERVER.PREFERENCE).query( + Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS), + Q.where('name', Preferences.LINK_PREVIEW_DISPLAY), + ).observe().pipe( + switchMap( + (preferences: PreferenceModel[]) => database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + // eslint-disable-next-line max-nested-callbacks + switchMap((config: SystemModel) => { + const cfg: ClientConfig = config.value; + const previewsEnabled = getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.LINK_PREVIEW_DISPLAY, true); + return of$(previewsEnabled && cfg.EnableLinkPreviews === 'true'); + }), + ), + ), + ); + + return {showLinkPreviews}; + }); + +export default withDatabase(withOpenGraphInput(React.memo(Opengraph))); diff --git a/app/components/post_list/post/body/content/opengraph/opengraph_image/index.tsx b/app/components/post_list/post/body/content/opengraph/opengraph_image/index.tsx new file mode 100644 index 000000000..79db5e087 --- /dev/null +++ b/app/components/post_list/post/body/content/opengraph/opengraph_image/index.tsx @@ -0,0 +1,111 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useRef} from 'react'; +import {useWindowDimensions, View} from 'react-native'; +import FastImage, {Source} from 'react-native-fast-image'; + +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Device as DeviceConstant, View as ViewConstants} from '@constants'; +import {openGallerWithMockFile} from '@utils/gallery'; +import {generateId} from '@utils/general'; +import {calculateDimensions} from '@utils/images'; +import {BestImage, getNearestPoint} from '@utils/opengraph'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {isValidUrl} from '@utils/url'; + +type OpengraphImageProps = { + isReplyPost: boolean; + metadata: PostMetadata; + openGraphImages: never[]; + postId: string; + theme: Theme; +} + +const MAX_IMAGE_HEIGHT = 150; +const VIEWPORT_IMAGE_OFFSET = 93; +const VIEWPORT_IMAGE_REPLY_OFFSET = 13; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + imageContainer: { + alignItems: 'center', + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderWidth: 1, + borderRadius: 3, + marginTop: 5, + }, + image: { + borderRadius: 3, + }, + }; +}); + +const getViewPostWidth = (isReplyPost: boolean, deviceHeight: number, deviceWidth: number) => { + const deviceSize = deviceWidth > deviceHeight ? deviceHeight : deviceWidth; + const viewPortWidth = deviceSize - VIEWPORT_IMAGE_OFFSET - (isReplyPost ? VIEWPORT_IMAGE_REPLY_OFFSET : 0); + const tabletOffset = DeviceConstant.IS_TABLET ? ViewConstants.TABLET.SIDEBAR_WIDTH : 0; + + return viewPortWidth - tabletOffset; +}; + +const OpengraphImage = ({isReplyPost, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => { + const fileId = useRef(generateId()).current; + const dimensions = useWindowDimensions(); + const style = getStyleSheet(theme); + const bestDimensions = { + height: MAX_IMAGE_HEIGHT, + width: getViewPostWidth(isReplyPost, dimensions.height, dimensions.width), + }; + const bestImage = getNearestPoint(bestDimensions, openGraphImages, 'width', 'height') as BestImage; + const imageUrl = (bestImage.secure_url || bestImage.url)!; + const imagesMetadata = metadata.images; + + let ogImage; + if (imagesMetadata && imagesMetadata[imageUrl]) { + ogImage = imagesMetadata[imageUrl]; + } + + if (!ogImage) { + ogImage = openGraphImages.find((i: BestImage) => i.url === imageUrl || i.secure_url === imageUrl); + } + + // Fallback when the ogImage does not have dimensions but there is a metaImage defined + const metaImages = imagesMetadata ? Object.values(imagesMetadata) : null; + if ((!ogImage?.width || !ogImage?.height) && metaImages?.length) { + ogImage = metaImages[0]; + } + + let imageDimensions = bestDimensions; + if (ogImage?.width && ogImage?.height) { + imageDimensions = calculateDimensions(ogImage.height, ogImage.width, getViewPostWidth(isReplyPost, dimensions.height, dimensions.width)); + } + + const onPress = useCallback(() => { + openGallerWithMockFile(imageUrl, postId, imageDimensions.height, imageDimensions.width, fileId); + }, []); + + const source: Source = {}; + if (isValidUrl(imageUrl)) { + source.uri = imageUrl; + } + + const dimensionsStyle = {width: imageDimensions.width, height: imageDimensions.height}; + return ( + + + + + + ); +}; + +export default OpengraphImage; diff --git a/app/components/post_list/post/body/content/youtube/index.tsx b/app/components/post_list/post/body/content/youtube/index.tsx new file mode 100644 index 000000000..73e3e102f --- /dev/null +++ b/app/components/post_list/post/body/content/youtube/index.tsx @@ -0,0 +1,196 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {Alert, Image, Platform, StatusBar, StyleSheet} from 'react-native'; +import {YouTubeStandaloneAndroid, YouTubeStandaloneIOS} from 'react-native-youtube'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import ProgressiveImage from '@components/progressive_image'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Device} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useSplitView} from '@hooks/device'; +import {emptyFunction} from '@utils/general'; +import {calculateDimensions, getViewPortWidth} from '@utils/images'; +import {getYouTubeVideoId, tryOpenURL} from '@utils/url'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type SystemModel from '@typings/database/models/servers/system'; + +type YouTubeProps = { + googleDeveloperKey?: string; + isReplyPost: boolean; + metadata: PostMetadata; +} + +const MAX_YOUTUBE_IMAGE_HEIGHT = 202; +const MAX_YOUTUBE_IMAGE_WIDTH = 360; +const timeRegex = /[\\?&](t|start|time_continue)=([0-9]+h)?([0-9]+m)?([0-9]+s?)/; + +const styles = StyleSheet.create({ + imageContainer: { + alignItems: 'flex-start', + justifyContent: 'flex-start', + marginBottom: 6, + marginTop: 10, + }, + image: { + alignItems: 'center', + borderRadius: 3, + justifyContent: 'center', + marginVertical: 1, + }, + playButton: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +const YouTube = ({googleDeveloperKey, isReplyPost, metadata}: YouTubeProps) => { + const intl = useIntl(); + const splitView = useSplitView(); + const link = metadata.embeds![0].url; + const videoId = getYouTubeVideoId(link); + const tabletOffset = !splitView && Device.IS_TABLET; + const dimensions = calculateDimensions( + MAX_YOUTUBE_IMAGE_HEIGHT, + MAX_YOUTUBE_IMAGE_WIDTH, + getViewPortWidth(isReplyPost, tabletOffset), + ); + + const getYouTubeTime = () => { + const time = link.match(timeRegex); + if (!time || !time[0]) { + return 0; + } + + const hours = time[2] ? time[2].match(/([0-9]+)h/) : null; + const minutes = time[3] ? time[3].match(/([0-9]+)m/) : null; + const seconds = time[4] ? time[4].match(/([0-9]+)s?/) : null; + + let ticks = 0; + + if (hours && hours[1]) { + ticks += parseInt(hours[1], 10) * 3600; + } + + if (minutes && minutes[1]) { + ticks += parseInt(minutes[1], 10) * 60; + } + + if (seconds && seconds[1]) { + ticks += parseInt(seconds[1], 10); + } + + return ticks; + }; + + const playYouTubeVideo = useCallback(() => { + const startTime = getYouTubeTime(); + + if (googleDeveloperKey) { + if (Platform.OS === 'ios') { + YouTubeStandaloneIOS. + playVideo(videoId, startTime). + then(playYouTubeVideoEnded). + catch(playYouTubeVideoError); + return; + } + + YouTubeStandaloneAndroid.playVideo({ + apiKey: googleDeveloperKey, + videoId, + autoplay: true, + startTime, + }).catch(playYouTubeVideoError); + } else { + const onError = () => { + Alert.alert( + intl.formatMessage({ + id: 'mobile.link.error.title', + defaultMessage: 'Error', + }), + intl.formatMessage({ + id: 'mobile.link.error.text', + defaultMessage: 'Unable to open the link.', + }), + ); + }; + + tryOpenURL(link, onError); + } + }, []); + + const playYouTubeVideoEnded = () => { + if (Platform.OS === 'ios') { + StatusBar.setHidden(false); + } + }; + + const playYouTubeVideoError = (errorMessage: string) => { + Alert.alert( + intl.formatMessage({ + id: 'mobile.youtube_playback_error.title', + defaultMessage: 'YouTube playback error', + }), + intl.formatMessage({ + id: 'mobile.youtube_playback_error.description', + defaultMessage: 'An error occurred while trying to play the YouTube video.\nDetails: {details}', + }, { + details: errorMessage, + }), + ); + }; + + let imgUrl; + if (metadata.images) { + imgUrl = Object.keys(metadata.images)[0]; + } + + if (!imgUrl) { + // Fallback to default YouTube thumbnail if available + imgUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`; + } + + return ( + + + + + + + + ); +}; + +const withGoogleKey = withObservables([], ({database}: WithDatabaseArgs) => ({ + googleDeveloperKey: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((config: SystemModel) => { + const cfg: ClientConfig = config.value; + return of$(cfg.GoogleDeveloperKey); + }), + ), +})); + +export default withDatabase(withGoogleKey(React.memo(YouTube))); diff --git a/app/components/post_list/post/body/failed/index.tsx b/app/components/post_list/post/body/failed/index.tsx new file mode 100644 index 000000000..b350bbc5b --- /dev/null +++ b/app/components/post_list/post/body/failed/index.tsx @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter, StyleSheet, View} from 'react-native'; + +import {removePost} from '@actions/local/post'; +import CompassIcon from '@components/compass_icon'; +import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import Navigation from '@constants/navigation'; +import {useServerUrl} from '@context/server_url'; +import {showModalOverCurrentContext} from '@screens/navigation'; + +import type PostModel from '@typings/database/models/servers/post'; + +type FailedProps = { + post: PostModel; + theme: Theme; +} + +const styles = StyleSheet.create({ + bottomSheet: { + flex: 1, + }, + retry: { + justifyContent: 'center', + marginLeft: 10, + }, +}); + +// TODO: Add Create post local action +const retryPost = (serverUrl: string, post: PostModel) => post; + +const Failed = ({post, theme}: FailedProps) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const onPress = useCallback(() => { + const renderContent = () => { + return ( + + { + DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL); + retryPost(serverUrl, post); + }} + testID='post.failed.retry' + text={intl.formatMessage({id: 'mobile.post.failed_retry', defaultMessage: 'Try Again'})} + /> + { + DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL); + removePost(serverUrl, post); + }} + testID='post.failed.delete' + text={intl.formatMessage({id: 'mobile.post.failed_delete', defaultMessage: 'Delete Message'})} + /> + + ); + }; + + showModalOverCurrentContext('BottomSheet', { + renderContent, + snapPoints: [3 * ITEM_HEIGHT, 10], + }); + }, []); + + return ( + + + + ); +}; + +export default Failed; diff --git a/app/components/post_list/post/body/files/document_file.tsx b/app/components/post_list/post/body/files/document_file.tsx new file mode 100644 index 000000000..5c44b942b --- /dev/null +++ b/app/components/post_list/post/body/files/document_file.tsx @@ -0,0 +1,203 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client'; +import React, {forwardRef, useImperativeHandle, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Platform, StatusBar, StatusBarStyle, StyleSheet, View} from 'react-native'; +import FileViewer from 'react-native-file-viewer'; +import {FileSystem} from 'react-native-unimodules'; +import tinyColor from 'tinycolor2'; + +import ProgressBar from '@components/progress_bar'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Device} from '@constants'; +import {DOWNLOAD_TIMEOUT} from '@constants/network'; +import {useServerUrl} from '@context/server_url'; +import NetworkManager from '@init/network_manager'; +import {alertDownloadDocumentDisabled, alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document'; +import {getLocalFilePathFromFile} from '@utils/file'; + +import FileIcon from './file_icon'; + +import type {Client} from '@client/rest'; + +export type DocumentFileRef = { + handlePreviewPress: () => void; +} + +type DocumentFileProps = { + backgroundColor?: string; + canDownloadFiles: boolean; + file: FileInfo; + theme: Theme; +} + +const {DOCUMENTS_PATH} = Device; +const styles = StyleSheet.create({ + progress: { + justifyContent: 'flex-end', + height: 48, + left: 2, + top: 5, + width: 44, + }, +}); + +const DocumentFile = forwardRef(({backgroundColor, canDownloadFiles, file, theme}: DocumentFileProps, ref) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const [didCancel, setDidCancel] = useState(false); + const [downloading, setDownloading] = useState(false); + const [preview, setPreview] = useState(false); + const [progress, setProgress] = useState(0); + let client: Client | undefined; + try { + client = NetworkManager.getClient(serverUrl); + } catch { + // do nothing + } + const downloadTask = useRef>(); + + const cancelDownload = () => { + setDidCancel(true); + if (downloadTask.current?.cancel) { + downloadTask.current.cancel(); + } + }; + + const downloadAndPreviewFile = async () => { + const path = getLocalFilePathFromFile(DOCUMENTS_PATH, serverUrl, file); + setDidCancel(false); + + try { + let exists = false; + if (path) { + const info = await FileSystem.getInfoAsync(path); + exists = info.exists; + } + if (exists) { + openDocument(); + } else { + setDownloading(true); + downloadTask.current = client?.apiClient.download(client?.getFileRoute(file.id!), path!, {timeoutInterval: DOWNLOAD_TIMEOUT}); + downloadTask.current?.progress?.(setProgress); + + await downloadTask.current; + setProgress(1); + openDocument(); + } + } catch (error) { + if (path) { + FileSystem.deleteAsync(path, {idempotent: true}); + } + setDownloading(false); + setProgress(0); + + if (error.message !== 'cancelled') { + alertDownloadFailed(intl); + } + } + }; + + const handlePreviewPress = async () => { + if (!canDownloadFiles) { + alertDownloadDocumentDisabled(intl); + return; + } + + if (downloading && progress < 1) { + cancelDownload(); + } else if (downloading) { + setProgress(0); + setDidCancel(true); + setDownloading(false); + } else { + downloadAndPreviewFile(); + } + }; + + const onDonePreviewingFile = () => { + setProgress(0); + setDownloading(false); + setPreview(false); + setStatusBarColor(); + }; + + const openDocument = () => { + if (!didCancel && !preview) { + const path = getLocalFilePathFromFile(DOCUMENTS_PATH, serverUrl, file); + setPreview(true); + setStatusBarColor('dark-content'); + FileViewer.open(path!, { + displayName: file.name, + onDismiss: onDonePreviewingFile, + showOpenWithDialog: true, + showAppsSuggestions: true, + }).then(() => { + setDownloading(false); + setProgress(0); + }).catch(() => { + alertFailedToOpenDocument(file, intl); + onDonePreviewingFile(); + + if (path) { + FileSystem.deleteAsync(path, {idempotent: true}); + } + }); + } + }; + + const setStatusBarColor = (style: StatusBarStyle = 'light-content') => { + if (Platform.OS === 'ios') { + if (style) { + StatusBar.setBarStyle(style, true); + } else { + const headerColor = tinyColor(theme.sidebarHeaderBg); + let barStyle: StatusBarStyle = 'light-content'; + if (headerColor.isLight() && Platform.OS === 'ios') { + barStyle = 'dark-content'; + } + StatusBar.setBarStyle(barStyle, true); + } + } + }; + + useImperativeHandle(ref, () => ({ + handlePreviewPress, + }), []); + + const icon = ( + + ); + + let fileAttachmentComponent = icon; + if (downloading) { + fileAttachmentComponent = ( + <> + {icon} + + + + + ); + } + + return ( + + {fileAttachmentComponent} + + ); +}); + +export default DocumentFile; diff --git a/app/components/post_list/post/body/files/file.tsx b/app/components/post_list/post/body/files/file.tsx new file mode 100644 index 000000000..2252f79e3 --- /dev/null +++ b/app/components/post_list/post/body/files/file.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useRef} from 'react'; +import {View} from 'react-native'; + +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {isDocument, isImage} from '@utils/file'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import DocumentFile, {DocumentFileRef} from './document_file'; +import FileIcon from './file_icon'; +import FileInfo from './file_info'; +import ImageFile from './image_file'; +import ImageFileOverlay from './image_file_overlay'; + +type FileProps = { + canDownloadFiles: boolean; + file: FileInfo; + index: number; + inViewPort: boolean; + isSingleImage: boolean; + nonVisibleImagesCount: number; + onPress: (index: number) => void; + theme: Theme; + wrapperWidth?: number; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + fileWrapper: { + flex: 1, + flexDirection: 'row', + marginTop: 10, + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.4), + borderRadius: 5, + }, + iconWrapper: { + marginTop: 7.8, + marginRight: 6, + marginBottom: 8.2, + marginLeft: 8, + }, + }; +}); + +const File = ({ + canDownloadFiles, file, index = 0, inViewPort = false, isSingleImage = false, + nonVisibleImagesCount = 0, onPress, theme, wrapperWidth = 300, +}: FileProps) => { + const document = useRef(null); + const style = getStyleSheet(theme); + + const handlePress = () => { + onPress(index); + }; + + const handlePreviewPress = () => { + if (document.current) { + document.current.handlePreviewPress(); + } else { + handlePress(); + } + }; + + if (isImage(file)) { + return ( + + + {Boolean(nonVisibleImagesCount) && + + } + + ); + } + + if (isDocument(file)) { + return ( + + + + + + + ); + } + + return ( + + + + + + + + + ); +}; + +export default File; diff --git a/app/components/post_list/post/body/files/file_icon.tsx b/app/components/post_list/post/body/files/file_icon.tsx new file mode 100644 index 000000000..16413fc1a --- /dev/null +++ b/app/components/post_list/post/body/files/file_icon.tsx @@ -0,0 +1,90 @@ +// 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 CompassIcon from '@components/compass_icon'; +import {getFileType} from '@utils/file'; + +type FileIconProps = { + backgroundColor?: string; + defaultImage?: boolean; + failed?: boolean; + file?: FileInfo; + iconColor?: string; + iconSize?: number; + smallImage?: boolean; + theme: Theme; +} + +const BLUE_ICON = '#338AFF'; +const RED_ICON = '#ED522A'; +const GREEN_ICON = '#1CA660'; +const GRAY_ICON = '#999999'; +const FAILED_ICON_NAME_AND_COLOR = ['file-image-broken-outline-large', GRAY_ICON]; +const ICON_NAME_AND_COLOR_FROM_FILE_TYPE: Record = { + audio: ['file-audio-outline-large', BLUE_ICON], + code: ['file-code-outline-large', BLUE_ICON], + image: ['file-image-outline-large', BLUE_ICON], + smallImage: ['image-outline', BLUE_ICON], + other: ['file-generic-outline-large', BLUE_ICON], + patch: ['file-patch-outline-large', BLUE_ICON], + pdf: ['file-pdf-outline-large', RED_ICON], + presentation: ['file-powerpoint-outline-large', RED_ICON], + spreadsheet: ['file-excel-outline-large', GREEN_ICON], + text: ['file-text-outline-large', GRAY_ICON], + video: ['file-video-outline-large', BLUE_ICON], + word: ['file-word-outline-large', BLUE_ICON], + zip: ['file-zip-outline-large', BLUE_ICON], +}; + +const styles = StyleSheet.create({ + fileIconWrapper: { + borderRadius: 4, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +const FileIcon = ({ + backgroundColor, defaultImage = false, failed = false, file, + iconColor, iconSize = 48, smallImage = false, theme, +}: FileIconProps) => { + const getFileIconNameAndColor = () => { + if (failed) { + return FAILED_ICON_NAME_AND_COLOR; + } + + if (defaultImage) { + if (smallImage) { + return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.smallImage; + } + + return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.image; + } + + if (file) { + const fileType = getFileType(file); + return ICON_NAME_AND_COLOR_FROM_FILE_TYPE[fileType] || ICON_NAME_AND_COLOR_FROM_FILE_TYPE.other; + } + + return ICON_NAME_AND_COLOR_FROM_FILE_TYPE.other; + }; + + const [iconName, defaultIconColor] = getFileIconNameAndColor(); + const color = iconColor || defaultIconColor; + const bgColor = backgroundColor || theme?.centerChannelBg || 'transparent'; + + return ( + + + + ); +}; + +export default FileIcon; diff --git a/app/components/post_list/post/body/files/file_info.tsx b/app/components/post_list/post/body/files/file_info.tsx new file mode 100644 index 000000000..1ee077dd7 --- /dev/null +++ b/app/components/post_list/post/body/files/file_info.tsx @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, View} from 'react-native'; + +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {getFormattedFileSize} from '@utils/file'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type FileInfoProps = { + file: FileInfo; + onPress: () => void; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + attachmentContainer: { + flex: 1, + justifyContent: 'center', + }, + fileDownloadContainer: { + flexDirection: 'row', + marginTop: 3, + }, + fileInfo: { + fontSize: 14, + color: theme.centerChannelColor, + }, + fileName: { + flexDirection: 'column', + flexWrap: 'wrap', + fontSize: 14, + fontWeight: '600', + color: theme.centerChannelColor, + paddingRight: 10, + }, + }; +}); + +const FileInfo = ({file, onPress, theme}: FileInfoProps) => { + const style = getStyleSheet(theme); + + return ( + + <> + + {file.name.trim()} + + + + {`${getFormattedFileSize(file)}`} + + + + + ); +}; + +export default FileInfo; diff --git a/app/components/post_list/post/body/files/image_file.tsx b/app/components/post_list/post/body/files/image_file.tsx new file mode 100644 index 000000000..27868b3a0 --- /dev/null +++ b/app/components/post_list/post/body/files/image_file.tsx @@ -0,0 +1,205 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useState} from 'react'; +import {StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle} from 'react-native'; + +import ProgressiveImage from '@components/progressive_image'; +import {useServerUrl} from '@context/server_url'; +import NetworkManager from '@init/network_manager'; +import {calculateDimensions} from '@utils/images'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import FileIcon from './file_icon'; + +import type {ResizeMode} from 'react-native-fast-image'; + +import type {Client} from '@client/rest'; + +type ImageFileProps = { + backgroundColor?: string; + file: FileInfo; + inViewPort?: boolean; + isSingleImage?: boolean; + resizeMode?: ResizeMode; + theme: Theme; + wrapperWidth?: number; +} + +type ProgressiveImageProps = { + defaultSource?: {uri: string}; + imageUri?: string; + inViewPort?: boolean; + thumbnailUri?: string; +} + +const SMALL_IMAGE_MAX_HEIGHT = 48; +const SMALL_IMAGE_MAX_WIDTH = 48; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + imagePreview: { + ...StyleSheet.absoluteFillObject, + }, + fileImageWrapper: { + borderRadius: 5, + overflow: 'hidden', + }, + boxPlaceholder: { + paddingBottom: '100%', + }, + failed: { + justifyContent: 'center', + alignItems: 'center', + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRadius: 4, + borderWidth: 1, + }, + smallImageBorder: { + borderRadius: 5, + }, + smallImageOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 4, + }, + singleSmallImageWrapper: { + height: SMALL_IMAGE_MAX_HEIGHT, + width: SMALL_IMAGE_MAX_WIDTH, + overflow: 'hidden', + }, +})); + +const ImageFile = ({ + backgroundColor, file, inViewPort, isSingleImage, + resizeMode = 'cover', theme, wrapperWidth, +}: ImageFileProps) => { + const serverUrl = useServerUrl(); + const [failed, setFailed] = useState(false); + const dimensions = useWindowDimensions(); + const style = getStyleSheet(theme); + let image; + let client: Client | undefined; + try { + client = NetworkManager.getClient(serverUrl); + } catch { + // do nothing + } + + const getImageDimensions = () => { + if (isSingleImage) { + const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45; + return calculateDimensions(file?.height, file?.width, wrapperWidth, viewPortHeight); + } + + return undefined; + }; + + const handleError = useCallback(() => { + setFailed(true); + }, []); + + const imageProps = () => { + const props: ProgressiveImageProps = {}; + + if (file.localPath) { + props.defaultSource = {uri: file.localPath}; + } else if (file.id) { + if (file.mini_preview && file.mime_type) { + props.thumbnailUri = `data:${file.mime_type};base64,${file.mini_preview}`; + } else { + props.thumbnailUri = client?.getFileThumbnailUrl(file.id, 0); + } + props.imageUri = client?.getFilePreviewUrl(file.id, 0); + props.inViewPort = inViewPort; + } + return props; + }; + + if (file.height <= SMALL_IMAGE_MAX_HEIGHT || file.width <= SMALL_IMAGE_MAX_WIDTH) { + let wrapperStyle: StyleProp = style.fileImageWrapper; + if (isSingleImage) { + wrapperStyle = style.singleSmallImageWrapper; + + if (file.width > SMALL_IMAGE_MAX_WIDTH) { + wrapperStyle = [wrapperStyle, {width: '100%'}]; + } + } + + image = ( + + ); + + if (failed) { + image = ( + + ); + } + + return ( + + {!isSingleImage && } + + {image} + + + ); + } + + const imageDimensions = getImageDimensions(); + image = ( + + ); + + if (failed) { + image = ( + + + + ); + } + + return ( + + {!isSingleImage && } + {image} + + ); +}; + +export default ImageFile; diff --git a/app/components/post_list/post/body/files/image_file_overlay.tsx b/app/components/post_list/post/body/files/image_file_overlay.tsx new file mode 100644 index 000000000..5a29e21cd --- /dev/null +++ b/app/components/post_list/post/body/files/image_file_overlay.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {PixelRatio, StyleSheet, Text, useWindowDimensions, View} from 'react-native'; + +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type ImageFileOverlayProps = { + theme: Theme; + value: number; +} + +const getStyleSheet = (scale: number, th: Theme) => { + const style = makeStyleSheetFromTheme((theme: Theme) => { + return { + moreImagesWrapper: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.6)', + borderRadius: 5, + }, + moreImagesText: { + color: theme.sidebarHeaderTextColor, + fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale)), + fontFamily: 'Open Sans', + textAlign: 'center', + }, + }; + }); + + return style(th); +}; + +const ImageFileOverlay = ({theme, value}: ImageFileOverlayProps) => { + const dimensions = useWindowDimensions(); + const scale = dimensions.width / 320; + const style = getStyleSheet(scale, theme); + return null; + + return ( + + + {`+${value}`} + + + ); +}; + +export default ImageFileOverlay; diff --git a/app/components/post_list/post/body/files/index.tsx b/app/components/post_list/post/body/files/index.tsx new file mode 100644 index 000000000..730b50107 --- /dev/null +++ b/app/components/post_list/post/body/files/index.tsx @@ -0,0 +1,210 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Device} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useServerUrl} from '@context/server_url'; +import {useSplitView} from '@hooks/device'; +import NetworkManager from '@init/network_manager'; +import {isGif, isImage} from '@utils/file'; +import {openGalleryAtIndex} from '@utils/gallery'; +import {getViewPortWidth} from '@utils/images'; +import {preventDoubleTap} from '@utils/tap'; + +import File from './file'; + +import type {Client} from '@client/rest'; +import type {WithDatabaseArgs} from '@typings/database/database'; +import type FileModel from '@typings/database/models/servers/file'; +import type PostModel from '@typings/database/models/servers/post'; +import type SystemModel from '@typings/database/models/servers/system'; + +type FilesProps = { + authorId: string; + canDownloadFiles: boolean; + failed?: boolean; + files: FileModel[]; + isReplyPost: boolean; + postId: string; + theme: Theme; +} + +const MAX_VISIBLE_ROW_IMAGES = 4; +const styles = StyleSheet.create({ + row: { + flex: 1, + flexDirection: 'row', + marginTop: 5, + }, + container: { + flex: 1, + }, + gutter: { + marginLeft: 8, + }, + failed: { + opacity: 0.5, + }, +}); + +const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId, theme}: FilesProps) => { + const [inViewPort, setInViewPort] = useState(false); + const serverUrl = useServerUrl(); + const isSplitView = useSplitView(); + const imageAttachments = useRef([]).current; + const nonImageAttachments = useRef([]).current; + const filesInfo: FileInfo[] = useMemo(() => files.map((f) => ({ + id: f.id, + user_id: authorId, + post_id: postId, + create_at: 0, + delete_at: 0, + update_at: 0, + name: f.name, + extension: f.extension, + mini_preview: f.imageThumbnail, + size: f.size, + mime_type: f.mimeType, + height: f.height, + has_preview_image: Boolean(f.imageThumbnail), + localPath: f.localPath, + width: f.width, + })), [files]); + let client: Client | undefined; + try { + client = NetworkManager.getClient(serverUrl); + } catch { + // do nothing + } + + if (!imageAttachments.length && !nonImageAttachments.length) { + filesInfo.reduce((info, file) => { + if (isImage(file)) { + let uri; + if (file.localPath) { + uri = file.localPath; + } else { + uri = isGif(file) ? client?.getFileUrl(file.id!, 0) : client?.getFilePreviewUrl(file.id!, 0); + } + info.imageAttachments.push({...file, uri}); + } else { + info.nonImageAttachments.push(file); + } + return info; + }, {imageAttachments, nonImageAttachments}); + } + + const filesForGallery = useRef(imageAttachments.concat(nonImageAttachments)).current; + const attachmentIndex = (fileId: string) => { + return filesForGallery.findIndex((file) => file.id === fileId) || 0; + }; + + const handlePreviewPress = preventDoubleTap((idx: number) => { + openGalleryAtIndex(idx, filesForGallery); + }); + + const isSingleImage = () => (files.length === 1 && isImage(files[0])); + + const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false) => { + const singleImage = isSingleImage(); + let nonVisibleImagesCount: number; + let container: StyleProp = styles.container; + const containerWithGutter = [container, styles.gutter]; + + return items.map((file, idx) => { + if (moreImagesCount && idx === MAX_VISIBLE_ROW_IMAGES - 1) { + nonVisibleImagesCount = moreImagesCount; + } + + if (idx !== 0 && includeGutter) { + container = containerWithGutter; + } + + return ( + + + + ); + }); + }; + + const renderImageRow = () => { + if (imageAttachments.length === 0) { + return null; + } + + const visibleImages = imageAttachments.slice(0, MAX_VISIBLE_ROW_IMAGES); + const tabletOffset = !isSplitView && Device.IS_TABLET; + const portraitPostWidth = getViewPortWidth(isReplyPost, tabletOffset); + + let nonVisibleImagesCount; + if (imageAttachments.length > MAX_VISIBLE_ROW_IMAGES) { + nonVisibleImagesCount = imageAttachments.length - MAX_VISIBLE_ROW_IMAGES; + } + + return ( + + { renderItems(visibleImages, nonVisibleImagesCount, true) } + + ); + }; + + useEffect(() => { + const onScrollEnd = DeviceEventEmitter.addListener('scrolled', (viewableItems) => { + if (postId in viewableItems) { + setInViewPort(true); + } + }); + + return () => onScrollEnd.remove(); + }, []); + + return ( + + {renderImageRow()} + {renderItems(nonImageAttachments)} + + ); +}; + +const withConfigAndLicense = withObservables([], ({database}: WithDatabaseArgs) => ({ + enableMobileFileDownload: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((cfg: SystemModel) => of$(cfg.value.EnableMobileFileDownload !== 'false')), + ), + complianceDisabled: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe( + switchMap((lc: SystemModel) => of$(lc.value.IsLicensed === 'false' || lc.value.Compliance === 'false')), + ), +})); + +const withCanDownload = withObservables( + ['enableMobileFileDownload', 'complianceDisabled', 'post'], + ({enableMobileFileDownload, complianceDisabled, post}: {enableMobileFileDownload: boolean; complianceDisabled: boolean; post: PostModel}) => { + return { + authorId: of$(post.userId), + canDownloadFiles: of$(complianceDisabled || enableMobileFileDownload), + postId: of$(post.id), + }; + }); + +export default withDatabase(withConfigAndLicense(withCanDownload(React.memo(Files)))); diff --git a/app/components/post_list/post/body/index.tsx b/app/components/post_list/post/body/index.tsx new file mode 100644 index 000000000..138f058bb --- /dev/null +++ b/app/components/post_list/post/body/index.tsx @@ -0,0 +1,192 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {StyleProp, View, ViewStyle} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import JumboEmoji from '@components/jumbo_emoji'; +import {THREAD} from '@constants/screens'; +import {isEdited as postEdited} from '@utils/post'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import AddMembers from './add_members'; +import Content from './content'; +import Failed from './failed'; +import Files from './files'; +import Message from './message'; +import Reactions from './reactions'; + +import type PostModel from '@typings/database/models/servers/post'; +import FileModel from '@typings/database/models/servers/file'; + +type BodyProps = { + appsEnabled: boolean; + files: FileModel[]; + hasReactions: boolean; + highlight: boolean; + highlightReplyBar: boolean; + isEphemeral: boolean; + isFirstReply?: boolean; + isJumboEmoji: boolean; + isLastReply?: boolean; + isPendingOrFailed: boolean; + isPostAddChannelMember: boolean; + location: string; + post: PostModel; + showAddReaction?: boolean; + theme: Theme; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + messageBody: { + paddingBottom: 2, + paddingTop: 2, + flex: 1, + }, + messageContainer: {width: '100%'}, + replyBar: { + backgroundColor: theme.centerChannelColor, + opacity: 0.1, + marginLeft: 1, + marginRight: 7, + width: 3, + flexBasis: 3, + }, + replyBarFirst: {paddingTop: 10}, + replyBarLast: {paddingBottom: 10}, + replyMention: { + backgroundColor: theme.mentionHighlightBg, + opacity: 1, + }, + message: { + color: theme.centerChannelColor, + fontSize: 15, + lineHeight: 20, + }, + messageContainerWithReplyBar: { + flexDirection: 'row', + width: '100%', + }, + }; +}); + +const Body = ({ + appsEnabled, files, hasReactions, highlight, highlightReplyBar, + isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAddChannelMember, + location, post, showAddReaction, theme, +}: BodyProps) => { + const style = getStyleSheet(theme); + const isEdited = postEdited(post); + const hasBeenDeleted = Boolean(post.deleteAt); + let body; + let message; + + const isReplyPost = Boolean(post.rootId && (!isEphemeral || !hasBeenDeleted) && location !== THREAD); + const hasContent = (post.metadata?.embeds?.length || (appsEnabled && post.props?.app_bindings?.length)) || post.props?.attachments?.length; + + const replyBarStyle = useCallback((): StyleProp|undefined => { + if (!isReplyPost) { + return undefined; + } + + const barStyle = [style.replyBar]; + + if (isFirstReply) { + barStyle.push(style.replyBarFirst); + } + + if (isLastReply) { + barStyle.push(style.replyBarLast); + } + + if (highlightReplyBar) { + barStyle.push(style.replyMention); + } + + return barStyle; + }, []); + + if (hasBeenDeleted) { + body = ( + + ); + } else if (isPostAddChannelMember) { + message = ( + + ); + } else if (isJumboEmoji) { + message = ( + + ); + } else if (post.message.length) { + message = ( + + ); + } + + if (!hasBeenDeleted) { + body = ( + + {message} + {hasContent && + + } + {files.length > 0 && + + } + {hasReactions && showAddReaction && + + } + + ); + } + + return ( + + + {body} + {post.props?.failed && + + } + + ); +}; + +export default Body; diff --git a/app/components/post_list/post/body/message/index.ts b/app/components/post_list/post/body/message/index.ts new file mode 100644 index 000000000..5d5cc3f3a --- /dev/null +++ b/app/components/post_list/post/body/message/index.ts @@ -0,0 +1,42 @@ +// 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 {from as from$} from 'rxjs'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {queryGroupForPosts} from '@helpers/database/groups'; + +import Message from './message'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; +import type SystemModel from '@typings/database/models/servers/system'; + +const {SERVER: {GROUP, SYSTEM, USER}} = MM_TABLES; + +const withPreferences = withObservables([], ({database}: WithDatabaseArgs) => ({ + currentUserId: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID), + groups: database.get(GROUP).query(Q.where('delete_at', Q.eq(0))).observe(), // Needed for when a group is added or removed +})); + +type MessageInputArgs = { + currentUserId: SystemModel; + post: PostModel; +} + +const withMessageInput = withObservables( + ['currentUserId', 'post'], + ({database, currentUserId, post}: WithDatabaseArgs & MessageInputArgs) => { + const currentUser = database.get(USER).findAndObserve(currentUserId.value); + const groupsForPosts = from$(queryGroupForPosts(post)); + + return { + currentUser, + groupsForPosts, + }; + }); + +export default withDatabase(withPreferences(withMessageInput(Message))); diff --git a/app/components/post_list/post/body/message/message.tsx b/app/components/post_list/post/body/message/message.tsx new file mode 100644 index 000000000..d22ea8451 --- /dev/null +++ b/app/components/post_list/post/body/message/message.tsx @@ -0,0 +1,113 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useState} from 'react'; +import {LayoutChangeEvent, useWindowDimensions, ScrollView, View} from 'react-native'; +import Animated from 'react-native-reanimated'; + +import Markdown from '@components/markdown'; +import {SEARCH} from '@constants/screens'; +import {useShowMoreAnimatedStyle} from '@hooks/show_more'; +import {getMentionKeysForPost} from '@utils/post'; +import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown'; + +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import ShowMoreButton from './show_more_button'; + +import type PostModel from '@typings/database/models/servers/post'; +import type GroupModel from '@typings/database/models/servers/group'; +import type UserModel from '@typings/database/models/servers/user'; + +type MessageProps = { + currentUser: UserModel; + groupsForPosts: GroupModel[]; + highlight: boolean; + isEdited: boolean; + isPendingOrFailed: boolean; + isReplyPost: boolean; + location: string; + post: PostModel; + theme: Theme; +} + +const SHOW_MORE_HEIGHT = 54; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + messageContainer: { + width: '100%', + }, + reply: { + paddingRight: 10, + }, + message: { + color: theme.centerChannelColor, + fontSize: 15, + lineHeight: 20, + }, + pendingPost: { + opacity: 0.5, + }, + }; +}); + +const Message = ({currentUser, groupsForPosts, highlight, isEdited, isPendingOrFailed, isReplyPost, location, post, theme}: MessageProps) => { + const [open, setOpen] = useState(false); + const [height, setHeight] = useState(); + const dimensions = useWindowDimensions(); + const maxHeight = Math.round((dimensions.height * 0.5) + SHOW_MORE_HEIGHT); + const animatedStyle = useShowMoreAnimatedStyle(height, maxHeight, open); + const style = getStyleSheet(theme); + const blockStyles = getMarkdownBlockStyles(theme); + const textStyles = getMarkdownTextStyles(theme); + const mentionKeys = useMemo(() => { + return getMentionKeysForPost(currentUser, post, groupsForPosts); + }, [currentUser, post.message, groupsForPosts]); + + const onLayout = useCallback((event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), []); + const onPress = () => setOpen(!open); + + return ( + <> + + + + + + + + {(height || 0) > maxHeight && + + } + + ); +}; + +export default React.memo(Message); diff --git a/app/components/post_list/post/body/message/show_more_button.tsx b/app/components/post_list/post/body/message/show_more_button.tsx new file mode 100644 index 000000000..028603111 --- /dev/null +++ b/app/components/post_list/post/body/message/show_more_button.tsx @@ -0,0 +1,125 @@ +// 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 LinearGradient from 'react-native-linear-gradient'; + +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type ShowMoreButtonProps = { + highlight: boolean; + onPress: () => void; + showMore: boolean; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + button: { + flex: 1, + flexDirection: 'row', + }, + buttonContainer: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.centerChannelBg, + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRadius: 22, + borderWidth: 1, + height: 44, + width: 44, + paddingTop: 7, + }, + container: { + alignItems: 'center', + justifyContent: 'center', + flex: 1, + flexDirection: 'row', + position: 'relative', + top: 10, + marginBottom: 10, + }, + dividerLeft: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + flex: 1, + height: 1, + marginRight: 10, + }, + dividerRight: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + flex: 1, + height: 1, + marginLeft: 10, + }, + gradient: { + flex: 1, + height: 50, + position: 'absolute', + top: -50, + width: '100%', + }, + sign: { + color: theme.linkColor, + }, + }; +}); + +const ShowMoreButton = ({highlight, onPress, showMore = true, theme}: ShowMoreButtonProps) => { + const style = getStyleSheet(theme); + + let iconName = 'chevron-down'; + if (!showMore) { + iconName = 'chevron-up'; + } + + let gradientColors = [ + changeOpacity(theme.centerChannelBg, 0), + changeOpacity(theme.centerChannelBg, 0.75), + theme.centerChannelBg, + ]; + + if (highlight) { + gradientColors = [ + changeOpacity(theme.mentionHighlightBg, 0), + changeOpacity(theme.mentionHighlightBg, 0.15), + changeOpacity(theme.mentionHighlightBg, 0.5), + ]; + } + + return ( + + {showMore && + + } + + + + + + + + + + + ); +}; + +export default ShowMoreButton; diff --git a/app/components/post_list/post/body/reactions/index.ts b/app/components/post_list/post/body/reactions/index.ts new file mode 100644 index 000000000..40fe35309 --- /dev/null +++ b/app/components/post_list/post/body/reactions/index.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import {from as from$, of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {General, Permissions} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {hasPermissionForPost} from '@utils/role'; +import {isSystemAdmin} from '@utils/user'; + +import Reactions from './reactions'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type PostModel from '@typings/database/models/servers/post'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +type WithReactionsInput = WithDatabaseArgs & { + experimentalTownSquareIsReadOnly: boolean; + post: PostModel; + currentUser: UserModel; +} + +const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({ + currentUser: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap((currentUserId: SystemModel) => + database.get(MM_TABLES.SERVER.USER).findAndObserve(currentUserId.value), + ), + ), + experimentalTownSquareIsReadOnly: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((cfg: SystemModel) => of$(cfg.value.ExperimentalTownSquareIsReadOnly === 'true')), + ), +})); + +const withReactions = withObservables(['experimentalTownSquareIsReadOnly', 'post', 'currentUser'], ({experimentalTownSquareIsReadOnly, post, currentUser}: WithReactionsInput) => { + const disabled = post.channel.observe().pipe( + switchMap((channel: ChannelModel) => { + return of$(channel.deleteAt > 0 || + (channel?.name === General.DEFAULT_CHANNEL && !isSystemAdmin(currentUser.roles) && experimentalTownSquareIsReadOnly)); + }), + ); + + return { + canAddReaction: from$(hasPermissionForPost(post, currentUser, Permissions.ADD_REACTION, true)), + canRemoveReaction: from$(hasPermissionForPost(post, currentUser, Permissions.REMOVE_REACTION, true)), + currentUserId: of$(currentUser.id), + disabled, + postId: of$(post.id), + reactions: post.reactions.observe(), + }; +}); + +export default withDatabase(withSystem(withReactions(Reactions))); diff --git a/app/components/post_list/post/body/reactions/reaction.tsx b/app/components/post_list/post/body/reactions/reaction.tsx new file mode 100644 index 000000000..5893fe5cc --- /dev/null +++ b/app/components/post_list/post/body/reactions/reaction.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {Platform, Text} from 'react-native'; + +import Emoji from '@components/emoji'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type ReactionProps = { + count: number; + emojiName: string; + highlight: boolean; + onPress: (emojiName: string, highlight: boolean) => void; + onLongPress: () => void; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + count: { + color: theme.linkColor, + marginLeft: 6, + }, + customEmojiStyle: {marginHorizontal: 3}, + highlight: {backgroundColor: changeOpacity(theme.linkColor, 0.1)}, + reaction: { + alignItems: 'center', + borderRadius: 2, + borderColor: changeOpacity(theme.linkColor, 0.4), + borderWidth: 1, + flexDirection: 'row', + height: 30, + marginRight: 6, + marginBottom: 5, + marginTop: 10, + paddingHorizontal: 6, + paddingBottom: Platform.select({android: 2}), + }, + }; +}); + +const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: ReactionProps) => { + const styles = getStyleSheet(theme); + + const handlePress = useCallback(() => { + onPress(emojiName, highlight); + }, [highlight]); + + return ( + + + + {count} + + + ); +}; + +export default Reaction; diff --git a/app/components/post_list/post/body/reactions/reactions.tsx b/app/components/post_list/post/body/reactions/reactions.tsx new file mode 100644 index 000000000..34eabb312 --- /dev/null +++ b/app/components/post_list/post/body/reactions/reactions.tsx @@ -0,0 +1,171 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useRef} from 'react'; +import {useIntl} from 'react-intl'; +import {View} from 'react-native'; + +import {addReaction, removeReaction} from '@actions/remote/reactions'; +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {MAX_ALLOWED_REACTIONS} from '@constants/emoji'; +import {useServerUrl} from '@context/server_url'; +import {showModal, showModalOverCurrentContext} from '@screens/navigation'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import Reaction from './reaction'; + +import type ReactionModel from '@typings/database/models/servers/reaction'; + +type ReactionsProps = { + canAddReaction: boolean; + canRemoveReaction: boolean; + disabled: boolean; + currentUserId: string; + postId: string; + reactions: ReactionModel[]; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + addReaction: { + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + reaction: { + alignItems: 'center', + justifyContent: 'center', + borderRadius: 2, + borderColor: changeOpacity(theme.centerChannelColor, 0.3), + borderWidth: 1, + flexDirection: 'row', + height: 30, + marginRight: 6, + marginBottom: 5, + marginTop: 10, + paddingVertical: 2, + paddingHorizontal: 6, + width: 40, + }, + reactionsContainer: { + flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + alignContent: 'flex-start', + }, + }; +}); + +const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, postId, reactions, theme}: ReactionsProps) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const pressed = useRef(false); + if (!reactions) { + return null; + } + const styles = getStyleSheet(theme); + + const buildReactionsMap = () => { + const highlightedReactions: string[] = []; + + const reactionsByName = reactions.reduce((acc, reaction) => { + if (reaction) { + if (acc.has(reaction.emojiName)) { + acc.get(reaction.emojiName)!.push(reaction); + } else { + acc.set(reaction.emojiName, [reaction]); + } + + if (reaction.userId === currentUserId) { + highlightedReactions.push(reaction.emojiName); + } + } + + return acc; + }, new Map()); + + return {reactionsByName, highlightedReactions}; + }; + + const handleAddReactionToPost = (emoji: string) => { + addReaction(serverUrl, postId, emoji); + }; + + const handleAddReaction = preventDoubleTap(() => { + const screen = 'AddReaction'; + const title = intl.formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}); + + const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + const passProps = { + closeButton, + onEmojiPress: handleAddReactionToPost, + }; + + showModal(screen, title, passProps); + }); + + const handleReactionPress = async (emoji: string, remove: boolean) => { + pressed.current = true; + if (remove && canRemoveReaction && !disabled) { + await removeReaction(serverUrl, postId, emoji); + } else if (!remove && canAddReaction && !disabled) { + await addReaction(serverUrl, postId, emoji); + } + + pressed.current = false; + }; + + const showReactionList = () => { + const screen = 'ReactionList'; + const passProps = { + postId, + }; + + if (!pressed.current) { + showModalOverCurrentContext(screen, passProps); + } + }; + + let addMoreReactions = null; + const {reactionsByName, highlightedReactions} = buildReactionsMap(); + if (!disabled && canAddReaction && reactionsByName.size < MAX_ALLOWED_REACTIONS) { + addMoreReactions = ( + + + + ); + } + + return ( + + { + Array.from(reactionsByName.keys()).map((r) => { + return ( + + ); + }) + } + {addMoreReactions} + + ); +}; + +export default React.memo(Reactions); diff --git a/app/components/post_list/post/header/commented_on/index.tsx b/app/components/post_list/post/header/commented_on/index.tsx new file mode 100644 index 000000000..f93655e7d --- /dev/null +++ b/app/components/post_list/post/header/commented_on/index.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import FormattedText from '@components/formatted_text'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type HeaderCommentedOnProps = { + locale: string; + name: string; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + commentedOn: { + color: changeOpacity(theme.centerChannelColor, 0.65), + marginBottom: 3, + lineHeight: 21, + }, + }; +}); + +const HeaderCommentedOn = ({locale, name, theme}: HeaderCommentedOnProps) => { + const style = getStyleSheet(theme); + let apostrophe; + if (locale.toLowerCase().startsWith('en')) { + if (name.slice(-1) === 's') { + apostrophe = '\''; + } else { + apostrophe = '\'s'; + } + } + + return ( + + ); +}; + +export default HeaderCommentedOn; diff --git a/app/components/post_list/post/header/display_name/index.tsx b/app/components/post_list/post/header/display_name/index.tsx new file mode 100644 index 000000000..c419c9c00 --- /dev/null +++ b/app/components/post_list/post/header/display_name/index.tsx @@ -0,0 +1,148 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useRef} from 'react'; +import {useIntl} from 'react-intl'; +import {Keyboard, Text, useWindowDimensions, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {showModal} from '@screens/navigation'; +import {preventDoubleTap} from '@utils/tap'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import type {ImageSource} from 'react-native-vector-icons/Icon'; + +type HeaderDisplayNameProps = { + commentCount: number; + displayName?: string; + isAutomation: boolean; + rootPostAuthor?: string; + shouldRenderReplyButton?: boolean; + theme: Theme; + userId: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + displayName: { + color: theme.centerChannelColor, + fontSize: 15, + fontWeight: '600', + flexGrow: 1, + paddingVertical: 2, + }, + displayNameContainer: { + maxWidth: '60%', + marginRight: 5, + marginBottom: 3, + }, + displayNameContainerBotReplyWidth: { + maxWidth: '50%', + }, + displayNameContainerLandscape: { + maxWidth: '80%', + }, + displayNameContainerLandscapeBotReplyWidth: { + maxWidth: '70%', + }, + + }; +}); + +const HeaderDisplayName = ({ + commentCount, displayName, isAutomation, rootPostAuthor, shouldRenderReplyButton, theme, userId, +}: HeaderDisplayNameProps) => { + const closeButton = useRef(); + const dimensions = useWindowDimensions(); + const intl = useIntl(); + const style = getStyleSheet(theme); + + const onPress = useCallback(preventDoubleTap(() => { + const screen = 'UserProfile'; + const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}); + const passProps = {userId}; + + if (!closeButton.current) { + closeButton.current = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + } + + const options = { + topBar: { + leftButtons: [{ + id: 'close-user-profile', + icon: closeButton.current, + testID: 'close.user_profile.button', + }], + }, + }; + + Keyboard.dismiss(); + showModal(screen, title, passProps, options); + }), []); + + const calcNameWidth = () => { + const isLandscape = dimensions.width > dimensions.height; + + const showReply = shouldRenderReplyButton || (!rootPostAuthor && commentCount > 0); + const reduceWidth = showReply && isAutomation; + + if (reduceWidth && isLandscape) { + return style.displayNameContainerLandscapeBotReplyWidth; + } else if (isLandscape) { + return style.displayNameContainerLandscape; + } else if (reduceWidth) { + return style.displayNameContainerBotReplyWidth; + } + return undefined; + }; + + const displayNameWidth = calcNameWidth(); + const displayNameStyle = [style.displayNameContainer, displayNameWidth]; + + if (isAutomation) { + return ( + + + {displayName} + + + ); + } else if (displayName) { + return ( + + + {displayName} + + + ); + } + + return ( + + + + ); +}; + +export default HeaderDisplayName; diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx new file mode 100644 index 000000000..daac0f25d --- /dev/null +++ b/app/components/post_list/post/header/header.tsx @@ -0,0 +1,147 @@ +// 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 CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; +import FormattedTime from '@components/formatted_time'; +import {CHANNEL, THREAD} from '@constants/screens'; +import {useTheme} from '@context/theme'; +import {postUserDisplayName} from '@utils/post'; +import {displayUsername, getUserCustomStatus, getUserTimezone, isCustomStatusExpired} from '@utils/user'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import HeaderCommentedOn from './commented_on'; +import HeaderDisplayName from './display_name'; +import HeaderReply from './reply'; +import HeaderTag from './tag'; + +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; + +type HeaderProps = { + author: UserModel; + commentCount: number; + currentUser: UserModel; + enablePostUsernameOverride: boolean; + isAutoResponse: boolean; + isCustomStatusEnabled: boolean; + isEphemeral: boolean; + isMilitaryTime: boolean; + isPendingOrFailed: boolean; + isSystemPost: boolean; + isTimezoneEnabled: boolean; + isWebHook: boolean; + location: string; + post: PostModel; + rootPostAuthor?: UserModel; + shouldRenderReplyButton?: boolean; + teammateNameDisplay: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + marginTop: 10, + }, + pendingPost: { + opacity: 0.5, + }, + wrapper: { + flex: 1, + flexDirection: 'row', + }, + time: { + color: theme.centerChannelColor, + fontSize: 12, + marginTop: 5, + opacity: 0.5, + flex: 1, + }, + customStatusEmoji: { + color: theme.centerChannelColor, + marginRight: 4, + marginTop: 1, + }, + }; +}); + +const Header = (props: HeaderProps) => { + const { + author, commentCount = 0, currentUser, enablePostUsernameOverride, isAutoResponse, isCustomStatusEnabled, + isEphemeral, isMilitaryTime, isPendingOrFailed, isSystemPost, isTimezoneEnabled, isWebHook, + location, post, rootPostAuthor, shouldRenderReplyButton, teammateNameDisplay, + } = props; + const theme = useTheme(); + const style = getStyleSheet(theme); + 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 rootAuthorDisplayName = rootPostAuthor ? displayUsername(rootPostAuthor, currentUser.locale, teammateNameDisplay, true) : undefined; + const customStatus = getUserCustomStatus(author); + const customStatusExpired = isCustomStatusExpired(author); + const showCustomStatusEmoji = Boolean( + isCustomStatusEnabled && displayName && + !(isSystemPost || author.isBot || isAutoResponse || isWebHook) && + customStatus, + ); + + return ( + <> + + + + {showCustomStatusEmoji && customStatusExpired && Boolean(customStatus?.emoji) && ( + + )} + {(!isSystemPost || isAutoResponse) && + + } + + {showReply && commentCount > 0 && + + } + + + {Boolean(rootAuthorDisplayName) && location === CHANNEL && + + } + + ); +}; + +export default React.memo(Header); diff --git a/app/components/post_list/post/header/index.ts b/app/components/post_list/post/header/index.ts new file mode 100644 index 000000000..84f564c7f --- /dev/null +++ b/app/components/post_list/post/header/index.ts @@ -0,0 +1,70 @@ +// 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 {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Preferences} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {getPreferenceAsBool, getTeammateNameDisplaySetting} from '@helpers/api/preference'; +import {isMinimumServerVersion} from '@utils/helpers'; + +import Header from './header'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; +import type SystemModel from '@typings/database/models/servers/system'; +import type PreferenceModel from '@typings/database/models/servers/preference'; + +type HeaderInputProps = { + config: ClientConfig; + license: ClientLicense; + preferences: PreferenceModel[]; + post: PostModel; +}; + +const withBaseHeaderProps = withObservables([], ({database}: WithDatabaseArgs & {post: PostModel}) => ({ + config: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(switchMap((cfg: SystemModel) => of$(cfg.value))), + license: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(switchMap((lcs: SystemModel) => of$(lcs.value))), + preferences: database.get(MM_TABLES.SERVER.PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe(), +})); + +const withHeaderProps = withObservables( + ['preferences', 'post'], + ({config, post, license, database, preferences}: WithDatabaseArgs & HeaderInputProps) => { + const author = post.author.observe(); + const enablePostUsernameOverride = of$(config.EnablePostUsernameOverride === 'true'); + const isTimezoneEnabled = of$(config.ExperimentalTimezone === 'true'); + const isMilitaryTime = of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)); + const teammateNameDisplay = of$(getTeammateNameDisplaySetting(preferences, config, license)); + const isCustomStatusEnabled = of$(config.EnableCustomUserStatuses === 'true' && isMinimumServerVersion(config.Version, 5, 36)); + const commentCount = database.get(MM_TABLES.SERVER.POST).query( + Q.and( + Q.where('root_id', (post.rootId || post.id)), + Q.where('delete_at', Q.eq(0)), + ), + ).observeCount(); + const rootPostAuthor = post.root.observe().pipe(switchMap((root) => { + if (root.length) { + return root[0].author.observe(); + } + + return of$(null); + })); + + return { + author, + commentCount, + enablePostUsernameOverride, + isCustomStatusEnabled, + isMilitaryTime, + isTimezoneEnabled, + teammateNameDisplay, + rootPostAuthor, + }; + }); + +export default withDatabase(withBaseHeaderProps(withHeaderProps(Header))); diff --git a/app/components/post_list/post/header/reply/index.tsx b/app/components/post_list/post/header/reply/index.tsx new file mode 100644 index 000000000..18d942e9f --- /dev/null +++ b/app/components/post_list/post/header/reply/index.tsx @@ -0,0 +1,81 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {DeviceEventEmitter, Text, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {SEARCH} from '@constants/screens'; +import {preventDoubleTap} from '@utils/tap'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import type PostModel from '@typings/database/models/servers/post'; + +type HeaderReplyProps = { + commentCount: number; + location: string; + post: PostModel; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + replyWrapper: { + flex: 1, + justifyContent: 'flex-end', + }, + replyIconContainer: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'flex-end', + minWidth: 40, + paddingTop: 2, + paddingBottom: 10, + flex: 1, + }, + replyText: { + fontSize: 12, + marginLeft: 2, + marginTop: 2, + color: theme.linkColor, + }, + }; +}); + +const HeaderReply = ({commentCount, location, post, theme}: HeaderReplyProps) => { + const style = getStyleSheet(theme); + + const onPress = useCallback(preventDoubleTap(() => { + DeviceEventEmitter.emit('goToThread', post); + }), []); + + return ( + + + + {location !== SEARCH && commentCount > 0 && + + {commentCount} + + } + + + ); +}; + +export default HeaderReply; diff --git a/app/components/post_list/post/header/tag/index.tsx b/app/components/post_list/post/header/tag/index.tsx new file mode 100644 index 000000000..9b5afbb7c --- /dev/null +++ b/app/components/post_list/post/header/tag/index.tsx @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet} from 'react-native'; + +import Tag, {BotTag, GuestTag} from '@components/tag'; +import {t} from '@i18n'; + +type HeaderTagProps = { + isAutomation?: boolean; + isAutoResponder?: boolean; + isGuest?: boolean; +} + +const style = StyleSheet.create({ + tag: { + marginLeft: 0, + marginRight: 5, + marginBottom: 5, + }, +}); + +const HeaderTag = ({ + isAutomation, isAutoResponder, isGuest, +}: HeaderTagProps) => { + if (isAutomation) { + return ( + + ); + } else if (isGuest) { + return ( + + ); + } else if (isAutoResponder) { + return ( + + ); + } + + return null; +}; + +export default HeaderTag; diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts new file mode 100644 index 000000000..76a5ec95e --- /dev/null +++ b/app/components/post_list/post/index.ts @@ -0,0 +1,147 @@ +// 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 {from as from$, of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Permissions, Preferences} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {appsEnabled} from '@utils/apps'; +import {hasJumboEmojiOnly} from '@utils/emoji/helpers'; +import {areConsecutivePosts, isPostEphemeral} from '@utils/post'; +import {canManageChannelMembers, hasPermissionForPost} from '@utils/role'; + +import Post from './post'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PreferenceModel from '@typings/database/models/servers/preference'; +import type PostModel from '@typings/database/models/servers/post'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; +import PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; +import CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; + +const {SERVER: {CUSTOM_EMOJI, POST, PREFERENCE, SYSTEM, USER}} = MM_TABLES; + +type PropsInput = WithDatabaseArgs & { + featureFlagAppsEnabled?: string; + currentUser: UserModel; + nextPost: PostModel | undefined; + post: PostModel; + previousPost: PostModel | undefined; +} + +async function shouldHighlightReplyBar(currentUser: UserModel, post: PostModel, postsInThread: PostsInThreadModel) { + let commentsNotifyLevel = Preferences.COMMENTS_NEVER; + let threadCreatedByCurrentUser = false; + let rootPost: PostModel | undefined; + const myPosts = await postsInThread.collections.get(POST).query( + Q.and( + Q.where('root_id', post.id || post.rootId), + Q.where('create_at', Q.between(postsInThread.earliest, postsInThread.latest)), + Q.where('user_id', currentUser.id), + ), + ).fetch(); + + const threadRepliedToByCurrentUser = myPosts.length > 0; + const root = await post.root.fetch(); + if (root.length) { + rootPost = root[0]; + } + + if (rootPost?.userId === currentUser.id) { + threadCreatedByCurrentUser = true; + } + if (currentUser.notifyProps?.comments) { + commentsNotifyLevel = currentUser.notifyProps.comments; + } + + const fromCurrentUser = post.userId !== currentUser.id || Boolean(post.props?.from_webhook); + if (!fromCurrentUser) { + if (commentsNotifyLevel === Preferences.COMMENTS_ANY && (threadCreatedByCurrentUser || threadRepliedToByCurrentUser)) { + return true; + } else if (commentsNotifyLevel === Preferences.COMMENTS_ROOT && threadCreatedByCurrentUser) { + return true; + } + } + + return false; +} +const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({ + featureFlagAppsEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(switchMap((cfg: SystemModel) => of$(cfg.value.FeatureFlagAppsEnabled))), + currentUser: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap((currentUserId: SystemModel) => database.get(USER).findAndObserve(currentUserId.value)), + ), +})); + +const withPost = withObservables( + ['currentUser', 'post', 'previousPost', 'nextPost'], + ({featureFlagAppsEnabled, currentUser, database, post, previousPost, nextPost}: PropsInput) => { + let isFirstReply = of$(true); + let isJumboEmoji = of$(false); + let isLastReply = of$(true); + let isPostAddChannelMember = of$(false); + const isOwner = currentUser.id === post.userId; + const canDelete = from$(hasPermissionForPost(post, currentUser, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false)); + const isConsecutivePost = post.author.observe().pipe(switchMap( + (user: UserModel) => { + return of$(Boolean(post && previousPost && !user.isBot && post.rootId && areConsecutivePosts(post, previousPost))); + }, + )); + const isEphemeral = of$(isPostEphemeral(post)); + const isFlagged = database.get(PREFERENCE).query( + Q.where('category', Preferences.CATEGORY_FLAGGED_POST), + Q.where('name', post.id), + ).observe().pipe(switchMap((pref: PreferenceModel[]) => of$(Boolean(pref.length)))); + + if (post.props?.add_channel_member && isPostEphemeral(post)) { + isPostAddChannelMember = from$(canManageChannelMembers(post, currentUser)); + } + + const highlightReplyBar = post.postsInThread.observe().pipe( + switchMap((postsInThreads: PostsInThreadModel[]) => { + if (postsInThreads.length) { + return from$(shouldHighlightReplyBar(currentUser, post, postsInThreads[0])); + } + return of$(false); + })); + + if (post.rootId) { + const differentThreadSequence = previousPost?.rootId ? previousPost?.rootId !== post.rootId : previousPost?.id !== post.rootId; + isFirstReply = of$(differentThreadSequence || (previousPost?.id === post.rootId || previousPost?.rootId === post.rootId)); + isLastReply = of$(!(nextPost?.rootId === post.rootId)); + } + + if (post.message.length && !(/^\s{4}/).test(post.message)) { + isJumboEmoji = post.collections.get(CUSTOM_EMOJI).query().observe().pipe( + // eslint-disable-next-line max-nested-callbacks + switchMap((customEmojis: CustomEmojiModel[]) => of$(hasJumboEmojiOnly(post.message, customEmojis.map((c) => c.name))), + )); + } + + const partialConfig: Partial = { + FeatureFlagAppsEnabled: featureFlagAppsEnabled, + }; + + return { + appsEnabled: of$(appsEnabled(partialConfig)), + canDelete, + currentUser, + files: post.files.observe(), + highlightReplyBar, + isConsecutivePost, + isEphemeral, + isFirstReply, + isFlagged, + isJumboEmoji, + isLastReply, + isPostAddChannelMember, + post: post.observe(), + reactionsCount: post.reactions.observeCount(), + }; + }); + +export default withDatabase(withSystem(withPost(Post))); diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx new file mode 100644 index 000000000..0c2c2d700 --- /dev/null +++ b/app/components/post_list/post/post.tsx @@ -0,0 +1,286 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {ReactNode, useRef} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter, Keyboard, StyleProp, View, ViewStyle} from 'react-native'; + +import {showPermalink} from '@actions/local/permalink'; +import {removePost} from '@actions/local/post'; +import SystemAvatar from '@app/components/system_avatar'; +import SystemHeader from '@app/components/system_header'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import * as Screens from '@constants/screens'; +import {useTheme} from '@context/theme'; +import {useServerUrl} from '@context/server_url'; +import {showModalOverCurrentContext} from '@screens/navigation'; +import {fromAutoResponder, isFromWebhook, isPostPendingOrFailed, isSystemMessage} from '@utils/post'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import Avatar from './avatar'; +import Body from './body'; +import Header from './header'; +import PreHeader from './pre_header'; +import SystemMessage from './system_message'; + +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; +import FileModel from '@typings/database/models/servers/file'; + +type PostProps = { + appsEnabled: boolean; + canDelete: boolean; + currentUser: UserModel; + files: FileModel[]; + highlight?: boolean; + highlightPinnedOrFlagged?: boolean; + highlightReplyBar: boolean; + isConsecutivePost?: boolean; + isEphemeral: boolean; + isFirstReply?: boolean; + isFlagged?: boolean; + isJumboEmoji: boolean; + isLastReply?: boolean; + isPostAddChannelMember: boolean; + location: string; + post: PostModel; + reactionsCount: number; + shouldRenderReplyButton?: boolean; + showAddReaction?: boolean; + skipFlaggedHeader?: boolean; + skipPinnedHeader?: boolean; + style?: StyleProp; + testID?: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + consecutive: {marginTop: 0}, + consecutivePostContainer: { + marginBottom: 10, + marginRight: 10, + marginLeft: 47, + marginTop: 10, + }, + container: {flexDirection: 'row'}, + highlight: {backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.5)}, + highlightBar: { + backgroundColor: theme.mentionHighlightBg, + opacity: 1, + }, + highlightPinnedOrFlagged: {backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.2)}, + pendingPost: {opacity: 0.5}, + postStyle: { + overflow: 'hidden', + flex: 1, + }, + replyBar: { + backgroundColor: theme.centerChannelColor, + opacity: 0.1, + marginLeft: 1, + marginRight: 7, + width: 3, + flexBasis: 3, + }, + replyBarFirst: {paddingTop: 10}, + replyBarLast: {paddingBottom: 10}, + rightColumn: { + flex: 1, + flexDirection: 'column', + marginRight: 12, + }, + rightColumnPadding: {paddingBottom: 3}, + }; +}); + +const Post = ({ + appsEnabled, canDelete, currentUser, files, highlight, highlightPinnedOrFlagged = true, highlightReplyBar, + isConsecutivePost, isEphemeral, isFirstReply, isFlagged, isJumboEmoji, isLastReply, isPostAddChannelMember, + location, post, reactionsCount, shouldRenderReplyButton, skipFlaggedHeader, skipPinnedHeader, showAddReaction = true, style, + testID, +}: PostProps) => { + const pressDetected = useRef(false); + const intl = useIntl(); + const serverUrl = useServerUrl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const isAutoResponder = fromAutoResponder(post); + const isPendingOrFailed = isPostPendingOrFailed(post); + const isSystemPost = isSystemMessage(post); + const isWebHook = isFromWebhook(post); + + const handlePress = preventDoubleTap(() => { + pressDetected.current = true; + + if (post) { + if (location === Screens.THREAD) { + Keyboard.dismiss(); + } else if (location === Screens.SEARCH) { + showPermalink(serverUrl, '', post.id, intl); + return; + } + + const isValidSystemMessage = isAutoResponder || !isSystemPost; + if (post.deleteAt !== 0 && isValidSystemMessage && !isPendingOrFailed) { + if ([Screens.CHANNEL, Screens.PERMALINK].includes(location)) { + DeviceEventEmitter.emit('goToThread', post); + } + } else if ((isEphemeral || post.deleteAt > 0)) { + removePost(serverUrl, post); + } + + const pressTimeout = setTimeout(() => { + pressDetected.current = false; + clearTimeout(pressTimeout); + }, 300); + } + }); + + const showPostOptions = () => { + if (!post) { + return; + } + + const hasBeenDeleted = (post.deleteAt !== 0); + if (isSystemPost && (!canDelete || hasBeenDeleted)) { + return; + } + + if (isPendingOrFailed || isEphemeral) { + return; + } + + const screen = 'PostOptions'; + const passProps = { + location, + post, + showAddReaction, + }; + + Keyboard.dismiss(); + const postOptionsRequest = requestAnimationFrame(() => { + showModalOverCurrentContext(screen, passProps); + cancelAnimationFrame(postOptionsRequest); + }); + }; + + const highlightFlagged = isFlagged && !skipFlaggedHeader; + const hightlightPinned = post.isPinned && !skipPinnedHeader; + const itemTestID = `${testID}.${post.id}`; + const rightColumnStyle = [styles.rightColumn, (post.rootId && isLastReply && styles.rightColumnPadding)]; + const pendingPostStyle: StyleProp | undefined = isPendingOrFailed ? styles.pendingPost : undefined; + + let highlightedStyle: StyleProp; + if (highlight) { + highlightedStyle = styles.highlight; + } else if ((highlightFlagged || hightlightPinned) && highlightPinnedOrFlagged) { + highlightedStyle = styles.highlightPinnedOrFlagged; + } + + let header: ReactNode; + let postAvatar: ReactNode; + let consecutiveStyle: StyleProp; + if (isConsecutivePost) { + consecutiveStyle = styles.consective; + postAvatar = ; + } else { + postAvatar = isAutoResponder ? ( + + ) : ( + + ); + + if (isSystemPost && !isAutoResponder) { + header = ( + + ); + } else { + header = ( +
+ ); + } + } + + let body; + if (isSystemPost && !isEphemeral && !isAutoResponder) { + body = ( + + ); + } else { + body = ( + 0} + highlight={Boolean(highlightedStyle)} + highlightReplyBar={highlightReplyBar} + isEphemeral={isEphemeral} + isFirstReply={isFirstReply} + isJumboEmoji={isJumboEmoji} + isLastReply={isLastReply} + isPendingOrFailed={isPendingOrFailed} + isPostAddChannelMember={isPostAddChannelMember} + location={location} + post={post} + showAddReaction={showAddReaction} + theme={theme} + /> + ); + } + + return ( + + + <> + + + {postAvatar} + + {header} + {body} + + + + + + ); +}; + +export default React.memo(Post); diff --git a/app/components/post_list/post/pre_header/index.tsx b/app/components/post_list/post/pre_header/index.tsx new file mode 100644 index 000000000..540f573e2 --- /dev/null +++ b/app/components/post_list/post/pre_header/index.tsx @@ -0,0 +1,119 @@ +// 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 CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {t} from '@i18n'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type PreHeaderProps = { + isConsecutivePost?: boolean; + isFlagged?: boolean; + isPinned: boolean; + skipFlaggedHeader?: boolean; + skipPinnedHeader?: boolean; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + flexDirection: 'row', + height: 15, + marginLeft: 10, + marginRight: 10, + marginTop: 10, + }, + consecutive: { + marginBottom: 3, + }, + iconsContainer: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'flex-end', + marginRight: 10, + width: 35, + }, + icon: { + color: theme.linkColor, + }, + iconsSeparator: { + marginRight: 5, + }, + rightColumn: { + flex: 1, + flexDirection: 'column', + marginLeft: 2, + }, + text: { + color: theme.linkColor, + fontSize: 13, + lineHeight: 15, + }, + }; +}); + +const PreHeader = ({isConsecutivePost, isFlagged, isPinned, skipFlaggedHeader, skipPinnedHeader}: PreHeaderProps) => { + const theme = useTheme(); + const style = getStyleSheet(theme); + const isPinnedAndFlagged = isPinned && isFlagged && !skipFlaggedHeader && !skipPinnedHeader; + + let text; + if (isPinnedAndFlagged) { + text = { + id: t('mobile.post_pre_header.pinned_flagged'), + defaultMessage: 'Pinned and Saved', + }; + } else if (isPinned && !skipPinnedHeader) { + text = { + id: t('mobile.post_pre_header.pinned'), + defaultMessage: 'Pinned', + }; + } else if (isFlagged && !skipFlaggedHeader) { + text = { + id: t('mobile.post_pre_header.flagged'), + defaultMessage: 'Saved', + }; + } + + if (!text) { + return null; + } + + return ( + + + {isPinned && !skipPinnedHeader && + + } + {isPinnedAndFlagged && + + } + {isFlagged && !skipFlaggedHeader && + + } + + + + + + ); +}; + +export default PreHeader; diff --git a/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap b/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap new file mode 100644 index 000000000..f9553fe58 --- /dev/null +++ b/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap @@ -0,0 +1,358 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] = ` + + + + + @username + + + + updated the channel display name from: old displayname to: new displayname + + + +`; + +exports[`renderSystemMessage uses renderer for Channel Header update 1`] = ` + + + + + @username + + + + updated the channel header from: old header to: new header + + + +`; + +exports[`renderSystemMessage uses renderer for Channel Purpose update 1`] = ` + + @username updated the channel purpose from: old purpose to: new purpose + +`; + +exports[`renderSystemMessage uses renderer for Guest added and join to channel 1`] = ` + + + + + @username + + + + joined the channel as a guest. + + + +`; + +exports[`renderSystemMessage uses renderer for Guest added and join to channel 2`] = ` + + + + + @other.user + + + + added to the channel as a guest by + + + + @username. + + + + +`; + +exports[`renderSystemMessage uses renderer for OLD archived channel without a username 1`] = ` + + + + archived the channel + + + +`; + +exports[`renderSystemMessage uses renderer for archived channel 1`] = ` + + + + + @username + + + + archived the channel + + + +`; + +exports[`renderSystemMessage uses renderer for unarchived channel 1`] = ` + + + + + @username + + + + unarchived the channel + + + +`; diff --git a/app/components/post_list/post/system_message/index.tsx b/app/components/post_list/post/system_message/index.tsx new file mode 100644 index 000000000..db0bda8d8 --- /dev/null +++ b/app/components/post_list/post/system_message/index.tsx @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; + +import type PostModel from '@typings/database/models/servers/post'; + +import SystemMessage from './system_message'; + +const withPost = withObservables(['post'], ({post}: {post: PostModel}) => ({ + author: post.author.observe(), +})); + +export default withPost(SystemMessage); diff --git a/app/components/post_list/post/system_message/system_message.tsx b/app/components/post_list/post/system_message/system_message.tsx new file mode 100644 index 000000000..b78bfe780 --- /dev/null +++ b/app/components/post_list/post/system_message/system_message.tsx @@ -0,0 +1,271 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {IntlShape, useIntl} from 'react-intl'; +import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native'; + +import Markdown from '@components/markdown'; +import {Post} from '@constants'; +import {useTheme} from '@context/theme'; +import {t} from '@i18n'; +import {getMarkdownTextStyles} from '@utils/markdown'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import type {PrimitiveType} from 'intl-messageformat'; + +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; + +type SystemMessageProps = { + author?: UserModel; + post: PostModel; +} + +type RenderersProps = SystemMessageProps & { + intl: IntlShape; + styles: { + messageStyle: StyleProp; + textStyles: { + [key: string]: TextStyle; + }; + }; + theme: Theme; +} + +type RenderMessageProps = RenderersProps & { + localeHolder: { + id: string; + defaultMessage: string; + }; + skipMarkdown?: boolean; + values: Record; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + systemMessage: { + color: changeOpacity(theme.centerChannelColor, 0.6), + fontSize: 15, + lineHeight: 20, + }, + }; +}); + +const renderUsername = (value = '') => { + if (value) { + return (value[0] === '@') ? value : `@${value}`; + } + + return value; +}; + +const renderMessage = ({styles, intl, localeHolder, theme, values, skipMarkdown = false}: RenderMessageProps) => { + const {messageStyle, textStyles} = styles; + + if (skipMarkdown) { + return ( + + {intl.formatMessage(localeHolder, values)} + + ); + } + + return ( + + ); +}; + +const renderHeaderChangeMessage = ({post, author, styles, intl, theme}: RenderersProps) => { + let values; + + if (!author?.username) { + return null; + } + + const username = renderUsername(author.username); + const oldHeader = post.props?.old_header; + const newHeader = post.props?.new_header; + let localeHolder; + + if (post.props?.new_header) { + if (post.props?.old_header) { + localeHolder = { + id: t('mobile.system_message.update_channel_header_message_and_forget.updated_from'), + defaultMessage: '{username} updated the channel header from: {oldHeader} to: {newHeader}', + }; + + values = {username, oldHeader, newHeader}; + return renderMessage({post, styles, intl, localeHolder, values, theme}); + } + + localeHolder = { + id: t('mobile.system_message.update_channel_header_message_and_forget.updated_to'), + defaultMessage: '{username} updated the channel header to: {newHeader}', + }; + + values = {username, oldHeader, newHeader}; + return renderMessage({post, styles, intl, localeHolder, values, theme}); + } else if (post.props?.old_header) { + localeHolder = { + id: t('mobile.system_message.update_channel_header_message_and_forget.removed'), + defaultMessage: '{username} removed the channel header (was: {oldHeader})', + }; + + values = {username, oldHeader, newHeader}; + return renderMessage({post, styles, intl, localeHolder, values, theme}); + } + + return null; +}; + +const renderPurposeChangeMessage = ({post, author, styles, intl, theme}: RenderersProps) => { + let values; + + if (!author?.username) { + return null; + } + + const username = renderUsername(author.username); + const oldPurpose = post.props?.old_purpose; + const newPurpose = post.props?.new_purpose; + let localeHolder; + + if (post.props?.new_purpose) { + if (post.props?.old_purpose) { + localeHolder = { + id: t('mobile.system_message.update_channel_purpose_message.updated_from'), + defaultMessage: '{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}', + }; + + values = {username, oldPurpose, newPurpose}; + return renderMessage({post, styles, intl, localeHolder, values, skipMarkdown: true, theme}); + } + + localeHolder = { + id: t('mobile.system_message.update_channel_purpose_message.updated_to'), + defaultMessage: '{username} updated the channel purpose to: {newPurpose}', + }; + + values = {username, oldPurpose, newPurpose}; + return renderMessage({post, styles, intl, localeHolder, values, skipMarkdown: true, theme}); + } else if (post.props?.old_purpose) { + localeHolder = { + id: t('mobile.system_message.update_channel_purpose_message.removed'), + defaultMessage: '{username} removed the channel purpose (was: {oldPurpose})', + }; + + values = {username, oldPurpose, newPurpose}; + return renderMessage({post, styles, intl, localeHolder, values, skipMarkdown: true, theme}); + } + + return null; +}; + +const renderDisplayNameChangeMessage = ({post, author, styles, intl, theme}: RenderersProps) => { + const oldDisplayName = post.props?.old_displayname; + const newDisplayName = post.props?.new_displayname; + + if (!(author?.username)) { + return null; + } + + const username = renderUsername(author.username); + const localeHolder = { + id: t('mobile.system_message.update_channel_displayname_message_and_forget.updated_from'), + defaultMessage: '{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}', + }; + + const values = {username, oldDisplayName, newDisplayName}; + return renderMessage({post, styles, intl, localeHolder, values, theme}); +}; + +const renderArchivedMessage = ({post, author, styles, intl, theme}: RenderersProps) => { + const username = renderUsername(author?.username); + const localeHolder = { + id: t('mobile.system_message.channel_archived_message'), + defaultMessage: '{username} archived the channel', + }; + + const values = {username}; + return renderMessage({post, styles, intl, localeHolder, values, theme}); +}; + +const renderUnarchivedMessage = ({post, author, styles, intl, theme}: RenderersProps) => { + if (!author?.username) { + return null; + } + + const username = renderUsername(author.username); + const localeHolder = { + id: t('mobile.system_message.channel_unarchived_message'), + defaultMessage: '{username} unarchived the channel', + }; + + const values = {username}; + return renderMessage({post, styles, intl, localeHolder, values, theme}); +}; + +const renderAddGuestToChannelMessage = ({post, styles, intl, theme}: RenderersProps) => { + if (!post.props.username || !post.props.addedUsername) { + return null; + } + + const username = renderUsername(post.props.username); + const addedUsername = renderUsername(post.props.addedUsername); + + const localeHolder = { + id: t('api.channel.add_guest.added'), + defaultMessage: '{addedUsername} added to the channel as a guest by {username}.', + }; + + const values = {username, addedUsername}; + return renderMessage({post, styles, intl, localeHolder, values, theme}); +}; + +const renderGuestJoinChannelMessage = ({post, styles, intl, theme}: RenderersProps) => { + if (!post.props.username) { + return null; + } + + const username = renderUsername(post.props.username); + const localeHolder = { + id: t('api.channel.guest_join_channel.post_and_forget'), + defaultMessage: '{username} joined the channel as a guest.', + }; + + const values = {username}; + return renderMessage({post, styles, intl, localeHolder, values, theme}); +}; + +const systemMessageRenderers = { + [Post.POST_TYPES.HEADER_CHANGE]: renderHeaderChangeMessage, + [Post.POST_TYPES.DISPLAYNAME_CHANGE]: renderDisplayNameChangeMessage, + [Post.POST_TYPES.PURPOSE_CHANGE]: renderPurposeChangeMessage, + [Post.POST_TYPES.CHANNEL_DELETED]: renderArchivedMessage, + [Post.POST_TYPES.CHANNEL_UNARCHIVED]: renderUnarchivedMessage, + [Post.POST_TYPES.GUEST_JOIN_CHANNEL]: renderGuestJoinChannelMessage, + [Post.POST_TYPES.ADD_GUEST_TO_CHANNEL]: renderAddGuestToChannelMessage, +}; + +export const SystemMessage = ({post, author}: SystemMessageProps) => { + const intl = useIntl(); + const theme = useTheme(); + const renderer = systemMessageRenderers[post.type]; + if (!renderer) { + return null; + } + const style = getStyleSheet(theme); + const textStyles = getMarkdownTextStyles(theme); + const styles = {messageStyle: style.systemMessage, textStyles}; + return renderer({post, author, styles, intl, theme}); +}; + +export default React.memo(SystemMessage); diff --git a/app/components/post_list/post/system_message/system_message_helpers.test.js b/app/components/post_list/post/system_message/system_message_helpers.test.js new file mode 100644 index 000000000..7e7bd0f07 --- /dev/null +++ b/app/components/post_list/post/system_message/system_message_helpers.test.js @@ -0,0 +1,202 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {Post} from '@constants'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import {SystemMessage} from './system_message'; + +const baseProps = { + author: { + id: 'me', + username: 'username', + firstName: 'Test', + lastName: 'Author', + }, +}; + +describe('renderSystemMessage', () => { + let database; + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + test('uses renderer for Channel Header update', () => { + const post = { + props: { + old_header: 'old header', + new_header: 'new header', + }, + type: Post.POST_TYPES.HEADER_CHANGE, + }; + const {getByText, toJSON} = renderWithEverything( + , + {database}, + ); + expect(toJSON()).toMatchSnapshot(); + expect(getByText('@username')).toBeTruthy(); + expect(getByText(' updated the channel header from: old header to: new header')).toBeTruthy(); + }); + + test('uses renderer for Channel Display Name update', () => { + const post = { + props: { + old_displayname: 'old displayname', + new_displayname: 'new displayname', + }, + type: Post.POST_TYPES.DISPLAYNAME_CHANGE, + }; + + const {getByText, toJSON} = renderWithEverything( + , + {database}, + ); + expect(toJSON()).toMatchSnapshot(); + expect(getByText('@username')).toBeTruthy(); + expect(getByText(' updated the channel display name from: old displayname to: new displayname')).toBeTruthy(); + }); + + test('uses renderer for Channel Purpose update', () => { + const post = { + props: { + old_purpose: 'old purpose', + new_purpose: 'new purpose', + }, + type: Post.POST_TYPES.PURPOSE_CHANGE, + }; + const {getByText, toJSON} = renderWithEverything( + , + {database}, + ); + expect(toJSON()).toMatchSnapshot(); + expect(getByText('@username updated the channel purpose from: old purpose to: new purpose')).toBeTruthy(); + }); + + test('uses renderer for archived channel', () => { + const post = { + type: Post.POST_TYPES.CHANNEL_DELETED, + }; + + const {getByText, toJSON} = renderWithEverything( + , + {database}, + ); + expect(toJSON()).toMatchSnapshot(); + expect(getByText('@username')).toBeTruthy(); + expect(getByText(' archived the channel')).toBeTruthy(); + }); + + test('uses renderer for OLD archived channel without a username', () => { + const post = { + props: {}, + type: Post.POST_TYPES.CHANNEL_DELETED, + }; + + const {getByText, toJSON} = renderWithEverything( + , + {database}, + ); + expect(toJSON()).toMatchSnapshot(); + expect(getByText('archived the channel')).toBeTruthy(); + }); + + test('uses renderer for unarchived channel', () => { + const post = { + type: Post.POST_TYPES.CHANNEL_UNARCHIVED, + }; + + const viewOne = renderWithEverything( + , + {database}, + ); + expect(viewOne.toJSON()).toMatchSnapshot(); + expect(viewOne.getByText('@username')).toBeTruthy(); + expect(viewOne.getByText(' unarchived the channel')).toBeTruthy(); + + const viewTwo = renderWithEverything( + , + {database}, + ); + expect(viewTwo.toJSON()).toBeNull(); + expect(viewTwo.queryByText('archived the channel')).toBeFalsy(); + }); + + test('is null for non-qualifying system messages', () => { + const post = { + postType: 'not_relevant', + }; + + const renderedMessage = renderWithEverything( + , + {database}, + ); + expect(renderedMessage.toJSON()).toBeNull(); + }); + + test('uses renderer for Guest added and join to channel', () => { + const post = { + props: { + username: 'username', + }, + type: Post.POST_TYPES.GUEST_JOIN_CHANNEL, + }; + const joined = renderWithEverything( + , + {database}, + ); + expect(joined.toJSON()).toMatchSnapshot(); + expect(joined.getByText('@username')).toBeTruthy(); + expect(joined.getByText(' joined the channel as a guest.')).toBeTruthy(); + + post.type = Post.POST_TYPES.ADD_GUEST_TO_CHANNEL; + post.props = { + username: 'username', + addedUsername: 'other.user', + }; + + const added = renderWithEverything( + , + {database}, + ); + expect(added.toJSON()).toMatchSnapshot(); + expect(added.getByText('@other.user')).toBeTruthy(); + expect(added.getByText(' added to the channel as a guest by ')).toBeTruthy(); + expect(added.getByText('@username.')).toBeTruthy(); + }); +}); diff --git a/app/components/profile_picture/index.tsx b/app/components/profile_picture/index.tsx index 8b74bb97e..f078567f9 100644 --- a/app/components/profile_picture/index.tsx +++ b/app/components/profile_picture/index.tsx @@ -5,9 +5,9 @@ import React, {useEffect} from 'react'; import {Platform, StyleProp, View, ViewProps, ViewStyle} from 'react-native'; import FastImage from 'react-native-fast-image'; -// import {fetchStatusInBatch} from '@actions/remote/user'; -// import UserStatus from '@components/user_status'; +import {fetchStatusInBatch} from '@actions/remote/user'; import CompassIcon from '@components/compass_icon'; +import UserStatus from '@components/user_status'; import {useServerUrl} from '@context/server_url'; import {useTheme} from '@context/theme'; import NetworkManager from '@init/network_manager'; @@ -73,7 +73,7 @@ const ProfilePicture = ({author, iconSize, showStatus = true, size = 64, statusS useEffect(() => { if (author && !author.status && showStatus) { - // fetchStatusInBatch(serverUrl, author.id); + fetchStatusInBatch(serverUrl, author.id); } }, []); @@ -86,10 +86,10 @@ const ProfilePicture = ({author, iconSize, showStatus = true, size = 64, statusS if (author?.status && !author.isBot && showStatus) { statusIcon = ( - {/* */} + /> ); } diff --git a/app/components/progress_bar/index.tsx b/app/components/progress_bar/index.tsx new file mode 100644 index 000000000..911fc531c --- /dev/null +++ b/app/components/progress_bar/index.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {Animated, LayoutChangeEvent, StyleSheet, StyleProp, View, ViewStyle} from 'react-native'; + +type ProgressBarProps = { + color: string; + progress: number; + style?: StyleProp; +} + +const styles = StyleSheet.create({ + container: { + height: 4, + borderRadius: 2, + backgroundColor: 'rgba(255, 255, 255, 0.16)', + overflow: 'hidden', + width: '100%', + }, + progressBar: { + flex: 1, + }, +}); + +const ProgressBar = ({color, progress, style}: ProgressBarProps) => { + const timer = useRef(new Animated.Value(progress)).current; + const [width, setWidth] = useState(0); + + useEffect(() => { + const animation = Animated.timing(timer, { + duration: 200, + useNativeDriver: true, + isInteraction: false, + toValue: progress, + }); + + animation.start(); + + return animation.stop; + }, [progress]); + + const onLayout = useCallback((e: LayoutChangeEvent) => { + setWidth(e.nativeEvent.layout.width); + }, []); + + const translateX = timer.interpolate({ + inputRange: [0, 1], + outputRange: [(-0.5 * width), 0], + }); + const scaleX = timer.interpolate({ + inputRange: [0, 1], + outputRange: [0.0001, 1], + }); + + return ( + + + + ); +}; + +export default ProgressBar; diff --git a/app/components/progressive_image/index.tsx b/app/components/progressive_image/index.tsx index e674ff1e7..eb542793d 100644 --- a/app/components/progressive_image/index.tsx +++ b/app/components/progressive_image/index.tsx @@ -23,7 +23,7 @@ type ProgressiveImageProps = { isBackgroundImage?: boolean; onError: () => void; resizeMode?: ResizeMode; - style?: ViewStyle; + style?: StyleProp; thumbnailUri?: string; tintDefaultSource?: boolean; }; diff --git a/app/components/server_version/index.tsx b/app/components/server_version/index.tsx index 16b30d759..724c0e9c3 100644 --- a/app/components/server_version/index.tsx +++ b/app/components/server_version/index.tsx @@ -9,7 +9,7 @@ import {useIntl} from 'react-intl'; import {View} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {isMinimumServerVersion} from '@utils/helpers'; -import {unsupportedServer} from '@utils/supported_server/supported_server'; +import {unsupportedServer} from '@app/utils/supported_server'; import {isSystemAdmin} from '@utils/user'; import type {WithDatabaseArgs} from '@typings/database/database'; @@ -39,7 +39,7 @@ const ServerVersion = ({config, user}: ServerVersionProps) => { if (!isSupportedServer) { // Only display the Alert if the TOS does not need to show first - unsupportedServer(isSystemAdmin(user.roles), intl.formatMessage); + unsupportedServer(isSystemAdmin(user.roles), intl); } } }, [config.value?.Version, user.roles]); diff --git a/app/components/system_avatar/index.tsx b/app/components/system_avatar/index.tsx new file mode 100644 index 000000000..6aa4cf2ff --- /dev/null +++ b/app/components/system_avatar/index.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {View as ViewConstants} from '@constants'; + +type Props = { + theme: Theme; +} + +const styles = StyleSheet.create({ + profilePictureContainer: { + marginBottom: 5, + marginLeft: 12, + marginRight: 13, + marginTop: 10, + }, +}); + +const SystemAvatar = ({theme}: Props) => { + return ( + + + + ); +}; + +export default SystemAvatar; diff --git a/app/components/system_header/index.tsx b/app/components/system_header/index.tsx new file mode 100644 index 000000000..1367ac08f --- /dev/null +++ b/app/components/system_header/index.tsx @@ -0,0 +1,113 @@ +// 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 React from 'react'; +import {View} from 'react-native'; +import {of as of$} from 'rxjs'; + +import FormattedText from '@components/formatted_text'; +import FormattedTime from '@components/formatted_time'; +import {Preferences} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {getPreferenceAsBool} from '@helpers/api/preference'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {getUserTimezone} from '@utils/user'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; +import type PreferenceModel from '@typings/database/models/servers/preference'; + +type withUserInputProps = { + config: SystemModel; + currentUserId: SystemModel; + preferences: PreferenceModel[]; +} + +type Props = { + createAt: number | string | Date; + isMilitaryTime: boolean; + isTimezoneEnabled: boolean; + theme: Theme; + user: UserModel; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + displayName: { + color: theme.centerChannelColor, + fontSize: 15, + fontWeight: '600', + flexGrow: 1, + paddingVertical: 2, + }, + displayNameContainer: { + maxWidth: '60%', + marginRight: 5, + marginBottom: 3, + }, + header: { + flex: 1, + flexDirection: 'row', + marginTop: 10, + }, + time: { + color: theme.centerChannelColor, + fontSize: 12, + marginTop: 5, + opacity: 0.5, + flex: 1, + }, + }; +}); + +const SystemHeader = ({isMilitaryTime, isTimezoneEnabled, createAt, theme, user}: Props) => { + const styles = getStyleSheet(theme); + const userTimezone = isTimezoneEnabled ? getUserTimezone(user) : ''; + + return ( + + + + + + + ); +}; + +const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({ + config: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG), + currentUserId: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID), + preferences: database.get(MM_TABLES.SERVER.PREFERENCE).query( + Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS), Q.where('name', 'use_military_time'), + ).observe(), +})); + +const withUser = withObservables(['currentUserId'], ({config, currentUserId, database, preferences}: WithDatabaseArgs & withUserInputProps) => { + const cfg: ClientConfig = config.value; + const isTimezoneEnabled = of$(cfg.ExperimentalTimezone === 'true'); + const isMilitaryTime = of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)); + + return { + isMilitaryTime, + isTimezoneEnabled, + user: database.get(MM_TABLES.SERVER.USER).findAndObserve(currentUserId.value), + }; +}); + +export default withDatabase(withSystemIds(withUser(React.memo(SystemHeader)))); diff --git a/app/components/tag/index.tsx b/app/components/tag/index.tsx new file mode 100644 index 000000000..ddf1306c3 --- /dev/null +++ b/app/components/tag/index.tsx @@ -0,0 +1,92 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, View, ViewStyle} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {t} from '@i18n'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type TagProps = { + id: string; + defaultMessage: string; + inTitle?: boolean; + show?: boolean; + style?: StyleProp; + testID?: string; +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + alignSelf: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.15), + borderRadius: 2, + marginRight: 2, + marginBottom: 1, + marginLeft: 2, + paddingVertical: 2, + paddingHorizontal: 4, + }, + text: { + color: theme.centerChannelColor, + fontSize: 10, + fontWeight: '600', + }, + title: { + backgroundColor: changeOpacity(theme.sidebarHeaderTextColor, 0.15), + color: changeOpacity(theme.sidebarHeaderTextColor, 0.6), + }, + }; +}); + +export function BotTag(props: Omit) { + const id = t('post_info.bot'); + const defaultMessage = 'BOT'; + + return ( + + ); +} + +export function GuestTag(props: Omit) { + const id = t('post_info.guest'); + const defaultMessage = 'GUEST'; + + return ( + + ); +} + +const Tag = ({id, defaultMessage, inTitle, show = true, style, testID}: TagProps) => { + const theme = useTheme(); + + if (!show) { + return null; + } + + const styles = getStyleFromTheme(theme); + + return ( + + + + ); +}; + +export default Tag; diff --git a/app/components/user_status/__snapshots__/user_status.test.js.snap b/app/components/user_status/__snapshots__/user_status.test.js.snap new file mode 100644 index 000000000..67b6b0f58 --- /dev/null +++ b/app/components/user_status/__snapshots__/user_status.test.js.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UserStatus should match snapshot, away status 1`] = ` + +`; + +exports[`UserStatus should match snapshot, dnd status 1`] = ` + +`; + +exports[`UserStatus should match snapshot, online status 1`] = ` + +`; + +exports[`UserStatus should match snapshot, should default to offline status 1`] = ` + +`; diff --git a/app/components/user_status/index.tsx b/app/components/user_status/index.tsx new file mode 100644 index 000000000..c2ae63c7b --- /dev/null +++ b/app/components/user_status/index.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import CompassIcon from '@components/compass_icon'; +import {useTheme} from '@context/theme'; +import {General} from '@constants'; +import {changeOpacity} from '@utils/theme'; + +type UserStatusProps = { + size?: number; + status?: string; +}; + +const UserStatus = ({size = 6, status = General.OFFLINE}: UserStatusProps) => { + const theme = useTheme(); + let iconName; + let iconColor; + switch (status) { + case General.AWAY: + iconName = 'clock'; + iconColor = theme.awayIndicator; + break; + case General.DND: + iconName = 'minus-circle'; + iconColor = theme.dndIndicator; + break; + case General.ONLINE: + iconName = 'check-circle'; + iconColor = theme.onlineIndicator; + break; + default: + iconName = 'circle-outline'; + iconColor = changeOpacity('#B8B8B8', 0.64); + break; + } + + return ( + + ); +}; + +export default UserStatus; diff --git a/app/components/user_status/user_status.test.js b/app/components/user_status/user_status.test.js new file mode 100644 index 000000000..27889c4f6 --- /dev/null +++ b/app/components/user_status/user_status.test.js @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {General} from '@constants'; +import {renderWithIntlAndTheme} from '@test/intl-test-helper'; + +import UserStatus from './'; + +describe('UserStatus', () => { + const baseProps = { + size: 32, + }; + + test('should match snapshot, should default to offline status', () => { + const wrapper = renderWithIntlAndTheme( + , + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + test('should match snapshot, away status', () => { + const wrapper = renderWithIntlAndTheme( + , + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + test('should match snapshot, dnd status', () => { + const wrapper = renderWithIntlAndTheme( + , + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + test('should match snapshot, online status', () => { + const wrapper = renderWithIntlAndTheme( + , + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/constants/apps.ts b/app/constants/apps.ts new file mode 100644 index 000000000..33546e1b6 --- /dev/null +++ b/app/constants/apps.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const AppBindingLocations = { + POST_MENU_ITEM: '/post_menu', + CHANNEL_HEADER_ICON: '/channel_header', + COMMAND: '/command', + IN_POST: '/in_post', +}; + +export const AppBindingPresentations = { + MODAL: 'modal', +}; + +export const AppCallResponseTypes: { [name: string]: AppCallResponseType } = { + OK: 'ok', + ERROR: 'error', + FORM: 'form', + CALL: 'call', + NAVIGATE: 'navigate', +}; + +export const AppCallTypes: { [name: string]: AppCallType } = { + SUBMIT: 'submit', + LOOKUP: 'lookup', + FORM: 'form', + CANCEL: 'cancel', +}; + +export const AppExpandLevels: { [name: string]: AppExpandLevel } = { + EXPAND_DEFAULT: '', + EXPAND_NONE: 'none', + EXPAND_ALL: 'all', + EXPAND_SUMMARY: 'summary', +}; + +export const AppFieldTypes: { [name: string]: AppFieldType } = { + TEXT: 'text', + STATIC_SELECT: 'static_select', + DYNAMIC_SELECT: 'dynamic_select', + BOOL: 'bool', + USER: 'user', + CHANNEL: 'channel', + MARKDOWN: 'markdown', +}; + +export default { + AppBindingLocations, + AppBindingPresentations, + AppCallResponseTypes, + AppCallTypes, + AppExpandLevels, + AppFieldTypes, +}; diff --git a/app/constants/database.ts b/app/constants/database.ts index cde429a5f..45e7b7296 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -26,7 +26,6 @@ export const MM_TABLES = { POST: 'Post', POSTS_IN_CHANNEL: 'PostsInChannel', POSTS_IN_THREAD: 'PostsInThread', - POST_METADATA: 'PostMetadata', PREFERENCE: 'Preference', REACTION: 'Reaction', ROLE: 'Role', @@ -53,6 +52,9 @@ export const SYSTEM_IDENTIFIERS = { CURRENT_TEAM_ID: 'currentTeamId', CURRENT_USER_ID: 'currentUserId', DATA_RETENTION_POLICIES: 'dataRetentionPolicies', + EXPANDED_LINKS: 'expandedLinks', + RECENT_REACTIONS: 'recentReactions', + INTEGRATION_TRIGGER_ID: 'IntegreationTriggerId', LICENSE: 'license', WEBSOCKET: 'WebSocket', }; diff --git a/app/constants/emoji.ts b/app/constants/emoji.ts index 4301d7775..032a046f4 100644 --- a/app/constants/emoji.ts +++ b/app/constants/emoji.ts @@ -4,6 +4,16 @@ export const ALL_EMOJIS = 'all_emojis'; export const MAX_ALLOWED_REACTIONS = 40; +// reEmoji matches an emoji (eg. :taco:) at the start of a string. +export const reEmoji = /^:([a-z0-9_\-+]+):\B/i; + +// reEmoticon matches an emoticon (eg. :D) at the start of a string. +export const reEmoticon = /^(?:(:-?\))|(;-?\))|(:o)|(:-o)|(:-?])|(:-?d)|(x-d)|(:-?p)|(:-?[[@])|(:-?\()|(:['’]-?\()|(:-?\/)|(:-?s)|(:-?\|)|(:-?\$)|(:-x)|(<3|<3)|(<\/3|<\/3)|(:[`'’]-?\(|:'\(|:'\())(?=$|\s|[*_~?])/i; + +// reMain matches some amount of plain text, starting at the beginning of the string and hopefully stopping right +// before the next emoji by looking for any character that could start an emoji (:, ;, x, or <) +export const reMain = /^[\s\S]+?(?=[:;x<]|$)/i; + export default { ALL_EMOJIS, MAX_ALLOWED_REACTIONS, diff --git a/app/constants/index.ts b/app/constants/index.ts index c5a778561..dddb889df 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import ActionType from './action_type'; +import Apps from './apps'; import Attachment from './attachment'; import Database from './database'; import DeepLink from './deep_linking'; @@ -12,6 +13,7 @@ import List from './list'; import Navigation from './navigation'; import Network from './network'; import Permissions from './permissions'; +import Post from './post'; import Preferences from './preferences'; import SSO, {REDIRECT_URL_SCHEME, REDIRECT_URL_SCHEME_DEV} from './sso'; import Screens from './screens'; @@ -21,6 +23,7 @@ import WebsocketEvents from './websocket'; export { ActionType, Attachment, + Apps, Database, DeepLink, Device, @@ -30,6 +33,7 @@ export { Navigation, Network, Permissions, + Post, Preferences, REDIRECT_URL_SCHEME, REDIRECT_URL_SCHEME_DEV, diff --git a/app/constants/network.ts b/app/constants/network.ts index ac51eef7d..1b67499d0 100644 --- a/app/constants/network.ts +++ b/app/constants/network.ts @@ -8,6 +8,9 @@ export const CERTIFICATE_ERRORS = keyMirror({ CLIENT_CERTIFICATE_MISSING: null, }); +export const DOWNLOAD_TIMEOUT = (1000 * 60) * 10; // 10 mins + export default { CERTIFICATE_ERRORS, + DOWNLOAD_TIMEOUT, }; diff --git a/app/constants/post.ts b/app/constants/post.ts new file mode 100644 index 000000000..df2f0bba4 --- /dev/null +++ b/app/constants/post.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const PostTypes: Record = { + CHANNEL_DELETED: 'system_channel_deleted', + CHANNEL_UNARCHIVED: 'system_channel_restored', + DISPLAYNAME_CHANGE: 'system_displayname_change', + CONVERT_CHANNEL: 'system_convert_channel', + EPHEMERAL: 'system_ephemeral', + EPHEMERAL_ADD_TO_CHANNEL: 'system_ephemeral_add_to_channel', + HEADER_CHANGE: 'system_header_change', + PURPOSE_CHANGE: 'system_purpose_change', + + SYSTEM_MESSAGE_PREFIX: 'system_', + JOIN_LEAVE: 'system_join_leave', + JOIN_CHANNEL: 'system_join_channel', + GUEST_JOIN_CHANNEL: 'system_guest_join_channel', + LEAVE_CHANNEL: 'system_leave_channel', + ADD_REMOVE: 'system_add_remove', + ADD_TO_CHANNEL: 'system_add_to_channel', + ADD_GUEST_TO_CHANNEL: 'system_add_guest_to_chan', + REMOVE_FROM_CHANNEL: 'system_remove_from_channel', + + JOIN_TEAM: 'system_join_team', + LEAVE_TEAM: 'system_leave_team', + ADD_TO_TEAM: 'system_add_to_team', + REMOVE_FROM_TEAM: 'system_remove_from_team', + + COMBINED_USER_ACTIVITY: 'system_combined_user_activity', + ME: 'me', + ADD_BOT_TEAMS_CHANNELS: 'add_bot_teams_channels', + + SYSTEM_AUTO_RESPONDER: 'system_auto_responder', +}; + +export default { + POST_COLLAPSE_TIMEOUT: 1000 * 60 * 5, + POST_TYPES: PostTypes, + USER_ACTIVITY_POST_TYPES: [ + PostTypes.ADD_TO_CHANNEL, + PostTypes.JOIN_CHANNEL, + PostTypes.LEAVE_CHANNEL, + PostTypes.REMOVE_FROM_CHANNEL, + PostTypes.ADD_TO_TEAM, + PostTypes.JOIN_TEAM, + PostTypes.LEAVE_TEAM, + PostTypes.REMOVE_FROM_TEAM, + ], +}; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 726fdc232..026bda842 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -2,14 +2,18 @@ // See LICENSE.txt for license information. export const ABOUT = 'About'; +export const APP_FORM = 'AppForm'; export const BOTTOM_SHEET = 'BottomSheet'; export const CHANNEL = 'Channel'; export const FORGOT_PASSWORD = 'ForgotPassword'; +export const INTEGRATION_SELECTOR = 'IntegrationSelector'; export const HOME = 'Home'; export const LOGIN = 'Login'; export const LOGIN_OPTIONS = 'LoginOptions'; export const MAIN_SIDEBAR = 'MainSidebar'; export const MFA = 'MFA'; +export const PERMALINK = 'Permalink'; +export const SEARCH = 'Search'; export const SERVER = 'Server'; export const SETTINGS_SIDEBAR = 'SettingsSidebar'; export const SSO = 'SSO'; @@ -17,14 +21,18 @@ export const THREAD = 'Thread'; export default { ABOUT, + APP_FORM, BOTTOM_SHEET, CHANNEL, FORGOT_PASSWORD, + INTEGRATION_SELECTOR, HOME, LOGIN, LOGIN_OPTIONS, MAIN_SIDEBAR, MFA, + PERMALINK, + SEARCH, SERVER, SETTINGS_SIDEBAR, SSO, diff --git a/app/context/theme/index.tsx b/app/context/theme/index.tsx index 2b1ed865c..39c6d277f 100644 --- a/app/context/theme/index.tsx +++ b/app/context/theme/index.tsx @@ -26,14 +26,14 @@ type WithThemeProps = { const {SERVER: {PREFERENCE, SYSTEM}} = MM_TABLES; -function getDefaultThemeByAppearance(): Theme { +export function getDefaultThemeByAppearance(): Theme { if (Appearance.getColorScheme() === 'dark') { return Preferences.THEMES.onyx; } return Preferences.THEMES.denim; } -const ThemeContext = createContext(getDefaultThemeByAppearance()); +export const ThemeContext = createContext(getDefaultThemeByAppearance()); const {Consumer, Provider} = ThemeContext; const ThemeProvider = ({currentTeamId, children, themes}: Props) => { diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index 4ebdf5677..fd0d36f33 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -17,7 +17,7 @@ import {schema as appSchema} from '@app/database/schema/app'; import ServerDatabaseMigrations from '@database/migration/server'; import {ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, - PostModel, PostMetadataModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, + PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, TermsOfServiceModel, UserModel, } from '@database/models/server'; @@ -52,7 +52,7 @@ class DatabaseManager { this.serverModels = [ ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, - PostModel, PostMetadataModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, + PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, TermsOfServiceModel, UserModel, ]; diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index aa8446434..b5a24cc8f 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -17,7 +17,7 @@ import {schema as appSchema} from '@database/schema/app'; import ServerDatabaseMigrations from '@database/migration/server'; import {ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, - PostModel, PostMetadataModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, + PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, TermsOfServiceModel, UserModel, } from '@database/models/server'; @@ -46,7 +46,7 @@ class DatabaseManager { this.serverModels = [ ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, - PostModel, PostMetadataModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, + PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, TermsOfServiceModel, UserModel, ]; diff --git a/app/database/models/server/group_membership.ts b/app/database/models/server/group_membership.ts index c8729ea36..47054b76c 100644 --- a/app/database/models/server/group_membership.ts +++ b/app/database/models/server/group_membership.ts @@ -1,8 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Q, Query, Relation} from '@nozbe/watermelondb'; -import {field, immutableRelation, lazy} from '@nozbe/watermelondb/decorators'; +import {Relation} from '@nozbe/watermelondb'; +import {field, immutableRelation} from '@nozbe/watermelondb/decorators'; import Model, {Associations} from '@nozbe/watermelondb/Model'; import {MM_TABLES} from '@constants/database'; @@ -36,19 +36,9 @@ export default class GroupMembershipModel extends Model { /* user_id: The foreign key to the related User record*/ @field('user_id') userId!: string; - /** memberGroup : The related group this user belongs to */ - @immutableRelation(GROUP, 'group_id') memberGroup!: Relation; + /** group : The related group this user belongs to */ + @immutableRelation(GROUP, 'group_id') group!: Relation; - /** memberUser : The related user in the group */ - @immutableRelation(USER, 'user_id') memberUser!: Relation; - - /** - * getAllGroupsForUser : Retrieves all the groups that the user is part of - */ - @lazy getAllGroupsForUser = this.collections.get(GROUP).query(Q.on(USER, 'id', this.userId)) as Query; - - /** - * getAllUsersInGroup : Retrieves all the users who are part of this group - */ - @lazy getAllUsersInGroup = this.collections.get(USER).query(Q.on(GROUP, 'id', this.groupId)) as Query; + /** user : The related user in the group */ + @immutableRelation(USER, 'user_id') user!: Relation; } diff --git a/app/database/models/server/index.ts b/app/database/models/server/index.ts index a39839d6f..473b942c4 100644 --- a/app/database/models/server/index.ts +++ b/app/database/models/server/index.ts @@ -14,7 +14,6 @@ export {default as GroupModel} from './group'; export {default as MyChannelSettingsModel} from './my_channel_settings'; export {default as MyChannelModel} from './my_channel'; export {default as MyTeamModel} from './my_team'; -export {default as PostMetadataModel} from './post_metadata'; export {default as PostsInChannelModel} from './posts_in_channel'; export {default as PostsInThreadModel} from './posts_in_thread'; export {default as PostModel} from './post'; diff --git a/app/database/models/server/post.ts b/app/database/models/server/post.ts index ae3d4430d..1b5c3a3ed 100644 --- a/app/database/models/server/post.ts +++ b/app/database/models/server/post.ts @@ -12,11 +12,10 @@ import type ChannelModel from '@typings/database/models/servers/channel'; import type DraftModel from '@typings/database/models/servers/draft'; import type FileModel from '@typings/database/models/servers/file'; import type PostInThreadModel from '@typings/database/models/servers/posts_in_thread'; -import type PostMetadataModel from '@typings/database/models/servers/post_metadata'; import type ReactionModel from '@typings/database/models/servers/reaction'; import type UserModel from '@typings/database/models/servers/user'; -const {CHANNEL, DRAFT, FILE, POST, POSTS_IN_THREAD, POST_METADATA, REACTION, USER} = MM_TABLES.SERVER; +const {CHANNEL, DRAFT, FILE, POST, POSTS_IN_THREAD, REACTION, USER} = MM_TABLES.SERVER; /** * The Post model is the building block of communication in the Mattermost app. @@ -68,6 +67,9 @@ export default class PostModel extends Model { /** message : Message in the post */ @field('message') message!: string; + /** metadata: All the extra data associated with this Post */ + @json('metadata', safeParseJSON) metadata!: PostMetadata | null; + /** original_id : Any post will have this value empty unless it is updated */ @field('original_id') originalId!: string; @@ -87,26 +89,39 @@ export default class PostModel extends Model { @field('user_id') userId!: string; /** props : Additional attributes for this props */ - @json('props', safeParseJSON) props!: object; + @json('props', safeParseJSON) props!: any; // A draft can be associated with this post for as long as this post is a parent post @lazy draft = this.collections.get(DRAFT).query(Q.on(POST, 'id', this.id)) as Query; + @lazy root = this.collection.query(Q.where('id', this.rootId)) as Query; + /** postsInThread: The thread to which this post is associated */ - @lazy postsInThread = this.collections.get(POSTS_IN_THREAD).query(Q.on(POST, 'id', this.id)) as Query; + @lazy postsInThread = this.collections.get(POSTS_IN_THREAD).query( + Q.where('root_id', this.rootId || this.id), + Q.experimentalSortBy('latest', Q.desc), + Q.experimentalTake(1), + ) as Query; /** files: All the files associated with this Post */ - @children(FILE) files!: FileModel[]; - - /** metadata: All the extra data associated with this Post */ - @immutableRelation(POST_METADATA, 'id') metadata!: Relation; + @children(FILE) files!: Query; /** reactions: All the reactions associated with this Post */ - @children(REACTION) reactions!: ReactionModel[]; + @children(REACTION) reactions!: Query; /** author: The author of this Post */ @immutableRelation(USER, 'user_id') author!: Relation; /** channel: The channel which is presenting this Post */ @immutableRelation(CHANNEL, 'channel_id') channel!: Relation; + + async destroyPermanently() { + await this.reactions.destroyAllPermanently(); + await this.files.destroyAllPermanently(); + await this.draft.destroyAllPermanently(); + await this.collections.get(POSTS_IN_THREAD).query( + Q.where('root_id', this.id), + ).destroyAllPermanently(); + super.destroyPermanently(); + } } diff --git a/app/database/models/server/post_metadata.ts b/app/database/models/server/post_metadata.ts deleted file mode 100644 index b92d79cf2..000000000 --- a/app/database/models/server/post_metadata.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Relation} from '@nozbe/watermelondb'; -import {immutableRelation, json} from '@nozbe/watermelondb/decorators'; -import Model from '@nozbe/watermelondb/Model'; - -import {MM_TABLES} from '@constants/database'; -import {safeParseJSON} from '@utils/helpers'; - -import type PostModel from '@typings/database/models/servers/post'; - -const {POST, POST_METADATA} = MM_TABLES.SERVER; - -/** - * PostMetadata provides additional information on a POST - */ -export default class PostMetadataModel extends Model { - /** table (name) : PostMetadata */ - static table = POST_METADATA; - - /** data : Different types of data ranging from embeds to images. */ - @json('data', safeParseJSON) data!: PostMetadata; - - /** post: The record representing the POST parent. */ - @immutableRelation(POST, 'id') post!: Relation; -} diff --git a/app/database/models/server/posts_in_channel.ts b/app/database/models/server/posts_in_channel.ts index 8952485d4..99dfe7631 100644 --- a/app/database/models/server/posts_in_channel.ts +++ b/app/database/models/server/posts_in_channel.ts @@ -36,5 +36,5 @@ export default class PostsInChannelModel extends Model { @field('latest') latest!: number; /** channel : The parent record of the channel for those posts */ - @immutableRelation(CHANNEL, 'id') channel!: Relation; + @immutableRelation(CHANNEL, 'channel_id') channel!: Relation; } diff --git a/app/database/models/server/posts_in_thread.ts b/app/database/models/server/posts_in_thread.ts index 1b72d94df..bf4fcd5a7 100644 --- a/app/database/models/server/posts_in_thread.ts +++ b/app/database/models/server/posts_in_thread.ts @@ -36,5 +36,5 @@ export default class PostsInThreadModel extends Model { @field('latest') latest!: number; /** post : The related record to the parent Post model */ - @immutableRelation(POST, 'id') post!: Relation; + @immutableRelation(POST, 'root_id') post!: Relation; } diff --git a/app/database/models/server/user.ts b/app/database/models/server/user.ts index dcd44dd9a..ba2ad6bcd 100644 --- a/app/database/models/server/user.ts +++ b/app/database/models/server/user.ts @@ -135,4 +135,10 @@ export default class UserModel extends Model { /** teams : All the team that this user is part of */ @children(TEAM_MEMBERSHIP) teams!: TeamMembershipModel[]; + + prepareSatus = (status: string) => { + this.prepareUpdate((u) => { + u.status = status; + }); + } } diff --git a/app/database/operator/server_data_operator/comparators/index.ts b/app/database/operator/server_data_operator/comparators/index.ts index 3c9525d95..42f13a5a7 100644 --- a/app/database/operator/server_data_operator/comparators/index.ts +++ b/app/database/operator/server_data_operator/comparators/index.ts @@ -15,7 +15,6 @@ import type MyChannelModel from '@typings/database/models/servers/my_channel'; import type MyChannelSettingsModel from '@typings/database/models/servers/my_channel_settings'; import type MyTeamModel from '@typings/database/models/servers/my_team'; import type PostModel from '@typings/database/models/servers/post'; -import type PostMetadataModel from '@typings/database/models/servers/post_metadata'; import type PreferenceModel from '@typings/database/models/servers/preference'; import type RoleModel from '@typings/database/models/servers/role'; import type SlashCommandModel from '@typings/database/models/servers/slash_command'; @@ -133,7 +132,3 @@ export const isRecordMyChannelEqualToRaw = (record: MyChannelModel, raw: Channel export const isRecordFileEqualToRaw = (record: FileModel, raw: FileInfo) => { return raw.id === record.id; }; - -export const isRecordMetadataEqualToRaw = (record: PostMetadataModel, raw: Metadata) => { - return raw.id === record.id; -}; diff --git a/app/database/operator/server_data_operator/handlers/post.test.ts b/app/database/operator/server_data_operator/handlers/post.test.ts index 2fc521142..c9bd14433 100644 --- a/app/database/operator/server_data_operator/handlers/post.test.ts +++ b/app/database/operator/server_data_operator/handlers/post.test.ts @@ -78,7 +78,6 @@ describe('*** Operator: Post Handlers tests ***', () => { user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', root_id: '', - parent_id: 'ps81iqbddesfby8jayz7owg4yypoo', original_id: '', message: "I'll second these kudos! Thanks m!", type: '', @@ -173,7 +172,6 @@ describe('*** Operator: Post Handlers tests ***', () => { user_id: 'hy5sq51sebfh58ktrce5ijtcwyy', channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', root_id: '8swgtrrdiff89jnsiwiip3y1eoe', - parent_id: '', original_id: '', message: 'a added to the channel by j.', type: 'system_add_to_channel', @@ -200,7 +198,6 @@ describe('*** Operator: Post Handlers tests ***', () => { user_id: '44ud4m9tqwby3mphzzdwm7h31sr', channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', root_id: '8swgtrrdiff89jnsiwiip3y1eoe', - parent_id: 'ps81iqbwesfby8jayz7owg4yypo', original_id: '', message: 'Great work M!', type: '', @@ -222,7 +219,6 @@ describe('*** Operator: Post Handlers tests ***', () => { const actionType = ActionType.POSTS.RECEIVED_IN_CHANNEL; const spyOnHandleFiles = jest.spyOn(operator, 'handleFiles'); - const spyOnHandlePostMetadata = jest.spyOn(operator, 'handlePostMetadata'); const spyOnHandleReactions = jest.spyOn(operator, 'handleReactions'); const spyOnHandleCustomEmojis = jest.spyOn(operator, 'handleCustomEmojis'); const spyOnHandlePostsInThread = jest.spyOn(operator, 'handlePostsInThread'); @@ -276,51 +272,6 @@ describe('*** Operator: Post Handlers tests ***', () => { prepareRecordsOnly: true, }); - expect(spyOnHandlePostMetadata).toHaveBeenCalledTimes(1); - expect(spyOnHandlePostMetadata).toHaveBeenCalledWith({ - metadatas: [{ - data: { - embeds: [ - { - type: 'opengraph', - url: 'https://github.com/mickmister/mattermost-plugin-default-theme', - data: { - type: 'object', - url: 'https://github.com/mickmister/mattermost-plugin-default-theme', - title: 'mickmister/mattermost-plugin-default-theme', - description: 'Contribute to mickmister/mattermost-plugin-default-theme development by creating an account on GitHub.', - determiner: '', - site_name: 'GitHub', - locale: '', - locales_alternate: null, - images: [ - { - url: '', - secure_url: 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4', - type: '', - width: 0, - height: 0, - }, - ], - audios: null, - videos: null, - }, - }, - ], - images: { - 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4': { - width: 400, - height: 400, - format: 'png', - frame_count: 0, - }, - }, - }, - id: '8swgtrrdiff89jnsiwiip3y1eoe', - }], - prepareRecordsOnly: true, - }); - expect(spyOnHandleCustomEmojis).toHaveBeenCalledTimes(1); expect(spyOnHandleCustomEmojis).toHaveBeenCalledWith({ prepareRecordsOnly: true, diff --git a/app/database/operator/server_data_operator/handlers/post.ts b/app/database/operator/server_data_operator/handlers/post.ts index 02e8ca5e5..e42fb0385 100644 --- a/app/database/operator/server_data_operator/handlers/post.ts +++ b/app/database/operator/server_data_operator/handlers/post.ts @@ -5,21 +5,19 @@ import Model from '@nozbe/watermelondb/Model'; import {ActionType, Database} from '@constants'; import DataOperatorException from '@database/exceptions/data_operator_exception'; -import {isRecordDraftEqualToRaw, isRecordFileEqualToRaw, isRecordMetadataEqualToRaw, isRecordPostEqualToRaw} from '@database/operator/server_data_operator/comparators'; +import {isRecordDraftEqualToRaw, isRecordFileEqualToRaw, isRecordPostEqualToRaw} from '@database/operator/server_data_operator/comparators'; import { transformDraftRecord, transformFileRecord, - transformPostMetadataRecord, transformPostRecord, } from '@database/operator/server_data_operator/transformers/post'; import {getUniqueRawsBy} from '@database/operator/utils/general'; import {createPostsChain} from '@database/operator/utils/post'; -import type {HandleDraftArgs, HandleFilesArgs, HandlePostMetadataArgs, HandlePostsArgs, ProcessRecordResults} from '@typings/database/database'; +import type {HandleDraftArgs, HandleFilesArgs, HandlePostsArgs, ProcessRecordResults} from '@typings/database/database'; import type DraftModel from '@typings/database/models/servers/draft'; import type FileModel from '@typings/database/models/servers/file'; import type PostModel from '@typings/database/models/servers/post'; -import type PostMetadataModel from '@typings/database/models/servers/post_metadata'; import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel'; import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; import type ReactionModel from '@typings/database/models/servers/reaction'; @@ -28,13 +26,11 @@ const { DRAFT, FILE, POST, - POST_METADATA, } = Database.MM_TABLES.SERVER; export interface PostHandlerMix { handleDraft: ({drafts, prepareRecordsOnly}: HandleDraftArgs) => Promise; handleFiles: ({files, prepareRecordsOnly}: HandleFilesArgs) => Promise; - handlePostMetadata: ({metadatas, prepareRecordsOnly}: HandlePostMetadataArgs) => Promise; handlePosts: ({actionType, order, posts, previousPostId}: HandlePostsArgs) => Promise; handlePostsInChannel: (posts: Post[]) => Promise; handlePostsInThread: (rootPosts: PostsInThread[]) => Promise; @@ -85,12 +81,6 @@ const PostHandler = (superclass: any) => class extends superclass { return; } - // Get unique posts in case they are duplicated - const uniquePosts = getUniqueRawsBy({ - raws: posts, - key: 'id', - }) as Post[]; - const emojis: CustomEmoji[] = []; const files: FileInfo[] = []; const metadatas: Metadata[] = []; @@ -138,13 +128,16 @@ const PostHandler = (superclass: any) => class extends superclass { delete data.files; } - metadatas.push({ - data, - id: post.id, - }); + post.metadata = data; } } + // Get unique posts in case they are duplicated + const uniquePosts = getUniqueRawsBy({ + raws: posts, + key: 'id', + }) as Post[]; + // Process the posts to get which ones need to be created and which updated const processedPosts = (await this.processRecords({ createOrUpdateRawValues: uniquePosts, @@ -246,40 +239,6 @@ const PostHandler = (superclass: any) => class extends superclass { return postFiles; }; - /** - * handlePostMetadata: Handler responsible for the Create/Update operations occurring on the PostMetadata table from the 'Server' schema - * @param {HandlePostMetadataArgs} handlePostMetadata - * @param {{embed: RawEmbed[], postId: string}[] | undefined} handlePostMetadata.embeds - * @param {{images: Dictionary, postId: string}[] | undefined} handlePostMetadata.images - * @param {boolean} handlePostMetadata.prepareRecordsOnly - * @returns {Promise} - */ - handlePostMetadata = async ({metadatas, prepareRecordsOnly}: HandlePostMetadataArgs): Promise => { - const processedMetas = (await this.processRecords({ - createOrUpdateRawValues: metadatas, - tableName: POST_METADATA, - findMatchingRecordBy: isRecordMetadataEqualToRaw, - fieldName: 'id', - })) as ProcessRecordResults; - - const postMetas = await this.prepareRecords({ - createRaws: processedMetas.createRaws, - updateRaws: processedMetas.updateRaws, - transformer: transformPostMetadataRecord, - tableName: POST_METADATA, - }); - - if (prepareRecordsOnly) { - return postMetas; - } - - if (postMetas?.length) { - await this.batchRecords(postMetas); - } - - return postMetas; - }; - /** * handlePostsInThread: Handler responsible for the Create/Update operations occurring on the PostsInThread table from the 'Server' schema * @param {PostsInThread[]} rootPosts @@ -294,10 +253,8 @@ const PostHandler = (superclass: any) => class extends superclass { case ActionType.POSTS.RECEIVED_SINCE: case ActionType.POSTS.RECEIVED_AFTER: case ActionType.POSTS.RECEIVED_BEFORE: + case ActionType.POSTS.RECEIVED_NEW: return this.handleReceivedPostsInThread(postsMap, prepareRecordsOnly) as Promise; - case ActionType.POSTS.RECEIVED_NEW: { - return this.handleReceivedPostForThread(Object.values(postsMap)[0], prepareRecordsOnly) as Promise; - } } return []; @@ -328,7 +285,7 @@ const PostHandler = (superclass: any) => class extends superclass { case ActionType.POSTS.RECEIVED_BEFORE: return this.handleReceivedPostsInChannelBefore(posts, prepareRecordsOnly) as Promise; case ActionType.POSTS.RECEIVED_NEW: - return this.handleReceivedPostForChannel(posts[0], prepareRecordsOnly) as Promise; + return this.handleReceivedPostForChannel(posts, prepareRecordsOnly) as Promise; } return []; diff --git a/app/database/operator/server_data_operator/handlers/posts_in_channel.ts b/app/database/operator/server_data_operator/handlers/posts_in_channel.ts index 67b555403..d8e82352a 100644 --- a/app/database/operator/server_data_operator/handlers/posts_in_channel.ts +++ b/app/database/operator/server_data_operator/handlers/posts_in_channel.ts @@ -174,8 +174,53 @@ const PostsInChannelHandler = (superclass: any) => class extends superclass { throw new Error(`handleReceivedPostsInChannelAfter Not implemented yet. posts count${posts.length} prepareRecordsOnly=${prepareRecordsOnly}`); } - handleReceivedPostForChannel = async (post: Post, prepareRecordsOnly = false): Promise => { - throw new Error(`handleReceivedPostsInChannelAfter Not implemented yet. postId ${post.id} prepareRecordsOnly=${prepareRecordsOnly}`); + handleReceivedPostForChannel = async (posts: Post[], prepareRecordsOnly = false): Promise => { + if (!posts.length) { + return []; + } + + const {firstPost, lastPost} = getPostListEdges(posts); + + // Channel Id for this chain of posts + const channelId = firstPost.channel_id; + + // Find smallest 'create_at' value in chain + const earliest = firstPost.create_at; + + // Find highest 'create_at' value in chain; -1 means we are dealing with one item in the posts array + const latest = lastPost.create_at; + + // Find the records in the PostsInChannel table that have a matching channel_id + const chunks = (await this.database.get(POSTS_IN_CHANNEL).query( + Q.where('channel_id', channelId), + Q.experimentalSortBy('latest', Q.desc), + ).fetch()) as PostsInChannelModel[]; + + // chunk length 0; then it's a new chunk to be added to the PostsInChannel table + if (chunks.length === 0) { + return this._createPostsInChannelRecord(channelId, earliest, latest, prepareRecordsOnly); + } + + let targetChunk = chunks[0]; + if (targetChunk) { + // If the chunk was found, Update the chunk and return + if (prepareRecordsOnly) { + targetChunk.prepareUpdate((record) => { + record.latest = Math.max(record.latest, latest); + }); + return [targetChunk]; + } + + targetChunk = await this.database.write(async () => { + return targetChunk!.update((record) => { + record.latest = Math.max(record.latest, latest); + }); + }); + + return [targetChunk!]; + } + + return targetChunk; } }; diff --git a/app/database/operator/server_data_operator/handlers/posts_in_thread.ts b/app/database/operator/server_data_operator/handlers/posts_in_thread.ts index c5ba4ac9d..56670952b 100644 --- a/app/database/operator/server_data_operator/handlers/posts_in_thread.ts +++ b/app/database/operator/server_data_operator/handlers/posts_in_thread.ts @@ -4,7 +4,7 @@ import {Q} from '@nozbe/watermelondb'; import {Database} from '@constants'; -import {getRawRecordPairs, getValidRecordsForUpdate, retrieveRecords} from '@database/operator/utils/general'; +import {getRawRecordPairs, getValidRecordsForUpdate} from '@database/operator/utils/general'; import {transformPostInThreadRecord} from '@database/operator/server_data_operator/transformers/post'; import {getPostListEdges} from '@database//operator/utils/post'; @@ -13,7 +13,6 @@ import type PostsInThreadModel from '@typings/database/models/servers/posts_in_t export interface PostsInThreadHandlerMix { handleReceivedPostsInThread: (postsMap: Record, prepareRecordsOnly?: boolean) => Promise; - handleReceivedPostForThread: (post: Post, prepareRecordsOnly?: boolean) => Promise; } const {POSTS_IN_THREAD} = Database.MM_TABLES.SERVER; @@ -29,11 +28,10 @@ const PostsInThreadHandler = (superclass: any) => class extends superclass { const ids = Object.keys(postsMap); for await (const rootId of ids) { const {firstPost, lastPost} = getPostListEdges(postsMap[rootId]); - const chunks = (await retrieveRecords({ - database: this.database, - tableName: POSTS_IN_THREAD, - condition: Q.where('root_id', rootId), - })) as PostsInThreadModel[]; + const chunks = (await this.database.get(POSTS_IN_THREAD).query( + Q.where('root_id', rootId), + Q.experimentalSortBy('latest', Q.desc), + ).fetch()) as PostsInThreadModel[]; if (chunks.length) { const chunk = chunks[0]; @@ -70,10 +68,6 @@ const PostsInThreadHandler = (superclass: any) => class extends superclass { return postInThreadRecords; }; - - handleReceivedPostForThread = async (post: Post, prepareRecordsOnly = false): Promise => { - throw new Error(`handleReceivedPostForThread Not implemented yet. postId ${post.id} prepareRecordsOnly=${prepareRecordsOnly}`); - } }; export default PostsInThreadHandler; diff --git a/app/database/operator/server_data_operator/handlers/user.test.ts b/app/database/operator/server_data_operator/handlers/user.test.ts index 98094c45c..391f0fc22 100644 --- a/app/database/operator/server_data_operator/handlers/user.test.ts +++ b/app/database/operator/server_data_operator/handlers/user.test.ts @@ -151,6 +151,7 @@ describe('*** Operator: User Handlers tests ***', () => { expect(spyOnHandleRecords).toHaveBeenCalledWith({ fieldName: 'user_id', createOrUpdateRawValues: preferences, + deleteRawValues: [], tableName: 'Preference', prepareRecordsOnly: false, findMatchingRecordBy: isRecordPreferenceEqualToRaw, diff --git a/app/database/operator/server_data_operator/handlers/user.ts b/app/database/operator/server_data_operator/handlers/user.ts index 918463bf2..589def61d 100644 --- a/app/database/operator/server_data_operator/handlers/user.ts +++ b/app/database/operator/server_data_operator/handlers/user.ts @@ -79,13 +79,30 @@ const UserHandler = (superclass: any) => class extends superclass { * @throws DataOperatorException * @returns {Promise} */ - handlePreferences = ({preferences, prepareRecordsOnly = true}: HandlePreferencesArgs): Promise => { + handlePreferences = async ({preferences, prepareRecordsOnly = true, sync = false}: HandlePreferencesArgs): Promise => { if (!preferences.length) { throw new DataOperatorException( 'An empty "preferences" array has been passed to the handlePreferences method', ); } + // WE NEED TO SYNC THE PREFS FROM WHAT WE GOT AND WHAT WE HAVE + const deleteRawValues: PreferenceType[] = []; + if (sync) { + const stored = await this.database.get(PREFERENCE).fetch() as PreferenceModel[]; + for (const pref of stored) { + const exists = preferences.findIndex((p) => p.category === pref.category && p.name === pref.name) > -1; + if (!exists) { + deleteRawValues.push({ + category: pref.category, + name: pref.name, + user_id: pref.userId, + value: pref.value, + }); + } + } + } + const createOrUpdateRawValues = getUniqueRawsBy({raws: preferences, key: 'name'}); return this.handleRecords({ @@ -94,6 +111,7 @@ const UserHandler = (superclass: any) => class extends superclass { transformer: transformPreferenceRecord, prepareRecordsOnly, createOrUpdateRawValues, + deleteRawValues, tableName: PREFERENCE, }); }; diff --git a/app/database/operator/server_data_operator/transformers/post.test.ts b/app/database/operator/server_data_operator/transformers/post.test.ts index b43c52cd8..ce44cb179 100644 --- a/app/database/operator/server_data_operator/transformers/post.test.ts +++ b/app/database/operator/server_data_operator/transformers/post.test.ts @@ -5,7 +5,6 @@ import { transformDraftRecord, transformFileRecord, transformPostInThreadRecord, - transformPostMetadataRecord, transformPostRecord, transformPostsInChannelRecord, } from '@database/operator/server_data_operator/transformers/post'; @@ -34,7 +33,6 @@ describe('*** POST Prepare Records Test ***', () => { user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', root_id: 'ps81iqbesfby8jayz7owg4yypoo', - parent_id: 'ps81iqbddesfby8jayz7owg4yypoo', original_id: '', message: 'Testing composer post', type: '', @@ -112,48 +110,6 @@ describe('*** POST Prepare Records Test ***', () => { expect(preparedRecords!.collection.modelClass.name).toBe('FileModel'); }); - it('=> transformPostMetadataRecord: should return an array of type PostMetadata', async () => { - expect.assertions(3); - - const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true}); - expect(database).toBeTruthy(); - - const preparedRecords = await transformPostMetadataRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'ps81i4yypoo', - data: { - files: [ - { - id: 'mjagj4ta4tb93f7mwdn68yw9rc', - user_id: 'gy5cnn5q9i8txdkcrj4dhntnta', - post_id: '4wpufe8d5pd7jpwshzrumgjd7r', - create_at: 1626207675617, - update_at: 1626207675617, - delete_at: 0, - name: 'Image Pasted at 2021-7-13 22-21.png', - extension: 'png', - size: 4668, - mime_type: 'image/png', - width: 67, - height: 116, - has_preview_image: true, - mini_preview: '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/APPv2c/gr8O/GvwHbWNW0CDW/Elxd6nbS3janMH090jj+xK1tDIHEUhZy0wjl27ANpDZXhP2kvgzo3wk8OeDjZPpbahfXE8bm2kdbqWKOOIieWFpXMSs7yKoYKxCZIGcCz+zvB8J5vD98vjIacmutFCLGfV4XlsIzvfzjKsfzmTGzAY7dp45zXjvirV9I1LXdRW4gXw7GLib7Iuh6J59uyo22IqWnDhZASzZ6bVwOTjuelS7vockKr9m4RUdd9r6equvkz//2Q==', - }, - ], - }, - post_id: 'ps81iqbddesfby8jayz7owg4yypoo', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toBe('PostMetadataModel'); - }); - it('=> transformDraftRecord: should return an array of type Draft', async () => { expect.assertions(3); diff --git a/app/database/operator/server_data_operator/transformers/post.ts b/app/database/operator/server_data_operator/transformers/post.ts index 1235b97f3..809392f81 100644 --- a/app/database/operator/server_data_operator/transformers/post.ts +++ b/app/database/operator/server_data_operator/transformers/post.ts @@ -1,6 +1,5 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Q} from '@nozbe/watermelondb'; import {MM_TABLES} from '@constants/database'; import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index'; @@ -10,7 +9,6 @@ import type DraftModel from '@typings/database/models/servers/draft'; import {OperationType} from '@typings/database/enums'; import type FileModel from '@typings/database/models/servers/file'; import type PostModel from '@typings/database/models/servers/post'; -import type PostMetadataModel from '@typings/database/models/servers/post_metadata'; import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel'; import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; @@ -20,7 +18,6 @@ const { POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, - POST_METADATA, } = MM_TABLES.SERVER; /** @@ -44,7 +41,8 @@ export const transformPostRecord = ({action, database, value}: TransformerArgs): post.editAt = raw.edit_at; post.updateAt = raw.update_at; post.isPinned = Boolean(raw.is_pinned); - post.message = Q.sanitizeLikeString(raw.message); + post.message = raw.message; + post.metadata = Object.keys(raw.metadata).length ? raw.metadata : null; post.userId = raw.user_id; post.originalId = raw.original_id; post.pendingPostId = raw.pending_post_id; @@ -126,32 +124,6 @@ export const transformFileRecord = ({action, database, value}: TransformerArgs): }) as Promise; }; -/** - * transformPostMetadataRecord: Prepares a record of the SERVER database 'PostMetadata' table for update or create actions. - * @param {TransformerArgs} operator - * @param {Database} operator.database - * @param {RecordPair} operator.value - * @returns {Promise} - */ -export const transformPostMetadataRecord = ({action, database, value}: TransformerArgs): Promise => { - const raw = value.raw as Metadata; - const record = value.record as PostMetadataModel; - const isCreateAction = action === OperationType.CREATE; - - const fieldsMapper = (postMeta: PostMetadataModel) => { - postMeta._raw.id = isCreateAction ? (raw.id || postMeta.id) : record.id; - postMeta.data = raw.data; - }; - - return prepareBaseRecord({ - action, - database, - tableName: POST_METADATA, - value, - fieldsMapper, - }) as Promise; -}; - /** * transformDraftRecord: Prepares a record of the SERVER database 'Draft' table for update or create actions. * @param {TransformerArgs} operator diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index ed29d961a..8e16dc120 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -18,7 +18,6 @@ import { MyChannelSettingsSchema, MyTeamSchema, PostInThreadSchema, - PostMetadataSchema, PostSchema, PostsInChannelSchema, PreferenceSchema, @@ -52,7 +51,6 @@ export const serverSchema: AppSchema = appSchema({ MyTeamSchema, PostsInChannelSchema, PostInThreadSchema, - PostMetadataSchema, PostSchema, PreferenceSchema, ReactionSchema, diff --git a/app/database/schema/server/table_schemas/index.ts b/app/database/schema/server/table_schemas/index.ts index dc360ef9c..c264074fe 100644 --- a/app/database/schema/server/table_schemas/index.ts +++ b/app/database/schema/server/table_schemas/index.ts @@ -16,7 +16,6 @@ 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 PostMetadataSchema} from './post_metadata'; export {default as PostSchema} from './post'; export {default as PreferenceSchema} from './preference'; export {default as ReactionSchema} from './reaction'; diff --git a/app/database/schema/server/table_schemas/post.ts b/app/database/schema/server/table_schemas/post.ts index baa5afac9..e33ca1548 100644 --- a/app/database/schema/server/table_schemas/post.ts +++ b/app/database/schema/server/table_schemas/post.ts @@ -17,6 +17,7 @@ export default tableSchema({ {name: 'edit_at', type: 'number'}, {name: 'is_pinned', type: 'boolean'}, {name: 'message', type: 'string'}, + {name: 'metadata', type: 'string', isOptional: true}, {name: 'original_id', type: 'string'}, {name: 'pending_post_id', type: 'string', isIndexed: true}, {name: 'previous_post_id', type: 'string'}, diff --git a/app/database/schema/server/table_schemas/post_metadata.ts b/app/database/schema/server/table_schemas/post_metadata.ts deleted file mode 100644 index 5ef0598a1..000000000 --- a/app/database/schema/server/table_schemas/post_metadata.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {tableSchema} from '@nozbe/watermelondb'; - -import {MM_TABLES} from '@constants/database'; - -const {POST_METADATA} = MM_TABLES.SERVER; - -export default tableSchema({ - name: POST_METADATA, - columns: [ - {name: 'data', type: 'string'}, - ], -}); diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index c8f900427..3f96d7b49 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -24,7 +24,6 @@ const { POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, - POST_METADATA, PREFERENCE, REACTION, ROLE, @@ -201,15 +200,6 @@ describe('*** Test schema for SERVER database ***', () => { {name: 'latest', type: 'number'}, ], }, - [POST_METADATA]: { - name: POST_METADATA, - columns: { - data: {name: 'data', type: 'string'}, - }, - columnArray: [ - {name: 'data', type: 'string'}, - ], - }, [POST]: { name: POST, columns: { @@ -220,6 +210,7 @@ describe('*** Test schema for SERVER database ***', () => { edit_at: {name: 'edit_at', type: 'number'}, is_pinned: {name: 'is_pinned', type: 'boolean'}, message: {name: 'message', type: 'string'}, + metadata: {name: 'metadata', type: 'string', isOptional: true}, original_id: {name: 'original_id', type: 'string'}, pending_post_id: {name: 'pending_post_id', type: 'string', isIndexed: true}, previous_post_id: {name: 'previous_post_id', type: 'string'}, @@ -236,6 +227,7 @@ describe('*** Test schema for SERVER database ***', () => { {name: 'edit_at', type: 'number'}, {name: 'is_pinned', type: 'boolean'}, {name: 'message', type: 'string'}, + {name: 'metadata', type: 'string', isOptional: true}, {name: 'original_id', type: 'string'}, {name: 'pending_post_id', type: 'string', isIndexed: true}, {name: 'previous_post_id', type: 'string'}, diff --git a/app/helpers/api/general.ts b/app/helpers/api/general.ts new file mode 100644 index 000000000..6e888b5c3 --- /dev/null +++ b/app/helpers/api/general.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Debounce function based on underscores modified to use es6 and a cb + +export function debounce(func: (...args: any) => unknown, wait: number, immediate?: boolean, cb?: () => unknown) { + let timeout: NodeJS.Timeout|null; + return function fx(...args: any[]) { + const runLater = () => { + timeout = null; + if (!immediate) { + Reflect.apply(func, this, args); + if (cb) { + cb(); + } + } + }; + const callNow = immediate && !timeout; + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(runLater, wait); + if (callNow) { + Reflect.apply(func, this, args); + if (cb) { + cb(); + } + } + }; +} diff --git a/app/helpers/api/preference.ts b/app/helpers/api/preference.ts index 49b168f49..bbf8bf4f9 100644 --- a/app/helpers/api/preference.ts +++ b/app/helpers/api/preference.ts @@ -5,13 +5,13 @@ import {General, Preferences} from '@constants'; import type PreferenceModel from '@typings/database/models/servers/preference'; -export function getPreferenceValue(preferences: PreferenceType[], category: string, name: string, defaultValue: unknown = '') { - const pref = preferences.find((p) => p.category === category && p.name === name); +export function getPreferenceValue(preferences: PreferenceType[] | PreferenceModel[], category: string, name: string, defaultValue: unknown = '') { + const pref = (preferences as PreferenceType[]).find((p) => p.category === category && p.name === name); return pref?.value || defaultValue; } -export function getPreferenceAsBool(preferences: PreferenceType[], category: string, name: string, defaultValue = false) { +export function getPreferenceAsBool(preferences: PreferenceType[] | PreferenceModel[], category: string, name: string, defaultValue = false) { const value = getPreferenceValue(preferences, category, name, defaultValue); if (typeof value === 'boolean') { return defaultValue; @@ -20,7 +20,7 @@ export function getPreferenceAsBool(preferences: PreferenceType[], category: str return value !== 'false'; } -export function getPreferenceAsInt(preferences: PreferenceType[], category: string, name: string, defaultValue = 0) { +export function getPreferenceAsInt(preferences: PreferenceType[] | PreferenceModel[], category: string, name: string, defaultValue = 0) { const value = getPreferenceValue(preferences, category, name, defaultValue); if (value) { return parseInt(value as string, 10); diff --git a/app/helpers/database/groups.ts b/app/helpers/database/groups.ts new file mode 100644 index 000000000..23d61b934 --- /dev/null +++ b/app/helpers/database/groups.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type GroupModel from '@typings/database/models/servers/group'; +import type GroupsChannelModel from '@typings/database/models/servers/groups_channel'; +import type GroupsTeamModel from '@typings/database/models/servers/groups_team'; +import type GroupMembershipModel from '@typings/database/models/servers/group_membership'; +import type PostModel from '@typings/database/models/servers/post'; +import type TeamModel from '@typings/database/models/servers/team'; + +export const queryGroupsAssociatedToChannelForReference = async (channel: ChannelModel) => { + const groupChannels = await channel.groupsChannel.fetch() as GroupsChannelModel[]; + const groupChannelPromises = groupChannels.map((g) => g.group.fetch()); + const groups = await Promise.all(groupChannelPromises) as GroupModel[]; + return groups.filter((g) => g.deleteAt === 0); +}; + +export const queryGroupsAssociatedToTeamForReference = async (team: TeamModel) => { + const groupTeams = await team.groupsTeam.fetch() as GroupsTeamModel[]; + const teamGroupPromises = groupTeams.map((g) => g.group.fetch()); + const groups = await Promise.all(teamGroupPromises) as GroupModel[]; + return groups.filter((g) => g.deleteAt === 0); +}; + +export const queryGroupForPosts = async (post: PostModel) => { + try { + if (post.id === post.pendingPostId) { + return null; + } + + const channel = await post.channel.fetch() as ChannelModel; + const team = await channel?.team.fetch() as TeamModel | undefined; + let groupsForReference: GroupModel[] | null = null; + + if (team?.isGroupConstrained && channel.isGroupConstrained) { + const groupsFromChannel = await queryGroupsAssociatedToChannelForReference(channel); + const groupsFromTeam = await queryGroupsAssociatedToTeamForReference(team); + groupsForReference = groupsFromChannel.concat(groupsFromTeam.filter((item) => groupsFromChannel.indexOf(item) < 0)); + } else if (team?.isGroupConstrained) { + groupsForReference = await queryGroupsAssociatedToTeamForReference(team); + } else if (channel.isGroupConstrained) { + groupsForReference = await queryGroupsAssociatedToChannelForReference(channel); + } else { + const myGroups = await post.collections.get(MM_TABLES.SERVER.GROUP_MEMBERSHIP).query().fetch() as GroupMembershipModel[]; + const myGroupsPromises = myGroups.map((g) => g.group.fetch()); + groupsForReference = await Promise.all(myGroupsPromises) as GroupModel[]; + } + + return groupsForReference; + } catch { + return null; + } +}; diff --git a/app/hooks/show_more.ts b/app/hooks/show_more.ts new file mode 100644 index 000000000..ed60089f9 --- /dev/null +++ b/app/hooks/show_more.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useAnimatedStyle, withTiming} from 'react-native-reanimated'; + +export const useShowMoreAnimatedStyle = (height: number|undefined, maxHeight: number, open: boolean) => { + return useAnimatedStyle(() => { + if (height === undefined) { + return { + maxHeight, + }; + } + + if (!open) { + const value = height ? withTiming(maxHeight, {duration: 300}) : withTiming(maxHeight, {duration: 300}); + return { + maxHeight: value, + }; + } + + return { + maxHeight: withTiming(height, {duration: 300}), + }; + }, [open, height]); +}; diff --git a/app/hooks/why_did_you_update.ts b/app/hooks/why_did_you_update.ts new file mode 100644 index 000000000..5488ef82c --- /dev/null +++ b/app/hooks/why_did_you_update.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useRef} from 'react'; + +export function useWhyDidYouUpdate(name: string, props: any) { + // Get a mutable ref object where we can store props ... + // ... for comparison next time this hook runs. + const previousProps = useRef(); + useEffect(() => { + if (previousProps.current) { + // Get all keys from previous and current props + const allKeys = Object.keys({...previousProps.current, ...props}); + + // Use this object to keep track of changed props + const changesObj: any = {}; + + // Iterate through keys + allKeys.forEach((key) => { + // If previous is different from current + if (previousProps.current[key] !== props[key]) { + // Add to changesObj + changesObj[key] = { + from: previousProps.current[key], + to: props[key], + }; + } + }); + + // If changesObj not empty then output to console + if (Object.keys(changesObj).length) { + // eslint-disable-next-line no-console + console.log('[why-did-you-update]', name, changesObj); + } + } + + // Finally update previousProps with current props for next hook call + previousProps.current = props; + }); +} diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index 34d0cb960..cd9e9e6a1 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -10,6 +10,15 @@ import type PostInChannelModel from '@typings/database/models/servers/posts_in_c const {SERVER: {POST, POSTS_IN_CHANNEL}} = MM_TABLES; +export const queryPostById = async (database: Database, postId: string) => { + try { + const userRecord = (await database.collections.get(MM_TABLES.SERVER.POST).find(postId)) as PostModel; + return userRecord; + } catch { + return undefined; + } +}; + export const queryPostsInChannel = (database: Database, channelId: string): Promise => { try { return database.get(POSTS_IN_CHANNEL).query( @@ -48,3 +57,4 @@ export const queryRecentPostsInChannel = async (database: Database, channelId: s return Promise.resolve([] as PostModel[]); } }; + diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index 904cc4b8a..e2ded0d23 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -81,6 +81,15 @@ export const queryCommonSystemValues = async (serverDatabase: Database) => { }; }; +export const queryExpandedLinks = async (serverDatabase: Database) => { + try { + const expandedLinks = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.EXPANDED_LINKS) as SystemModel; + return (expandedLinks?.value || {}) as Record; + } catch { + return {}; + } +}; + export const queryWebSocketLastDisconnected = async (serverDatabase: Database) => { try { const websocketLastDisconnected = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.WEBSOCKET) as SystemModel; diff --git a/app/queries/servers/user.ts b/app/queries/servers/user.ts index 2eb8b466a..3d1c4da2b 100644 --- a/app/queries/servers/user.ts +++ b/app/queries/servers/user.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database} from '@nozbe/watermelondb'; +import {Database, Q} from '@nozbe/watermelondb'; import {MM_TABLES} from '@constants/database'; @@ -37,6 +37,24 @@ export const queryAllUsers = async (database: Database): Promise => } }; +export const queryUsersById = async (database: Database, userIds: string[]): Promise => { + try { + const userRecords = (await database.get(MM_TABLES.SERVER.USER).query(Q.where('id', Q.oneOf(userIds))).fetch()) as UserModel[]; + return userRecords; + } catch { + return Promise.resolve([] as UserModel[]); + } +}; + +export const queryUsersByUsername = async (database: Database, usernames: string[]): Promise => { + try { + const userRecords = (await database.get(MM_TABLES.SERVER.USER).query(Q.where('username', Q.oneOf(usernames))).fetch()) as UserModel[]; + return userRecords; + } catch { + return Promise.resolve([] as UserModel[]); + } +}; + export const prepareUsers = (operator: ServerDataOperator, users: UserProfile[]) => { try { if (users.length) { diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx index c1703b2a8..33af5de38 100644 --- a/app/screens/channel/index.tsx +++ b/app/screens/channel/index.tsx @@ -4,27 +4,23 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import {goToScreen} from '@screens/navigation'; -import React, {useMemo, useState} from 'react'; +import React, {useMemo} from 'react'; import {useIntl} from 'react-intl'; -import {Text, View, ScrollView} from 'react-native'; +import {Text, View} from 'react-native'; import {SafeAreaView} from 'react-native-safe-area-context'; import {logout} from '@actions/remote/session'; -import Markdown from '@components/markdown'; -import JumboEmoji from '@components/jumbo_emoji'; -import ProgressiveImage from '@components/progressive_image'; +import PostList from '@components/post_list'; import ServerVersion from '@components/server_version'; import StatusBar from '@components/status_bar'; import {Screens, Database} from '@constants'; import {useServerUrl} from '@context/server_url'; import {useTheme} from '@context/theme'; import {makeStyleSheetFromTheme} from '@utils/theme'; -import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown'; import ChannelNavBar from './channel_nav_bar'; import FailedChannels from './failed_channels'; import FailedTeams from './failed_teams'; -import md from './md.json'; import type {WithDatabaseArgs} from '@typings/database/database'; import type SystemModel from '@typings/database/models/servers/system'; @@ -44,11 +40,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ flex: 1, }, sectionContainer: { - marginTop: 32, + marginTop: 10, paddingHorizontal: 24, }, sectionTitle: { - fontSize: 24, + fontSize: 16, fontWeight: '600', color: theme.centerChannelColor, }, @@ -93,80 +89,36 @@ const Channel = ({currentChannelId, currentTeamId, time}: ChannelProps) => { return ; } - const textStyle = getMarkdownTextStyles(theme); - const blockStyle = getMarkdownBlockStyles(theme); - return ( <> null} /> - - true} - style={{width: 322.72727272727275, height: 132.96363636363637}} - inViewPort={inViewport} - /> - - - - - {`Loaded in: ${time || 0}ms. Logout from ${serverUrl}`} - - - - - {'Go to About Screen'} - - - + + + + {`Loaded in: ${time || 0}ms. Logout from ${serverUrl}`} + + + + + {'Go to About Screen'} + + ); }, [currentTeamId.value, currentChannelId.value, theme]); - const [inViewport, setInViewport] = useState(false); - - const viewPortTimer = setTimeout(async () => { - setInViewport(true); - clearTimeout(viewPortTimer); - }, 3000); - return ( this should be a quote\nof two lines\n\n1. ~~first~~\n2. *second* with a #hashtag\n3. **third**\n\n`code span of one line`\n\n| Syntax | Description |\n| ----------- | ----------- |\n| Header | Title |\n| Paragraph | Text |\n\n[google link](https://google.com)\n\nhttps://community.mattermost.com\n\nsome html

My Great Heading

\n\nthis is an at mention for @elias, @nonexistent and @nahumhbl. and @channel @user15.6.\n\nthis is a @groupMention.\n\n### Mentioning ~town-square and ~forth or a [direct link to the channel](http://192.168.1.6:8065/apps-team/channels/forth). Tap on them to switch channels.", - "channelMentions": { - "forth": { - "display_name": "forth", - "team_name":"apps-team" - } - }, - "imagesMetadata": { - "http://www.microlife-dns.com/welcome-paper-poster-with-colorful-brush-strokes-vector-21849225.jpeg": { - "width": 1000, - "height": 412, - "format": "jpeg", - "frame_count": 0 - } - }, - "jumbo": ":fire: :mattermost: :thanks:\n:the_horns: :not:" -} \ No newline at end of file diff --git a/app/utils/apps.ts b/app/utils/apps.ts new file mode 100644 index 000000000..f9d65ee85 --- /dev/null +++ b/app/utils/apps.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {AppBindingLocations, AppCallResponseTypes} from '@constants/apps'; + +export function appsEnabled(config: Partial) { // eslint-disable-line @typescript-eslint/no-unused-vars + const enabled = config['FeatureFlagAppsEnabled' as keyof Partial]; + return enabled === 'true'; +} + +export function copyAndFillBindings(binding?: AppBinding): AppBinding | undefined { + if (!binding) { + return undefined; + } + + const copy = JSON.parse(JSON.stringify(binding)); + fillAndTrimBindingsInformation(copy); + return copy; +} + +// fillAndTrimBindingsInformation does: +// - Build the location (e.g. channel_header/binding) +// - Inherit app calls +// - Inherit app ids +// - Trim invalid bindings (do not have an app call or app id at the leaf) +export function fillAndTrimBindingsInformation(binding?: AppBinding) { + if (!binding) { + return; + } + + binding.bindings?.forEach((b) => { + // Propagate id down if not defined + if (!b.app_id) { + b.app_id = binding.app_id; + } + + // Compose location + b.location = binding.location + '/' + b.location; + + // Propagate call down if not defined + if (!b.call) { + b.call = binding.call; + } + + fillAndTrimBindingsInformation(b); + }); + + // Trim branches without app_id + if (!binding.app_id) { + binding.bindings = binding.bindings?.filter((v) => v.app_id); + } + + // Trim branches without calls + if (!binding.call) { + binding.bindings = binding.bindings?.filter((v) => v.call); + } + + // Pull up app_id if needed + if (binding.bindings?.length && !binding.app_id) { + binding.app_id = binding.bindings[0].app_id; + } + + // Pull up call if needed + if (binding.bindings?.length && !binding.call) { + binding.call = binding.bindings[0].call; + } +} + +export function validateBindings(binding?: AppBinding) { + filterInvalidChannelHeaderBindings(binding); + filterInvalidCommands(binding); + filterInvalidPostMenuBindings(binding); + binding?.bindings?.forEach(fillAndTrimBindingsInformation); +} + +// filterInvalidCommands remove commands without a label +function filterInvalidCommands(binding?: AppBinding) { + if (!binding) { + return; + } + + const isValidCommand = (b: AppBinding): boolean => { + return Boolean(b.label); + }; + + const validateCommand = (b: AppBinding) => { + b.bindings = b.bindings?.filter(isValidCommand); + b.bindings?.forEach(validateCommand); + }; + + binding.bindings?.filter((b) => b.location === AppBindingLocations.COMMAND).forEach(validateCommand); +} + +// filterInvalidChannelHeaderBindings remove bindings +// without a label. +function filterInvalidChannelHeaderBindings(binding?: AppBinding) { + if (!binding) { + return; + } + + const isValidChannelHeaderBindings = (b: AppBinding): boolean => { + return Boolean(b.label); + }; + + const validateChannelHeaderBinding = (b: AppBinding) => { + b.bindings = b.bindings?.filter(isValidChannelHeaderBindings); + b.bindings?.forEach(validateChannelHeaderBinding); + }; + + binding.bindings?.filter((b) => b.location === AppBindingLocations.CHANNEL_HEADER_ICON).forEach(validateChannelHeaderBinding); +} + +// filterInvalidPostMenuBindings remove bindings +// without a label. +function filterInvalidPostMenuBindings(binding?: AppBinding) { + if (!binding) { + return; + } + + const isValidPostMenuBinding = (b: AppBinding): boolean => { + return Boolean(b.label); + }; + + const validatePostMenuBinding = (b: AppBinding) => { + b.bindings = b.bindings?.filter(isValidPostMenuBinding); + b.bindings?.forEach(validatePostMenuBinding); + }; + + binding.bindings?.filter((b) => b.location === AppBindingLocations.POST_MENU_ITEM).forEach(validatePostMenuBinding); +} + +export function createCallContext( + appID: string, + location?: string, + channelID?: string, + teamID?: string, + postID?: string, + rootID?: string, +): AppContext { + return { + app_id: appID, + location, + channel_id: channelID, + team_id: teamID, + post_id: postID, + root_id: rootID, + }; +} + +export function createCallRequest( + call: AppCall, + context: AppContext, + defaultExpand: AppExpand = {}, + values?: AppCallValues, + rawCommand?: string, + query?: string, + selectedField?: string, +): AppCallRequest { + return { + ...call, + context, + values, + expand: { + ...defaultExpand, + ...call.expand, + }, + raw_command: rawCommand, + query, + selected_field: selectedField, + }; +} + +export const makeCallErrorResponse = (errMessage: string) => { + return { + type: AppCallResponseTypes.ERROR, + error: errMessage, + }; +}; diff --git a/app/utils/document/index.ts b/app/utils/document/index.ts new file mode 100644 index 000000000..48f3a9df8 --- /dev/null +++ b/app/utils/document/index.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {IntlShape} from 'react-intl'; +import {Alert} from 'react-native'; + +export function alertFailedToOpenDocument(file: FileInfo, intl: IntlShape) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.document_preview.failed_title', + defaultMessage: 'Open Document failed', + }), + intl.formatMessage({ + id: 'mobile.document_preview.failed_description', + defaultMessage: 'An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n', + }, { + fileType: file.extension.toUpperCase(), + }), + [{ + text: intl.formatMessage({ + id: 'mobile.server_upgrade.button', + defaultMessage: 'OK', + }), + }], + ); +} + +export function alertDownloadDocumentDisabled(intl: IntlShape) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.downloader.disabled_title', + defaultMessage: 'Download disabled', + }), + intl.formatMessage({ + id: 'mobile.downloader.disabled_description', + defaultMessage: 'File downloads are disabled on this server. Please contact your System Admin for more details.\n', + }), + [{ + text: intl.formatMessage({ + id: 'mobile.server_upgrade.button', + defaultMessage: 'OK', + }), + }], + ); +} + +export function alertDownloadFailed(intl: IntlShape) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.downloader.failed_title', + defaultMessage: 'Download failed', + }), + intl.formatMessage({ + id: 'mobile.downloader.failed_description', + defaultMessage: 'An error occurred while downloading the file. Please check your internet connection and try again.\n', + }), + [{ + text: intl.formatMessage({ + id: 'mobile.server_upgrade.button', + defaultMessage: 'OK', + }), + }], + ); +} diff --git a/app/utils/emoji/helpers.ts b/app/utils/emoji/helpers.ts index eda23aeaf..b8af85fbf 100644 --- a/app/utils/emoji/helpers.ts +++ b/app/utils/emoji/helpers.ts @@ -48,18 +48,13 @@ export function getEmoticonName(value: string) { return Object.keys(RE_EMOTICON).find((key) => value.match(RE_EMOTICON[key]) !== null); } -export function hasEmojisOnly(message: string, customEmojis: Map) { - if (!message || message.length === 0 || (/^\s{4}/).test(message)) { - return {isEmojiOnly: false, isJumboEmoji: false}; - } - - const chunks = message.trim().replace(/\n/g, ' ').split(' ').filter((m) => m && m.length > 0); - - if (chunks.length === 0) { - return {isEmojiOnly: false, isJumboEmoji: false}; - } - +export function hasJumboEmojiOnly(message: string, customEmojis: string[]) { let emojiCount = 0; + const chunks = message.trim().replace(/\n/g, ' ').split(' ').filter((m) => m && m.length > 0); + if (chunks.length === 0) { + return false; + } + for (const chunk of chunks) { if (doesMatchNamedEmoji(chunk)) { const emojiName = chunk.substring(1, chunk.length - 1); @@ -68,7 +63,7 @@ export function hasEmojisOnly(message: string, customEmojis: Map 0 && emojiCount <= MAX_JUMBO_EMOJIS, - }; + return emojiCount > 0 && emojiCount <= MAX_JUMBO_EMOJIS; } export function doesMatchNamedEmoji(emojiName: string) { diff --git a/app/utils/file/index.ts b/app/utils/file/index.ts index 4f6b4a035..a4b9c6e41 100644 --- a/app/utils/file/index.ts +++ b/app/utils/file/index.ts @@ -5,7 +5,12 @@ import mimeDB from 'mime-db'; import {Platform} from 'react-native'; import {FileSystem} from 'react-native-unimodules'; +import {Files} from '@constants'; import {hashCode} from '@utils/security'; +import {removeProtocol} from '@utils/url'; + +import type FileModel from '@typings/database/models/servers/file'; +import Model from '@nozbe/watermelondb/Model'; const EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/; const CONTENT_DISPOSITION_REGEXP = /inline;filename=".*\.([a-z]+)";/i; @@ -196,8 +201,14 @@ export const getAllowedServerMaxFileSize = (config: ClientConfig) => { return config && config.MaxFileSize ? parseInt(config.MaxFileSize, 10) : DEFAULT_SERVER_MAX_FILE_SIZE; }; -export const isGif = (file?: FileInfo) => { - let mime = file?.mime_type || ''; +export const isGif = (file?: FileInfo | FileModel) => { + if (!file) { + return false; + } + + const fi = file as FileInfo; + const fm = file as FileModel; + let mime = fi.mime_type || fm.mimeType || ''; if (mime && mime.includes(';')) { mime = mime.split(';')[0]; } else if (!mime && file?.name) { @@ -207,10 +218,27 @@ export const isGif = (file?: FileInfo) => { return mime === 'image/gif'; }; -export const isImage = (file?: FileInfo) => (file?.has_preview_image || isGif(file) || file?.mime_type?.startsWith('image/')); +export const isImage = (file?: FileInfo | FileModel) => { + if (!file) { + return false; + } + const fi = file as FileInfo; + const fm = file as FileModel; -export const isDocument = (file?: FileInfo) => { - let mime = file?.mime_type || ''; + const hasPreview = Boolean(fi.mini_preview || fm.imageThumbnail); + const mimeType = fi.mime_type || fm.mimeType || ''; + + return (hasPreview || isGif(file) || mimeType.startsWith('image/')); +}; + +export const isDocument = (file?: FileInfo | FileModel) => { + if (!file) { + return false; + } + + const fi = file as FileInfo; + const fm = file as FileModel; + let mime = fi.mime_type || fm.mimeType || ''; if (mime && mime.includes(';')) { mime = mime.split(';')[0]; } else if (!mime && file?.name) { @@ -220,8 +248,14 @@ export const isDocument = (file?: FileInfo) => { return SUPPORTED_DOCS_FORMAT!.includes(mime); }; -export const isVideo = (file?: FileInfo) => { - let mime = file?.mime_type || ''; +export const isVideo = (file?: FileInfo | FileModel) => { + if (!file) { + return false; + } + + const fi = file as FileInfo; + const fm = file as FileModel; + let mime = fi.mime_type || fm.mimeType || ''; if (mime && mime.includes(';')) { mime = mime.split(';')[0]; } else if (!mime && file?.name) { @@ -230,3 +264,81 @@ export const isVideo = (file?: FileInfo) => { return SUPPORTED_VIDEO_FORMAT!.includes(mime); }; + +export function getFormattedFileSize(file: FileInfo): string { + const bytes = file.size; + const fileSizes = [ + ['TB', 1024 * 1024 * 1024 * 1024], + ['GB', 1024 * 1024 * 1024], + ['MB', 1024 * 1024], + ['KB', 1024], + ]; + const size = fileSizes.find((unitAndMinBytes) => { + const minBytes = unitAndMinBytes[1]; + return bytes > minBytes; + }); + + if (size) { + return `${Math.floor(bytes / (size[1] as number))} ${size[0]}`; + } + + return `${bytes} B`; +} + +export function getFileType(file: FileInfo): string { + if (!file || !file.extension) { + return 'other'; + } + + const fileExt = file.extension.toLowerCase(); + const fileTypes = [ + 'image', + 'code', + 'pdf', + 'video', + 'audio', + 'spreadsheet', + 'text', + 'word', + 'presentation', + 'patch', + 'zip', + ]; + return fileTypes.find((fileType) => { + const constForFileTypeExtList = `${fileType}_types`.toUpperCase(); + const fileTypeExts = Files[constForFileTypeExtList]; + return fileTypeExts.indexOf(fileExt) > -1; + }) || 'other'; +} + +export function getLocalFilePathFromFile(dir: string, serverUrl: string, file: FileInfo | FileModel) { + if (dir && serverUrl) { + const server = removeProtocol(serverUrl); + if (file?.name) { + let extension: string | undefined = file.extension; + let filename = file.name; + + if (!extension) { + const mimeType = (file instanceof Model) ? file.mimeType : file.mime_type; + extension = getExtensionFromMime(mimeType); + } + + if (extension && filename.includes(`.${extension}`)) { + filename = filename.replace(`.${extension}`, ''); + } else { + const fileParts = file.name.split('.'); + + if (fileParts.length > 1) { + extension = fileParts.pop(); + filename = fileParts.join('.'); + } + } + + return `${dir}/${server}/${filename}-${hashCode(file.id!)}.${extension}`; + } else if (file?.id && file?.extension) { + return `${dir}/${server}/${file.id}.${file.extension}`; + } + } + + return undefined; +} diff --git a/app/utils/gallery/index.ts b/app/utils/gallery/index.ts index 62fc7b777..afb8eb172 100644 --- a/app/utils/gallery/index.ts +++ b/app/utils/gallery/index.ts @@ -9,7 +9,9 @@ import {goToScreen} from '@screens/navigation'; import {isImage, lookupMimeType} from '@utils/file'; import {generateId} from '@utils/general'; -export function openGalleryAtIndex(index: number, files: FileInfo[]) { +import type FileModel from '@typings/database/models/servers/file'; + +export function openGalleryAtIndex(index: number, files: FileInfo[] | FileModel[]) { Keyboard.dismiss(); requestAnimationFrame(() => { const screen = 'Gallery'; diff --git a/app/utils/message_attachment_colors.ts b/app/utils/message_attachment_colors.ts new file mode 100644 index 000000000..391c34475 --- /dev/null +++ b/app/utils/message_attachment_colors.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export function getStatusColors(theme: Theme) { + return { + good: '#00c100', + warning: '#dede01', + danger: theme.errorTextColor, + default: theme.centerChannelColor, + primary: theme.buttonBg, + success: theme.onlineIndicator, + } as Dictionary; +} diff --git a/app/utils/opengraph.js b/app/utils/opengraph.js new file mode 100644 index 000000000..589056622 --- /dev/null +++ b/app/utils/opengraph.js @@ -0,0 +1,23 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export function getDistanceBW2Points(point1, point2, xAttr = 'x', yAttr = 'y') { + return Math.sqrt(Math.pow(point1[xAttr] - point2[xAttr], 2) + Math.pow(point1[yAttr] - point2[yAttr], 2)); +} + +/** + * Funtion to return nearest point of given pivot point. + * It return two points one nearest and other nearest but having both coorditanes smaller than the given point's coordinates. + */ +export function getNearestPoint(pivotPoint, points, xAttr = 'x', yAttr = 'y') { + let nearestPoint = {}; + for (const point of points) { + if (typeof nearestPoint[xAttr] === 'undefined' || typeof nearestPoint[yAttr] === 'undefined') { + nearestPoint = point; + } else if (getDistanceBW2Points(point, pivotPoint, xAttr, yAttr) < getDistanceBW2Points(nearestPoint, pivotPoint, xAttr, yAttr)) { + // Check for bestImage + nearestPoint = point; + } + } + return nearestPoint; +} diff --git a/app/utils/opengraph.ts b/app/utils/opengraph.ts new file mode 100644 index 000000000..97f8a7bbd --- /dev/null +++ b/app/utils/opengraph.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export type BestImage = { + secure_url?: string; + url?: string; +}; + +export function getDistanceBW2Points(point1: Record, point2: Record, xAttr = 'x', yAttr = 'y') { + return Math.sqrt(Math.pow(point1[xAttr] - point2[xAttr], 2) + Math.pow(point1[yAttr] - point2[yAttr], 2)); +} + +/** + * Funtion to return nearest point of given pivot point. + * It return two points one nearest and other nearest but having both coorditanes smaller than the given point's coordinates. + */ +export function getNearestPoint(pivotPoint: {height: number; width: number}, points: never[], xAttr = 'x', yAttr = 'y') { + let nearestPoint: Record = {}; + for (const point of points) { + if (typeof nearestPoint[xAttr] === 'undefined' || typeof nearestPoint[yAttr] === 'undefined') { + nearestPoint = point; + } else if (getDistanceBW2Points(point, pivotPoint, xAttr, yAttr) < getDistanceBW2Points(nearestPoint, pivotPoint, xAttr, yAttr)) { + // Check for bestImage + nearestPoint = point; + } + } + return nearestPoint as BestImage; +} diff --git a/app/utils/post/index.ts b/app/utils/post/index.ts new file mode 100644 index 000000000..c073fcee4 --- /dev/null +++ b/app/utils/post/index.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Post} from '@constants'; +import {DEFAULT_LOCALE} from '@i18n'; +import {displayUsername} from '@utils/user'; + +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; +import type GroupModel from '@typings/database/models/servers/group'; +import type {UserMentionKey} from '@typings/global/markdown'; + +export function areConsecutivePosts(post: PostModel, previousPost: PostModel) { + let consecutive = false; + + if (post && previousPost) { + const postFromWebhook = Boolean(post?.props?.from_webhook); // eslint-disable-line camelcase + const prevPostFromWebhook = Boolean(previousPost?.props?.from_webhook); // eslint-disable-line camelcase + const isFromSameUser = previousPost.userId === post.userId; + const isNotSystemMessage = !isSystemMessage(post) && !isSystemMessage(previousPost); + const isInTimeframe = (post.createAt - previousPost.createAt) <= Post.POST_COLLAPSE_TIMEOUT; + const isSameThread = (previousPost.rootId === post.rootId || previousPost.id === post.rootId); + + // Were the last post and this post made by the same user within some time? + consecutive = previousPost && (isFromSameUser || isInTimeframe) && !postFromWebhook && + !prevPostFromWebhook && isNotSystemMessage && isSameThread; + } + return consecutive; +} + +export function isFromWebhook(post: PostModel): boolean { + return post.props && post.props.from_webhook === 'true'; +} + +export function isEdited(post: PostModel): boolean { + return post.editAt > 0; +} + +export function isPostEphemeral(post: PostModel): boolean { + return post.type === Post.POST_TYPES.EPHEMERAL || post.type === Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL || post.deleteAt > 0; +} + +export function isPostPendingOrFailed(post: PostModel): boolean { + return post.pendingPostId === post.id || post.props.failed; +} + +export function isSystemMessage(post: PostModel): boolean { + return Boolean(post.type && post.type?.startsWith(Post.POST_TYPES.SYSTEM_MESSAGE_PREFIX)); +} + +export function fromAutoResponder(post: PostModel): boolean { + return Boolean(post.type && (post.type === Post.POST_TYPES.SYSTEM_AUTO_RESPONDER)); +} + +export function postUserDisplayName(post: PostModel, author?: UserModel, teammateNameDisplay?: string, enablePostUsernameOverride = false) { + if (isFromWebhook(post) && post.props?.override_username && enablePostUsernameOverride) { + return post.props.override_username; + } + + return displayUsername(author, author?.locale || DEFAULT_LOCALE, teammateNameDisplay, true); +} + +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}); + } + + if (groups?.length) { + for (const group of groups) { + keys.push({key: `@${group.name}`}); + } + } + + return keys; +}; diff --git a/app/utils/post_list/index.ts b/app/utils/post_list/index.ts new file mode 100644 index 000000000..2305d7791 --- /dev/null +++ b/app/utils/post_list/index.ts @@ -0,0 +1,375 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment-timezone'; + +import {Post} from '@constants'; +import {getTimezone} from '@utils/user'; + +import type PostModel from '@typings/database/models/servers/post'; + +const joinLeavePostTypes = [ + Post.POST_TYPES.JOIN_LEAVE, + Post.POST_TYPES.JOIN_CHANNEL, + Post.POST_TYPES.LEAVE_CHANNEL, + Post.POST_TYPES.ADD_REMOVE, + Post.POST_TYPES.ADD_TO_CHANNEL, + Post.POST_TYPES.REMOVE_FROM_CHANNEL, + Post.POST_TYPES.JOIN_TEAM, + Post.POST_TYPES.LEAVE_TEAM, + Post.POST_TYPES.ADD_TO_TEAM, + Post.POST_TYPES.REMOVE_FROM_TEAM, + Post.POST_TYPES.COMBINED_USER_ACTIVITY, +]; + +const postTypePriority = { + [Post.POST_TYPES.JOIN_TEAM]: 0, + [Post.POST_TYPES.ADD_TO_TEAM]: 1, + [Post.POST_TYPES.LEAVE_TEAM]: 2, + [Post.POST_TYPES.REMOVE_FROM_TEAM]: 3, + [Post.POST_TYPES.JOIN_CHANNEL]: 4, + [Post.POST_TYPES.ADD_TO_CHANNEL]: 5, + [Post.POST_TYPES.LEAVE_CHANNEL]: 6, + [Post.POST_TYPES.REMOVE_FROM_CHANNEL]: 7, + [Post.POST_TYPES.PURPOSE_CHANGE]: 8, + [Post.POST_TYPES.HEADER_CHANGE]: 9, + [Post.POST_TYPES.JOIN_LEAVE]: 10, + [Post.POST_TYPES.DISPLAYNAME_CHANGE]: 11, + [Post.POST_TYPES.CONVERT_CHANNEL]: 12, + [Post.POST_TYPES.CHANNEL_DELETED]: 13, + [Post.POST_TYPES.CHANNEL_UNARCHIVED]: 14, + [Post.POST_TYPES.ADD_REMOVE]: 15, + [Post.POST_TYPES.EPHEMERAL]: 16, +}; + +export const COMBINED_USER_ACTIVITY = 'user-activity-'; +export const DATE_LINE = 'date-'; +export const START_OF_NEW_MESSAGES = 'start-of-new-messages'; +export const MAX_COMBINED_SYSTEM_POSTS = 100; + +function combineUserActivityPosts(orderedPosts: Array) { + let lastPostIsUserActivity = false; + let combinedCount = 0; + const out: Array = []; + let changed = false; + + for (let i = 0; i < orderedPosts.length; i++) { + const post = orderedPosts[i]; + + if (typeof post === 'string') { + if (post === START_OF_NEW_MESSAGES || post.startsWith(DATE_LINE)) { + // Not a post, so it won't be combined + out.push(post); + + lastPostIsUserActivity = false; + combinedCount = 0; + + continue; + } + } else { + const postIsUserActivity = Post.USER_ACTIVITY_POST_TYPES.includes(post.type); + if (postIsUserActivity && lastPostIsUserActivity && combinedCount < MAX_COMBINED_SYSTEM_POSTS) { + // Add the ID to the previous combined post + out[out.length - 1] += '_' + post.id; + combinedCount += 1; + changed = true; + } else if (postIsUserActivity) { + // Start a new combined post, even if the "combined" post is only a single post + out.push(COMBINED_USER_ACTIVITY + post.id); + combinedCount = 1; + changed = true; + } else { + out.push(post); + combinedCount = 0; + } + + lastPostIsUserActivity = postIsUserActivity; + } + } + + if (!changed) { + return orderedPosts; + } + + return out; +} + +function comparePostTypes(a: typeof postTypePriority, b: typeof postTypePriority) { + return postTypePriority[a.postType] - postTypePriority[b.postType]; +} + +function extractUserActivityData(userActivities: any) { + const messageData: any[] = []; + const allUserIds: string[] = []; + const allUsernames: string[] = []; + Object.entries(userActivities).forEach(([postType, values]: [string, any]) => { + if ( + postType === Post.POST_TYPES.ADD_TO_TEAM || + postType === Post.POST_TYPES.ADD_TO_CHANNEL || + postType === Post.POST_TYPES.REMOVE_FROM_CHANNEL + ) { + Object.keys(values).map((key) => [key, values[key]]).forEach(([actorId, users]) => { + if (Array.isArray(users)) { + throw new Error('Invalid Post activity data'); + } + const {ids, usernames} = users; + messageData.push({postType, userIds: [...usernames, ...ids], actorId}); + if (ids.length > 0) { + allUserIds.push(...ids); + } + + if (usernames.length > 0) { + allUsernames.push(...usernames); + } + allUserIds.push(actorId); + }); + } else { + if (!Array.isArray(values)) { + throw new Error('Invalid Post activity data'); + } + messageData.push({postType, userIds: values}); + allUserIds.push(...values); + } + }); + + messageData.sort(comparePostTypes); + + function reduceUsers(acc: string[], curr: string) { + if (!acc.includes(curr)) { + acc.push(curr); + } + return acc; + } + + return { + allUserIds: allUserIds.reduce(reduceUsers, []), + allUsernames: allUsernames.reduce(reduceUsers, []), + messageData, + }; +} + +function isJoinLeavePostForUsername(post: PostModel, currentUsername: string): boolean { + if (!post.props || !currentUsername) { + return false; + } + + if (post.props.user_activity_posts) { + for (const childPost of post.props.user_activity_posts as PostModel[]) { + if (isJoinLeavePostForUsername(childPost, currentUsername)) { + // If any of the contained posts are for this user, the client will + // need to figure out how to render the post + return true; + } + } + } + + return post.props.username === currentUsername || + post.props.addedUsername === currentUsername || + post.props.removedUsername === currentUsername; +} + +// are we going to do something with selectedPostId as in v1? +function selectOrderedPosts( + posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUsername: string, showJoinLeave: boolean, + timezoneEnabled: boolean, currentTimezone: UserTimezone | null, isThreadScreen = false) { + if (posts.length === 0) { + return []; + } + + const out: Array = []; + let lastDate; + let addedNewMessagesIndicator = false; + + // Iterating through the posts from oldest to newest + for (let i = posts.length - 1; i >= 0; i--) { + const post = posts[i]; + + if ( + !post || + (post.type === Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL && !isThreadScreen) + ) { + continue; + } + + // Filter out join/leave messages if necessary + if (shouldFilterJoinLeavePost(post, showJoinLeave, currentUsername)) { + continue; + } + + // Push on a date header if the last post was on a different day than the current one + const postDate = new Date(post.createAt); + if (timezoneEnabled) { + const currentOffset = postDate.getTimezoneOffset() * 60 * 1000; + const timezone = getTimezone(currentTimezone); + if (timezone) { + const zone = moment.tz.zone(timezone); + if (zone) { + const timezoneOffset = zone.utcOffset(post.createAt) * 60 * 1000; + postDate.setTime(post.createAt + (currentOffset - timezoneOffset)); + } + } + } + + if (!lastDate || lastDate.toDateString() !== postDate.toDateString()) { + out.push(DATE_LINE + postDate.getTime()); + + lastDate = postDate; + } + + if ( + lastViewedAt && + post.createAt > lastViewedAt && + !addedNewMessagesIndicator && + indicateNewMessages + ) { + out.push(START_OF_NEW_MESSAGES); + addedNewMessagesIndicator = true; + } + + out.push(post); + } + + // Flip it back to newest to oldest + return out.reverse(); +} + +function combineUserActivitySystemPost(systemPosts: PostModel[]) { + const userActivities = systemPosts.reduce((acc: any, post: PostModel) => { + const postType = post.type; + let userActivityProps = acc; + const combinedPostType = userActivityProps[postType as string]; + + if ( + postType === Post.POST_TYPES.ADD_TO_TEAM || + postType === Post.POST_TYPES.ADD_TO_CHANNEL || + postType === Post.POST_TYPES.REMOVE_FROM_CHANNEL + ) { + const userId = post.props.addedUserId || post.props.removedUserId; + const username = post.props.addedUsername || post.props.removedUsername; + if (combinedPostType) { + if (Array.isArray(combinedPostType[post.userId])) { + throw new Error('Invalid Post activity data'); + } + const users = combinedPostType[post.userId] || {ids: [], usernames: []}; + if (userId) { + if (!users.ids.includes(userId)) { + users.ids.push(userId); + } + } else if (username && !users.usernames.includes(username)) { + users.usernames.push(username); + } + combinedPostType[post.userId] = users; + } else { + const users = { + ids: [] as string[], + usernames: [] as string[], + }; + + if (userId) { + users.ids.push(userId); + } else if (username) { + users.usernames.push(username); + } + userActivityProps[postType] = { + [post.userId]: users, + }; + } + } else { + const propsUserId = post.userId; + + if (combinedPostType) { + if (!Array.isArray(combinedPostType)) { + throw new Error('Invalid Post activity data'); + } + if (!combinedPostType.includes(propsUserId)) { + userActivityProps[postType] = [...combinedPostType, propsUserId]; + } + } else { + userActivityProps = {...userActivityProps, [postType]: [propsUserId]}; + } + } + + return userActivityProps; + }, {}); + + return extractUserActivityData(userActivities); +} + +export function generateCombinedPost(combinedId: string, systemPosts: PostModel[]): Post { + // All posts should be in the same chann + const channelId = systemPosts[0].channelId; + + // Assume that the last post is the oldest one + const createAt = systemPosts[systemPosts.length - 1].createAt; + + const messages = systemPosts.map((post) => post.message); + const message = messages.join('\n'); + + return { + id: combinedId, + root_id: '', + original_id: '', + channel_id: channelId, + create_at: createAt, + delete_at: 0, + edit_at: 0, + update_at: 0, + is_pinned: false, + message, + hashtags: '', + pending_post_id: '', + reply_count: 0, + props: { + messages, + user_activity: combineUserActivitySystemPost(systemPosts), + user_activity_posts: systemPosts, + system_post_ids: systemPosts.map((post) => post.id), + }, + type: Post.POST_TYPES.COMBINED_USER_ACTIVITY as PostType, + user_id: '', + metadata: {}, + participants: null, + }; +} + +export function getDateForDateLine(item: string) { + return parseInt(item.substring(DATE_LINE.length), 10); +} + +export function getPostIdsForCombinedUserActivityPost(item: string) { + return item.substring(COMBINED_USER_ACTIVITY.length).split('_'); +} + +export function isCombinedUserActivityPost(item: string) { + return (/^user-activity-(?:[^_]+_)*[^_]+$/).test(item); +} + +export function isDateLine(item: string) { + return Boolean(item?.startsWith(DATE_LINE)); +} + +export function isStartOfNewMessages(item: string) { + return item === START_OF_NEW_MESSAGES; +} + +export function preparePostList( + posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUsername: string, showJoinLeave: boolean, + timezoneEnabled: boolean, currentTimezone: UserTimezone | null, isThreadScreen = false) { + const orderedPosts = selectOrderedPosts(posts, lastViewedAt, indicateNewMessages, currentUsername, showJoinLeave, timezoneEnabled, currentTimezone, isThreadScreen); + return combineUserActivityPosts(orderedPosts); +} + +// Returns true if a post should be hidden when the user has Show Join/Leave Messages disabled +export function shouldFilterJoinLeavePost(post: PostModel, showJoinLeave: boolean, currentUsername: string): boolean { + if (showJoinLeave) { + return false; + } + + // Don't filter out non-join/leave messages + if (joinLeavePostTypes.indexOf(post.type) === -1) { + return false; + } + + // Don't filter out join/leave messages about the current user + return !isJoinLeavePostForUsername(post, currentUsername); +} diff --git a/app/utils/role/index.ts b/app/utils/role/index.ts index b3beff765..5db2ffa62 100644 --- a/app/utils/role/index.ts +++ b/app/utils/role/index.ts @@ -1,7 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Q} from '@nozbe/watermelondb'; + +import {General, Permissions} from '@constants'; +import {MM_TABLES} from '@constants/database'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type MyTeamModel from '@typings/database/models/servers/my_team'; +import type PostModel from '@typings/database/models/servers/post'; import type RoleModel from '@typings/database/models/servers/role'; +import type TeamModel from '@typings/database/models/servers/team'; +import type UserModel from '@typings/database/models/servers/user'; export function hasPermission(roles: RoleModel[] | Role[], permission: string, defaultValue: boolean) { const permissions = new Set(); @@ -12,3 +23,59 @@ export function hasPermission(roles: RoleModel[] | Role[], permission: string, d const exists = permissions.has(permission); return defaultValue === true || exists; } + +export async function hasPermissionForPost(post: PostModel, user: UserModel, permission: string, defaultValue: boolean) { + const rolesArray = [...user.roles.split(' ')]; + const channel = await post.channel.fetch() as ChannelModel | undefined; + if (channel) { + const myChannel = await channel.membership.fetch() as MyChannelModel | undefined; + if (myChannel) { + rolesArray.push(...myChannel.roles.split(' ')); + } + + const team = await channel.team.fetch() as TeamModel | undefined; + if (team) { + const myTeam = await team.myTeam.fetch() as MyTeamModel | undefined; + if (myTeam) { + rolesArray.push(...myTeam.roles.split(' ')); + } + } + } + + if (rolesArray.length) { + const roles = await post.collections.get(MM_TABLES.SERVER.ROLE).query(Q.where('name', Q.oneOf(rolesArray))).fetch() as RoleModel[]; + return hasPermission(roles, permission, defaultValue); + } + + return defaultValue; +} + +export async function canManageChannelMembers(post: PostModel, user: UserModel) { + const rolesArray = [...user.roles.split(' ')]; + const channel = await post.channel.fetch() as ChannelModel | undefined; + + if (!channel || channel.deleteAt !== 0 || [General.DM_CHANNEL, General.GM_CHANNEL].includes(channel.type) || channel.name === General.DEFAULT_CHANNEL) { + return false; + } + + const myChannel = await channel.membership.fetch() as MyChannelModel | undefined; + if (myChannel) { + rolesArray.push(...myChannel.roles.split(' ')); + } + + const team = await channel.team.fetch() as TeamModel | undefined; + if (team) { + const myTeam = await team.myTeam.fetch() as MyTeamModel | undefined; + if (myTeam) { + rolesArray.push(...myTeam.roles.split(' ')); + } + } + + if (rolesArray.length) { + const roles = await post.collections.get(MM_TABLES.SERVER.ROLE).query(Q.where('name', Q.oneOf(rolesArray))).fetch() as RoleModel[]; + const permission = channel.type === General.OPEN_CHANNEL ? Permissions.MANAGE_PUBLIC_CHANNEL_MEMBERS : Permissions.MANAGE_PRIVATE_CHANNEL_MEMBERS; + return hasPermission(roles, permission, true); + } + + return true; +} diff --git a/app/utils/supported_server/supported_server.ts b/app/utils/supported_server/index.ts similarity index 56% rename from app/utils/supported_server/supported_server.ts rename to app/utils/supported_server/index.ts index 030fbdff5..c244db042 100644 --- a/app/utils/supported_server/supported_server.ts +++ b/app/utils/supported_server/index.ts @@ -1,49 +1,41 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {IntlShape} from 'react-intl'; import {Alert, AlertButton} from 'react-native'; import ViewTypes from '@constants/view'; import {tryOpenURL} from '@utils/url'; -export interface FormatObjectType { - id: string; - defaultMessage: string; -} - -export interface FormatMessageType { - (obj: FormatObjectType, values?: any): string; -} - -export function unsupportedServer(isSystemAdmin: boolean, formatMessage: FormatMessageType) { +export function unsupportedServer(isSystemAdmin: boolean, intl: IntlShape) { if (isSystemAdmin) { - return unsupportedServerAdminAlert(formatMessage); + return unsupportedServerAdminAlert(intl); } - return unsupportedServerAlert(formatMessage); + return unsupportedServerAlert(intl); } -function unsupportedServerAdminAlert(formatMessage: FormatMessageType) { - const title = formatMessage({id: 'mobile.server_upgrade.title', defaultMessage: 'Server upgrade required'}); +function unsupportedServerAdminAlert(intl: IntlShape) { + const title = intl.formatMessage({id: 'mobile.server_upgrade.title', defaultMessage: 'Server upgrade required'}); - const message = formatMessage({ + const message = intl.formatMessage({ id: 'mobile.server_upgrade.alert_description', defaultMessage: 'This server version is unsupported and users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {serverVersion} or later is required.', }, {serverVersion: ViewTypes.RequiredServer.FULL_VERSION}); const cancel: AlertButton = { - text: formatMessage({id: 'mobile.server_upgrade.dismiss', defaultMessage: 'Dismiss'}), + text: intl.formatMessage({id: 'mobile.server_upgrade.dismiss', defaultMessage: 'Dismiss'}), style: 'default', }; const learnMore: AlertButton = { - text: formatMessage({id: 'mobile.server_upgrade.learn_more', defaultMessage: 'Learn More'}), + text: intl.formatMessage({id: 'mobile.server_upgrade.learn_more', defaultMessage: 'Learn More'}), style: 'cancel', onPress: () => { const url = 'https://docs.mattermost.com/administration/release-lifecycle.html'; const onError = () => { Alert.alert( - formatMessage({id: 'mobile.link.error.title', defaultMessage: 'Error'}), - formatMessage({id: 'mobile.link.error.text', defaultMessage: 'Unable to open the link.'}), + intl.formatMessage({id: 'mobile.link.error.title', defaultMessage: 'Error'}), + intl.formatMessage({id: 'mobile.link.error.text', defaultMessage: 'Unable to open the link.'}), ); }; @@ -56,16 +48,16 @@ function unsupportedServerAdminAlert(formatMessage: FormatMessageType) { Alert.alert(title, message, buttons, options); } -function unsupportedServerAlert(formatMessage: FormatMessageType) { - const title = formatMessage({id: 'mobile.unsupported_server.title', defaultMessage: 'Unsupported server version'}); +function unsupportedServerAlert(intl: IntlShape) { + const title = intl.formatMessage({id: 'mobile.unsupported_server.title', defaultMessage: 'Unsupported server version'}); - const message = formatMessage({ + const message = intl.formatMessage({ id: 'mobile.unsupported_server.message', defaultMessage: 'Attachments, link previews, reactions and embed data may not be displayed correctly. If this issue persists contact your System Administrator to upgrade your Mattermost server.', }); const okButton: AlertButton = { - text: formatMessage({id: 'mobile.unsupported_server.ok', defaultMessage: 'OK'}), + text: intl.formatMessage({id: 'mobile.unsupported_server.ok', defaultMessage: 'OK'}), style: 'default', }; diff --git a/app/utils/supported_server/supported_server.test.ts b/app/utils/supported_server/supported_server.test.ts index 8d7e84b58..ffbaf1348 100644 --- a/app/utils/supported_server/supported_server.test.ts +++ b/app/utils/supported_server/supported_server.test.ts @@ -3,20 +3,22 @@ import {Alert} from 'react-native'; -import {unsupportedServer} from './supported_server'; +import {getIntlShape} from '@test/intl-test-helper'; + +import {unsupportedServer} from '.'; describe('Unsupported Server Alert', () => { - const formatMessage = jest.fn(); + const intl = getIntlShape(); it('should show the alert for sysadmin', () => { const alert = jest.spyOn(Alert, 'alert'); - unsupportedServer(true, formatMessage); + unsupportedServer(true, intl); expect(alert?.mock?.calls?.[0]?.[2]?.length).toBe(2); }); it('should show the alert for team admin / user', () => { const alert = jest.spyOn(Alert, 'alert'); - unsupportedServer(false, formatMessage); + unsupportedServer(false, intl); expect(alert?.mock?.calls?.[0]?.[2]?.length).toBe(1); }); }); diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index d8486d9bf..7064ca63c 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -1,12 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import moment from 'moment-timezone'; import {General, Preferences} from '@constants'; import {UserModel} from '@database/models/server'; import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n'; 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'; export function displayUsername(user?: UserProfile | UserModel, locale?: string, teammateDisplayNameSetting?: string, useFallbackUsername = true) { let name = useFallbackUsername ? getLocalizedMessage(locale || DEFAULT_LOCALE, t('channel_loader.someone'), 'Someone') : ''; @@ -140,3 +142,50 @@ export const getUserMentionKeys = (user: UserModel, groups: GroupModel[], userGr return keys; }; + +export const getUserTimezone = (user: UserModel) => { + return getTimezone(user.timezone); +}; + +export const getTimezone = (timezone: UserTimezone | null) => { + if (!timezone) { + return ''; + } + + const {useAutomaticTimezone} = timezone; + let useAutomatic = useAutomaticTimezone; + if (typeof useAutomaticTimezone === 'string') { + useAutomatic = useAutomaticTimezone === 'true'; + } + + if (useAutomatic) { + return timezone.automaticTimezone; + } + + return timezone.manualTimezone; +}; + +export const getUserCustomStatus = (user: UserModel) => { + if (user.props?.customStatus) { + return user.props.customStatus as UserCustomStatus; + } + + return undefined; +}; + +export function isCustomStatusExpired(user: UserModel) { + const customStatus = getUserCustomStatus(user); + + if (!customStatus) { + return true; + } + + if (customStatus.duration === CustomStatusDuration.DONT_CLEAR || !customStatus.duration) { + return false; + } + + const expiryTime = moment(customStatus.expires_at); + const timezone = getUserTimezone(user); + const currentTime = timezone ? moment.tz(timezone) : moment(); + return currentTime.isSameOrAfter(expiryTime); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 90ccfaeb3..d76002e48 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -10,11 +10,56 @@ "about.teamEditiont0": "Team Edition", "about.teamEditiont1": "Enterprise Edition", "about.title": "About {appTitle}", + "api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.", + "api.channel.add_member.added": "{addedUsername} added to the channel by {username}.", + "api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.", + "apps.error.responses.form.no_form": "Response type is `form`, but no form was included in the response.", + "apps.error.responses.navigate.no_submit": "Response type is `navigate`, but the call was not a submission.", + "apps.error.responses.navigate.no_url": "Response type is `navigate`, but no url was included in response.", + "apps.error.responses.unexpected_error": "Received an unexpected error.", + "apps.error.responses.unknown_type": "App response type not supported. Response type: {type}.", + "apps.error.unknown": "Unknown error occurred.", "channel_header.directchannel.you": "{displayname} (you)", "channel_loader.someone": "Someone", + "channel_modal.optional": "(optional)", "channel.channelHasGuests": "This channel has guests", "channel.hasGuests": "This group message has guests", "channel.isGuest": "This person is a guest", + "combined_system_message.added_to_channel.many_expanded": "{users} and {lastUser} were **added to the channel** by {actor}.", + "combined_system_message.added_to_channel.one": "{firstUser} **added to the channel** by {actor}.", + "combined_system_message.added_to_channel.one_you": "You were **added to the channel** by {actor}.", + "combined_system_message.added_to_channel.two": "{firstUser} and {secondUser} **added to the channel** by {actor}.", + "combined_system_message.added_to_team.many_expanded": "{users} and {lastUser} were **added to the team** by {actor}.", + "combined_system_message.added_to_team.one": "{firstUser} **added to the team** by {actor}.", + "combined_system_message.added_to_team.one_you": "You were **added to the team** by {actor}.", + "combined_system_message.added_to_team.two": "{firstUser} and {secondUser} **added to the team** by {actor}.", + "combined_system_message.joined_channel.many_expanded": "{users} and {lastUser} **joined the channel**.", + "combined_system_message.joined_channel.one": "{firstUser} **joined the channel**.", + "combined_system_message.joined_channel.one_you": "You **joined the channel**.", + "combined_system_message.joined_channel.two": "{firstUser} and {secondUser} **joined the channel**.", + "combined_system_message.joined_team.many_expanded": "{users} and {lastUser} **joined the team**.", + "combined_system_message.joined_team.one": "{firstUser} **joined the team**.", + "combined_system_message.joined_team.one_you": "You **joined the team**.", + "combined_system_message.joined_team.two": "{firstUser} and {secondUser} **joined the team**.", + "combined_system_message.left_channel.many_expanded": "{users} and {lastUser} **left the channel**.", + "combined_system_message.left_channel.one": "{firstUser} **left the channel**.", + "combined_system_message.left_channel.one_you": "You **left the channel**.", + "combined_system_message.left_channel.two": "{firstUser} and {secondUser} **left the channel**.", + "combined_system_message.left_team.many_expanded": "{users} and {lastUser} **left the team**.", + "combined_system_message.left_team.one": "{firstUser} **left the team**.", + "combined_system_message.left_team.one_you": "You **left the team**.", + "combined_system_message.left_team.two": "{firstUser} and {secondUser} **left the team**.", + "combined_system_message.removed_from_channel.many_expanded": "{users} and {lastUser} were **removed from the channel**.", + "combined_system_message.removed_from_channel.one": "{firstUser} was **removed from the channel**.", + "combined_system_message.removed_from_channel.one_you": "You were **removed from the channel**.", + "combined_system_message.removed_from_channel.two": "{firstUser} and {secondUser} were **removed from the channel**.", + "combined_system_message.removed_from_team.many_expanded": "{users} and {lastUser} were **removed from the team**.", + "combined_system_message.removed_from_team.one": "{firstUser} was **removed from the team**.", + "combined_system_message.removed_from_team.one_you": "You were **removed from the team**.", + "combined_system_message.removed_from_team.two": "{firstUser} and {secondUser} were **removed from the team**.", + "combined_system_message.you": "You", + "date_separator.today": "Today", + "date_separator.yesterday": "Yesterday", "emoji_picker.activities": "Activities", "emoji_picker.animals-nature": "Animals & Nature", "emoji_picker.custom": "Custom", @@ -37,6 +82,16 @@ "failed_action.fetch_teams": "An error ocurred while loading the teams of this server", "failed_action.something_wrong": "Something went wrong", "failed_action.try_again": "Try again", + "last_users_message.added_to_channel.type": "were **added to the channel** by {actor}.", + "last_users_message.added_to_team.type": "were **added to the team** by {actor}.", + "last_users_message.first": "{firstUser} and ", + "last_users_message.joined_channel.type": "**joined the channel**.", + "last_users_message.joined_team.type": "**joined the team**.", + "last_users_message.left_channel.type": "**left the channel**.", + "last_users_message.left_team.type": "**left the team**.", + "last_users_message.others": "{numOthers} others ", + "last_users_message.removed_from_channel.type": "were **removed from the channel**.", + "last_users_message.removed_from_team.type": "were **removed from the team**.", "login_mfa.enterToken": "To complete the sign in process, please enter a token from your smartphone's authenticator", "login_mfa.token": "MFA Token", "login_mfa.tokenReq": "Please enter an MFA token", @@ -65,11 +120,18 @@ "mobile.about.powered_by": "{site} is powered by Mattermost", "mobile.about.serverVersion": "Server Version: {version} (Build {number})", "mobile.about.serverVersionNoBuild": "Server Version: {version}", + "mobile.action_menu.select": "Select an option", "mobile.components.select_server_view.connect": "Connect", "mobile.components.select_server_view.connecting": "Connecting...", "mobile.components.select_server_view.enterServerUrl": "Enter Server URL", "mobile.components.select_server_view.proceed": "Proceed", "mobile.components.select_server_view.siteUrlPlaceholder": "https://mattermost.example.com", + "mobile.document_preview.failed_description": "An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n", + "mobile.document_preview.failed_title": "Open Document failed", + "mobile.downloader.disabled_description": "File downloads are disabled on this server. Please contact your System Admin for more details.\n", + "mobile.downloader.disabled_title": "Download disabled", + "mobile.downloader.failed_description": "An error occurred while downloading the file. Please check your internet connection and try again.\n", + "mobile.downloader.failed_title": "Download failed", "mobile.error_handler.button": "Relaunch", "mobile.error_handler.description": "\nTap relaunch to open the app again. After restart, you can report the problem from the settings menu.\n", "mobile.error_handler.title": "Unexpected error occurred", @@ -101,7 +163,14 @@ "mobile.oauth.something_wrong.okButon": "Ok", "mobile.oauth.switch_to_browser": "Please use your browser to complete the login", "mobile.oauth.try_again": "Try again", + "mobile.post_info.add_reaction": "Add Reaction", + "mobile.post_pre_header.flagged": "Saved", + "mobile.post_pre_header.pinned": "Pinned", + "mobile.post_pre_header.pinned_flagged": "Pinned and Saved", "mobile.post.cancel": "Cancel", + "mobile.post.failed_delete": "Delete Message", + "mobile.post.failed_retry": "Try Again", + "mobile.posts_view.moreMsg": "More New Messages Above", "mobile.privacy_link": "Privacy Policy", "mobile.push_notification_reply.button": "Send", "mobile.push_notification_reply.placeholder": "Write a reply...", @@ -128,10 +197,21 @@ "mobile.server_url.empty": "Please enter a valid server URL", "mobile.server_url.invalid_format": "URL must start with http:// or https://", "mobile.session_expired": "Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.", + "mobile.system_message.channel_archived_message": "{username} archived the channel", + "mobile.system_message.channel_unarchived_message": "{username} unarchived the channel", + "mobile.system_message.update_channel_displayname_message_and_forget.updated_from": "{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}", + "mobile.system_message.update_channel_header_message_and_forget.removed": "{username} removed the channel header (was: {oldHeader})", + "mobile.system_message.update_channel_header_message_and_forget.updated_from": "{username} updated the channel header from: {oldHeader} to: {newHeader}", + "mobile.system_message.update_channel_header_message_and_forget.updated_to": "{username} updated the channel header to: {newHeader}", + "mobile.system_message.update_channel_purpose_message.removed": "{username} removed the channel purpose (was: {oldPurpose})", + "mobile.system_message.update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}", + "mobile.system_message.update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {newPurpose}", "mobile.tos_link": "Terms of Service", "mobile.unsupported_server.message": "Attachments, link previews, reactions and embed data may not be displayed correctly. If this issue persists contact your System Administrator to upgrade your Mattermost server.", "mobile.unsupported_server.ok": "OK", "mobile.unsupported_server.title": "Unsupported server version", + "mobile.youtube_playback_error.description": "An error occurred while trying to play the YouTube video.\nDetails: {details}", + "mobile.youtube_playback_error.title": "YouTube playback error", "password_form.title": "Password Reset", "password_send.checkInbox": "Please check your inbox.", "password_send.description": "To reset your password, enter the email address you used to sign up", @@ -142,7 +222,21 @@ "permalink.show_dialog_warn.description": "You are about to join {channel} without explicitly being added by the channel admin. Are you sure you wish to join this private channel?", "permalink.show_dialog_warn.join": "Join", "permalink.show_dialog_warn.title": "Join private channel", + "post_body.check_for_out_of_channel_groups_mentions.message": "did not get notified by this mention because they are not in the channel. They are also not a member of the groups linked to this channel.", + "post_body.check_for_out_of_channel_mentions.link.and": " and ", + "post_body.check_for_out_of_channel_mentions.link.private": "add them to this private channel", + "post_body.check_for_out_of_channel_mentions.link.public": "add them to the channel", + "post_body.check_for_out_of_channel_mentions.message_last": "? They will have access to all message history.", + "post_body.check_for_out_of_channel_mentions.message.multiple": "were mentioned but they are not in the channel. Would you like to ", + "post_body.check_for_out_of_channel_mentions.message.one": "was mentioned but is not in the channel. Would you like to ", + "post_body.commentedOn": "Commented on {name}{apostrophe} message: ", + "post_body.deleted": "(message deleted)", + "post_info.auto_responder": "AUTOMATIC REPLY", + "post_info.bot": "BOT", + "post_info.guest": "GUEST", + "post_info.system": "System", "post_message_view.edited": "(edited)", + "posts_view.newMsg": "New Messages", "signup.email": "Email and Password", "signup.google": "Google Apps", "signup.office365": "Office 365", diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj index fb618fd98..60dd1c9cc 100644 --- a/ios/Mattermost.xcodeproj/project.pbxproj +++ b/ios/Mattermost.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; - 279A77DD26A6F1BE00B515F1 /* compass-icons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 279A77DC26A6F1BE00B515F1 /* compass-icons.ttf */; }; 2D5296A8926B4D7FBAF2D6E2 /* OpenSans-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 6561AEAC21CC40B8A72ABB93 /* OpenSans-Light.ttf */; }; 4953BF602368AE8600593328 /* SwimeProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4953BF5F2368AE8600593328 /* SwimeProxy.swift */; }; 49AE36FF26D4455800EF4E52 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 49AE36FE26D4455800EF4E52 /* SwiftPackageProductDependency */; }; @@ -42,6 +41,7 @@ 7FABDFC22211A39000D0F595 /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FABDFC12211A39000D0F595 /* Section.swift */; }; 7FABE00A2212650600D0F595 /* ChannelsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FABE0092212650600D0F595 /* ChannelsViewController.swift */; }; 7FABE0562213884700D0F595 /* libUploadAttachments.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FABE04522137F2A00D0F595 /* libUploadAttachments.a */; }; + 7FB4224026DED2E40063B0EE /* compass-icons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 531BEBC52513E93C00BC05B1 /* compass-icons.ttf */; }; 7FCEFB9326B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m in Sources */ = {isa = PBXBuildFile; fileRef = 7FCEFB9226B7934F006DC1DE /* SDWebImageDownloaderOperation+Swizzle.m */; }; 7FEB109D1F61019C0039A015 /* MattermostManaged.m in Sources */ = {isa = PBXBuildFile; fileRef = 7FEB109A1F61019C0039A015 /* MattermostManaged.m */; }; 84E3264B229834C30055068A /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E325FF229834C30055068A /* Config.swift */; }; @@ -136,7 +136,6 @@ 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Mattermost/main.m; sourceTree = ""; }; 182D203F539AF68F1647EFAF /* Pods-Mattermost-MattermostTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mattermost-MattermostTests.release.xcconfig"; path = "Target Support Files/Pods-Mattermost-MattermostTests/Pods-Mattermost-MattermostTests.release.xcconfig"; sourceTree = ""; }; 25BF2BACE89201DE6E585B7E /* Pods-Mattermost.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mattermost.release.xcconfig"; path = "Target Support Files/Pods-Mattermost/Pods-Mattermost.release.xcconfig"; sourceTree = ""; }; - 279A77DC26A6F1BE00B515F1 /* compass-icons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "compass-icons.ttf"; path = "../assets/fonts/compass-icons.ttf"; sourceTree = ""; }; 297AAFCCF0BD99FC109DA2BC /* Pods-MattermostTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MattermostTests.release.xcconfig"; path = "Target Support Files/Pods-MattermostTests/Pods-MattermostTests.release.xcconfig"; sourceTree = ""; }; 32AC3D4EA79E44738A6E9766 /* OpenSans-BoldItalic.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "OpenSans-BoldItalic.ttf"; path = "../assets/fonts/OpenSans-BoldItalic.ttf"; sourceTree = ""; }; 34B20A903038487E8D7DEA1E /* Roboto-Light.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Roboto-Light.ttf"; path = "../assets/fonts/Roboto-Light.ttf"; sourceTree = ""; }; @@ -145,7 +144,6 @@ 4953BF5F2368AE8600593328 /* SwimeProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SwimeProxy.swift; path = Mattermost/SwimeProxy.swift; sourceTree = ""; }; 495BC95F23565ABF00C40C83 /* libXCDYouTubeKit.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libXCDYouTubeKit.a; sourceTree = BUILT_PRODUCTS_DIR; }; 495BC96123565ADD00C40C83 /* libYoutubePlayer-in-WKWebView.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = "libYoutubePlayer-in-WKWebView.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 499F7AF0235511FC00E7AF6E /* Mattermost-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Mattermost-Regular.otf"; path = "../assets/fonts/Mattermost-Regular.otf"; sourceTree = ""; }; 499F7B3F235513F600E7AF6E /* libXCDYouTubeKit.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libXCDYouTubeKit.a; sourceTree = BUILT_PRODUCTS_DIR; }; 499F7B412355141200E7AF6E /* libYoutubePlayer-in-WKWebView.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = "libYoutubePlayer-in-WKWebView.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 49AE36FB26D4452900EF4E52 /* Gekidou */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Gekidou; sourceTree = ""; }; @@ -269,7 +267,6 @@ 0156F464626F49C2977D7982 /* Resources */ = { isa = PBXGroup; children = ( - 499F7AF0235511FC00E7AF6E /* Mattermost-Regular.otf */, D4B1B363C2414DA19C1AC521 /* OpenSans-Bold.ttf */, 32AC3D4EA79E44738A6E9766 /* OpenSans-BoldItalic.ttf */, 3647DF63D6764CF093375861 /* OpenSans-ExtraBold.ttf */, @@ -426,7 +423,6 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( - 279A77DC26A6F1BE00B515F1 /* compass-icons.ttf */, 4953BF5F2368AE8600593328 /* SwimeProxy.swift */, 13B07FAE1A68108700A75B9A /* Mattermost */, 7F240A1A220D3A2300637665 /* MattermostShare */, @@ -624,7 +620,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 279A77DD26A6F1BE00B515F1 /* compass-icons.ttf in Resources */, + 7FB4224026DED2E40063B0EE /* compass-icons.ttf in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 7F0F4B0A24BA173900E14C60 /* LaunchScreen.storyboard in Resources */, 7F292A711E8AB73400A450A3 /* SplashScreenResource in Resources */, diff --git a/ios/Podfile b/ios/Podfile index 079ccc959..f1da9d904 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -40,4 +40,10 @@ end post_install do |installer| react_native_post_install(installer) + + puts 'Patching Alamofire to include X-Uncompressed-Content-Length to measure download progress' + %x(patch Pods/Alamofire/Source/SessionDelegate.swift < patches/SessionDelegate.patch) + + puts 'Patching XCDYouTube so it can playback videos' + %x(patch Pods/XCDYouTubeKit/XCDYouTubeKit/XCDYouTubeVideoOperation.m < patches/XCDYouTubeVideoOperation.patch) end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 792d31ede..19f8f24c6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -803,6 +803,6 @@ SPEC CHECKSUMS: Yoga: 575c581c63e0d35c9a83f4b46d01d63abc1100ac YoutubePlayer-in-WKWebView: cfbf46da51d7370662a695a8f351e5fa1d3e1008 -PODFILE CHECKSUM: fe7a8bc870b0f26b5cb70bccd50d5c634a7f5059 +PODFILE CHECKSUM: ffe54846de075924f3ae637c97a327da67ac16d0 COCOAPODS: 1.10.1 diff --git a/ios/patches/SessionDelegate.patch b/ios/patches/SessionDelegate.patch new file mode 100644 index 000000000..b568cdcff --- /dev/null +++ b/ios/patches/SessionDelegate.patch @@ -0,0 +1,18 @@ +--- SessionDelegate.swifts 2021-08-10 16:26:55.000000000 -0400 ++++ SessionDelegate.swift 2021-08-10 16:27:33.000000000 -0400 +@@ -286,6 +286,15 @@ + return + } + ++ let sizeString = ((downloadTask.response as? HTTPURLResponse)?.allHeaderFields["X-Uncompressed-Content-Length"]) as? String ++ if (sizeString != nil) { ++ let size = Int64(sizeString!) ++ downloadRequest.updateDownloadProgress(bytesWritten: bytesWritten, ++ totalBytesExpectedToWrite: size!) ++ return ++ ++ } ++ + downloadRequest.updateDownloadProgress(bytesWritten: bytesWritten, + totalBytesExpectedToWrite: totalBytesExpectedToWrite) + } diff --git a/ios/patches/XCDYouTubeVideoOperation.patch b/ios/patches/XCDYouTubeVideoOperation.patch new file mode 100644 index 000000000..041d8cb55 --- /dev/null +++ b/ios/patches/XCDYouTubeVideoOperation.patch @@ -0,0 +1,11 @@ +--- Pods/XCDYouTubeKit/XCDYouTubeKit/XCDYouTubeVideoOperation.m 2021-05-28 20:53:40.000000000 -0400 ++++ XCDYouTubeVideoOperation.m 2021-05-28 20:55:18.000000000 -0400 +@@ -119,7 +119,7 @@ + NSString *eventLabel = [self.eventLabels objectAtIndex:0]; + [self.eventLabels removeObjectAtIndex:0]; + +- NSDictionary *query = @{ @"video_id": self.videoIdentifier, @"hl": self.languageIdentifier, @"el": eventLabel, @"ps": @"default" }; ++ NSDictionary *query = @{ @"video_id": self.videoIdentifier, @"hl": self.languageIdentifier, @"el": eventLabel, @"ps": @"default", @"html5": @"1"}; + NSString *queryString = XCDQueryStringWithDictionary(query); + NSURL *videoInfoURL = [NSURL URLWithString:[@"https://www.youtube.com/get_video_info?" stringByAppendingString:queryString]]; + [self startRequestWithURL:videoInfoURL type:XCDYouTubeRequestTypeGetVideoInfo]; diff --git a/package-lock.json b/package-lock.json index bd11ff961..0f9536435 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,6 +102,7 @@ "@testing-library/react-native": "7.2.0", "@types/commonmark": "0.27.5", "@types/commonmark-react-renderer": "4.3.1", + "@types/deep-equal": "1.0.1", "@types/jest": "26.0.24", "@types/react": "17.0.15", "@types/react-intl": "3.0.0", @@ -4856,7 +4857,7 @@ }, "node_modules/@mattermost/react-native-network-client": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/mattermost/react-native-network-client.git#71fabbad5844aa0a1675d2fd137b0905e4d02097", + "resolved": "git+ssh://git@github.com/mattermost/react-native-network-client.git#52e2f9be66a5af660b7875bfbee6de859e339831", "license": "MIT", "dependencies": { "validator": "13.6.0", @@ -7449,6 +7450,12 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-mMUu4nWHLBlHtxXY17Fg6+ucS/MnndyOWyOe7MmwkoMYxvfQU2ajtRaEvqSUv+aVkMqH/C0NCI8UoVfRNQ10yg==", + "dev": true + }, "node_modules/@types/emscripten": { "version": "1.39.5", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.5.tgz", @@ -36376,7 +36383,7 @@ "requires": {} }, "@mattermost/react-native-network-client": { - "version": "git+ssh://git@github.com/mattermost/react-native-network-client.git#71fabbad5844aa0a1675d2fd137b0905e4d02097", + "version": "git+ssh://git@github.com/mattermost/react-native-network-client.git#52e2f9be66a5af660b7875bfbee6de859e339831", "from": "@mattermost/react-native-network-client@github:mattermost/react-native-network-client", "requires": { "validator": "13.6.0", @@ -38463,6 +38470,12 @@ "@types/ms": "*" } }, + "@types/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-mMUu4nWHLBlHtxXY17Fg6+ucS/MnndyOWyOe7MmwkoMYxvfQU2ajtRaEvqSUv+aVkMqH/C0NCI8UoVfRNQ10yg==", + "dev": true + }, "@types/emscripten": { "version": "1.39.5", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.5.tgz", diff --git a/package.json b/package.json index 006b7b418..0f12f4b5f 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "@testing-library/react-native": "7.2.0", "@types/commonmark": "0.27.5", "@types/commonmark-react-renderer": "4.3.1", + "@types/deep-equal": "1.0.1", "@types/jest": "26.0.24", "@types/react": "17.0.15", "@types/react-intl": "3.0.0", diff --git a/patches/react-native-navigation+7.18.1.patch b/patches/react-native-navigation+7.18.1.patch index b13ce61a5..ef0eb5c1c 100644 --- a/patches/react-native-navigation+7.18.1.patch +++ b/patches/react-native-navigation+7.18.1.patch @@ -1,8 +1,26 @@ diff --git a/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java b/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java -index 2b30edf..2063845 100644 +index 2b30edf..5d01be0 100644 --- a/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java +++ b/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/react/NavigationModule.java -@@ -61,15 +61,19 @@ public class NavigationModule extends ReactContextBaseJavaModule { +@@ -33,6 +33,8 @@ import androidx.annotation.Nullable; + + import static com.reactnativenavigation.utils.UiUtils.pxToDp; + ++import android.app.Activity; ++ + public class NavigationModule extends ReactContextBaseJavaModule { + private static final String NAME = "RNNBridgeModule"; + +@@ -56,20 +58,30 @@ public class NavigationModule extends ReactContextBaseJavaModule { + @Override + public void onHostPause() { + super.onHostPause(); +- navigator().onHostPause(); ++ Navigator navigator = navigator(); ++ if (navigator != null) { ++ navigator().onHostPause(); ++ } + } @Override public void onHostResume() { @@ -17,21 +35,37 @@ index 2b30edf..2063845 100644 - navigator().onHostResume(); + try { + eventEmitter = new EventEmitter(reactContext); -+ navigator().setEventEmitter(eventEmitter); -+ layoutFactory.init( -+ activity(), -+ eventEmitter, -+ navigator().getChildRegistry(), -+ ((NavigationApplication) activity().getApplication()).getExternalComponents() -+ ); -+ navigator().onHostResume(); ++ Navigator navigator = navigator(); ++ if (navigator != null) { ++ navigator().setEventEmitter(eventEmitter); ++ layoutFactory.init( ++ activity(), ++ eventEmitter, ++ navigator().getChildRegistry(), ++ ((NavigationApplication) activity().getApplication()).getExternalComponents() ++ ); ++ navigator().onHostResume(); ++ } + } catch (ClassCastException e) { + // The most current activity is not a NavigationActivity + } } }); } -@@ -217,8 +221,12 @@ public class NavigationModule extends ReactContextBaseJavaModule { +@@ -206,7 +218,11 @@ public class NavigationModule extends ReactContextBaseJavaModule { + } + + private Navigator navigator() { +- return activity().getNavigator(); ++ if (activity() instanceof NavigationActivity) { ++ NavigationActivity activity = (NavigationActivity) activity(); ++ return activity.getNavigator(); ++ } ++ return null; + } + + private Options parse(@Nullable ReadableMap mergeOptions) { +@@ -217,21 +233,26 @@ public class NavigationModule extends ReactContextBaseJavaModule { protected void handle(Runnable task) { UiThread.post(() -> { @@ -46,32 +80,58 @@ index 2b30edf..2063845 100644 } }); } + +- protected NavigationActivity activity() { +- return (NavigationActivity) getCurrentActivity(); ++ protected Activity activity() { ++ return getCurrentActivity(); + } + + @Override + public void onCatalystInstanceDestroy() { +- final NavigationActivity navigationActivity = activity(); +- if (navigationActivity != null) { +- navigationActivity.onCatalystInstanceDestroy(); ++ final Activity navigationActivity = activity(); ++ if (navigationActivity != null && navigationActivity instanceof NavigationActivity) { ++ NavigationActivity activity = (NavigationActivity)navigationActivity; ++ activity.onCatalystInstanceDestroy(); + } + super.onCatalystInstanceDestroy(); + } +diff --git a/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java b/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java +index 94e2a9b..ea77732 100644 +--- a/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java ++++ b/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java +@@ -3,6 +3,7 @@ package com.reactnativenavigation.viewcontrollers.component; + import android.app.Activity; + import android.view.View; + ++import com.reactnativenavigation.options.params.NullBool; + import com.reactnativenavigation.viewcontrollers.viewcontroller.ScrollEventListener; + import com.reactnativenavigation.options.Options; + import com.reactnativenavigation.viewcontrollers.viewcontroller.Presenter; +@@ -147,7 +148,7 @@ public class ComponentViewController extends ChildController { + + @Override + public void destroy() { +- final boolean blurOnUnmount = options != null && options.modal.blurOnUnmount.isTrue(); ++ final boolean blurOnUnmount = options != null && (options.modal.blurOnUnmount.isTrue() || options.overlayOptions.interceptTouchOutside instanceof NullBool); + if (blurOnUnmount) { + blurActivityFocus(); + } diff --git a/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java b/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java -index 1e5751e..f921605 100644 +index 1e5751e..5117993 100644 --- a/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java +++ b/node_modules/react-native-navigation/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java -@@ -1,10 +1,12 @@ - package com.reactnativenavigation.viewcontrollers.viewcontroller; - - import android.app.Activity; -+import android.content.Context; - import android.view.View; - import android.view.ViewGroup; - import android.view.ViewManager; - import android.view.ViewTreeObserver; -+import android.view.inputmethod.InputMethodManager; - +@@ -9,7 +9,6 @@ import android.view.ViewTreeObserver; import androidx.annotation.CallSuper; import androidx.annotation.CheckResult; -@@ -276,6 +278,8 @@ public abstract class ViewController implements ViewTreeObs - ((Destroyable) view).destroy(); - } - if (view != null) { -+ InputMethodManager imm = (InputMethodManager)activity.getSystemService(Context.INPUT_METHOD_SERVICE); -+ imm.hideSoftInputFromWindow(view.getWindowToken(), 0); - view.getViewTreeObserver().removeOnGlobalLayoutListener(this); - view.setOnHierarchyChangeListener(null); - if (view.getParent() instanceof ViewGroup) { + import androidx.annotation.Nullable; +-import androidx.annotation.RestrictTo; + import androidx.annotation.VisibleForTesting; + import androidx.coordinatorlayout.widget.CoordinatorLayout; + diff --git a/node_modules/react-native-navigation/lib/ios/RNNOverlayWindow.m b/node_modules/react-native-navigation/lib/ios/RNNOverlayWindow.m index 934e7e7..19169a3 100644 --- a/node_modules/react-native-navigation/lib/ios/RNNOverlayWindow.m diff --git a/patches/react-native-youtube+2.0.2.patch b/patches/react-native-youtube+2.0.2.patch index 8cdaa892e..3f9c7fd9e 100644 --- a/patches/react-native-youtube+2.0.2.patch +++ b/patches/react-native-youtube+2.0.2.patch @@ -18,7 +18,7 @@ index fabd291..45b322b 100644 @implementation RCTYouTubeStandalone { RCTPromiseResolveBlock resolver; RCTPromiseRejectBlock rejecter; -@@ -14,6 +23,7 @@ @implementation RCTYouTubeStandalone { +@@ -14,6 +23,7 @@ RCT_EXPORT_MODULE(); RCT_REMAP_METHOD(playVideo, playVideoWithResolver:(NSString*)videoId @@ -26,7 +26,7 @@ index fabd291..45b322b 100644 resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { -@@ -22,10 +32,10 @@ @implementation RCTYouTubeStandalone { +@@ -22,10 +32,10 @@ RCT_REMAP_METHOD(playVideo, #else dispatch_async(dispatch_get_main_queue(), ^{ UIViewController *root = [[[[UIApplication sharedApplication] delegate] window] rootViewController]; @@ -39,7 +39,7 @@ index fabd291..45b322b 100644 [[XCDYouTubeClient defaultClient] getVideoWithIdentifier:videoId completionHandler:^(XCDYouTubeVideo * _Nullable video, NSError * _Nullable error) { -@@ -38,10 +48,18 @@ @implementation RCTYouTubeStandalone { +@@ -38,10 +48,18 @@ RCT_REMAP_METHOD(playVideo, streamURLs[@(XCDYouTubeVideoQualitySmall240) ]; @@ -72,3 +72,16 @@ index 0ee59c6..4a8294b 100644 ? null - : { playVideo: (videoId) => YouTubeStandalone.playVideo(videoId) }; + : { playVideo: (videoId, startTime) => YouTubeStandalone.playVideo(videoId, startTime) }; +diff --git a/node_modules/react-native-youtube/main.d.ts b/node_modules/react-native-youtube/main.d.ts +index d771db7..410709d 100644 +--- a/node_modules/react-native-youtube/main.d.ts ++++ b/node_modules/react-native-youtube/main.d.ts +@@ -35,7 +35,7 @@ declare class YouTube extends React.Component { + } + + export declare const YouTubeStandaloneIOS: { +- playVideo(videoId: string): Promise; ++ playVideo(videoId: string, startTime: number): Promise; + }; + + export declare const YouTubeStandaloneAndroid: { diff --git a/test/intl-test-helper.tsx b/test/intl-test-helper.tsx index 0b1c94193..3fc8f8fe8 100644 --- a/test/intl-test-helper.tsx +++ b/test/intl-test-helper.tsx @@ -1,10 +1,22 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {getTranslations} from '@i18n'; +import DatabaseProvider from '@nozbe/watermelondb/DatabaseProvider'; import {render} from '@testing-library/react-native'; import React, {ReactElement} from 'react'; -import {IntlProvider} from 'react-intl'; +import {createIntl, IntlProvider} from 'react-intl'; + +import {ThemeContext, getDefaultThemeByAppearance} from '@context/theme'; +import {getTranslations} from '@i18n'; + +import type Database from '@nozbe/watermelondb/Database'; + +export function getIntlShape(locale = 'en') { + return createIntl({ + locale, + messages: getTranslations(locale), + }); +} export function renderWithIntl(ui: ReactElement, {locale = 'en', ...renderOptions} = {}) { function Wrapper({children}: {children: ReactElement}) { @@ -21,5 +33,45 @@ export function renderWithIntl(ui: ReactElement, {locale = 'en', ...renderOption return render(ui, {wrapper: Wrapper, ...renderOptions}); } +export function renderWithIntlAndTheme(ui: ReactElement, {locale = 'en', ...renderOptions} = {}) { + function Wrapper({children}: {children: ReactElement}) { + return ( + + + {children} + + + ); + } + + return render(ui, {wrapper: Wrapper, ...renderOptions}); +} + +export function renderWithEverything(ui: ReactElement, {locale = 'en', database, ...renderOptions}: {locale?: string; database?: Database; renderOptions?: any} = {}) { + function Wrapper({children}: {children: ReactElement}) { + if (!database) { + return null; + } + + return ( + + + + {children} + + + + ); + } + + return render(ui, {wrapper: Wrapper, ...renderOptions}); +} + // eslint-disable-next-line no-duplicate-imports export * from '@testing-library/react-native'; diff --git a/test/test_helper.js b/test/test_helper.js index 1f2d21b20..1a861cb6a 100644 --- a/test/test_helper.js +++ b/test/test_helper.js @@ -7,6 +7,8 @@ import nock from 'nock'; import Config from '@assets/config.json'; import {Client} from '@client/rest'; import GENERAL_CONSTANTS from '@constants/general'; +import DatabaseManager from '@database/manager'; +import {prepareCommonSystemValues} from '@queries/servers/system'; import {generateId} from '@utils/general'; const PASSWORD = 'password1'; @@ -27,6 +29,41 @@ class TestHelper { this.basicScheme = null; } + setupServerDatabase = async () => { + const serverUrl = 'https://appv1.mattermost.com'; + await DatabaseManager.init([serverUrl]); + const {database, operator} = DatabaseManager.serverDatabases[serverUrl]; + + this.initMockEntities(); + + // Add current user + await operator.handleUsers({ + users: [this.basicUser], + prepareRecordsOnly: false, + }); + + // Add one team + await operator.handleTeam({ + teams: [this.basicTeam], + prepareRecordsOnly: false, + }); + await operator.handleMyTeam({ + myTeams: [this.basicTeamMember], + prepareRecordsOnly: false, + }); + + const systems = await prepareCommonSystemValues(operator, { + config: {}, + license: {}, + currentChannelId: '', + currentTeamId: this.basicTeam.id, + currentUserId: this.basicUser.id, + }); + await operator.batchRecords(systems); + + return {database, operator}; + } + activateMocking() { if (!nock.isActive()) { nock.activate(); @@ -95,6 +132,7 @@ class TestHelper { fakeChannelMember = (userId, channelId) => { return { + id: channelId, user_id: userId, channel_id: channelId, notify_props: {}, @@ -163,6 +201,7 @@ class TestHelper { fakeTeamMember = (userId, teamId) => { return { + id: teamId, user_id: userId, team_id: teamId, roles: 'team_user', diff --git a/types/api/config.d.ts b/types/api/config.d.ts index 177bbba72..b9fa5d2cc 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -49,6 +49,7 @@ interface ClientConfig { EnableConfirmNotificationsToChannel: string; EnableCustomBrand: string; EnableCustomEmoji: string; + EnableCustomUserStatuses: string; EnableCustomTermsOfService: string; EnableDeveloper: string; EnableDiagnostics: string; @@ -110,6 +111,7 @@ interface ClientConfig { ExperimentalTownSquareIsReadOnly: string; ExperimentalViewArchivedChannels: string; ExtendSessionLengthWithActivity: string; + FeatureFlagAppsEnabled?: string; GfycatApiKey: string; GfycatApiSecret: string; GoogleDeveloperKey: string; diff --git a/types/api/integrations.d.ts b/types/api/integrations.d.ts index a726d0987..9817aac7a 100644 --- a/types/api/integrations.d.ts +++ b/types/api/integrations.d.ts @@ -85,3 +85,31 @@ type InteractiveDialogConfig = { state: string; }; }; + +type PostAction = { + id?: string; + type?: string; + name?: string; + disabled?: boolean; + style?: string; + data_source?: string; + options?: PostActionOption[]; + default_option?: string; + integration?: PostActionIntegration; + cookie?: string; +}; + +type PostActionOption = { + text: string; + value: string; +}; + +type PostActionIntegration = { + url?: string; + context?: Record; +} + +type PostActionResponse = { + status: string; + trigger_id: string; +}; diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index 71c997a94..285ee1387 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -51,7 +51,6 @@ type Post = { user_id: string; channel_id: string; root_id: string; - parent_id: string; original_id: string; message: string; type: PostType; @@ -116,3 +115,29 @@ type PostProps = { type PostResponse = PostOrderBlock & { posts: IDMappedObjects; }; + +type MessageAttachment = { + id: number; + fallback: string; + color: string; + pretext: string; + author_name: string; + author_link: string; + author_icon: string; + title: string; + title_link: string; + text: string; + fields: MessageAttachmentField[]; + image_url: string; + thumb_url: string; + footer: string; + footer_icon: string; + timestamp: number | string; + actions?: PostAction[]; +}; + +type MessageAttachmentField = { + title: string; + value: any; + short: boolean; +} diff --git a/types/api/users.d.ts b/types/api/users.d.ts index 39fd63b7a..be24eaa46 100644 --- a/types/api/users.d.ts +++ b/types/api/users.d.ts @@ -83,3 +83,20 @@ type UserStatus = { type UserProps = { [userPropsName: string]: any; }; + +type UserCustomStatus = { + emoji?: string; + text?: string; + expires_at?: string; + duration?: CustomStatusDuration; +}; + +enum CustomStatusDuration { + DONT_CLEAR = '', + THIRTY_MINUTES = 'thirty_minutes', + ONE_HOUR = 'one_hour', + FOUR_HOURS = 'four_hours', + TODAY = 'today', + THIS_WEEK = 'this_week', + DATE_AND_TIME = 'date_and_time', +} diff --git a/types/database/database.d.ts b/types/database/database.d.ts index c19e02562..af97bf782 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -79,11 +79,6 @@ export type HandleFilesArgs = { prepareRecordsOnly: boolean; }; -export type HandlePostMetadataArgs = { - metadatas: Metadata[]; - prepareRecordsOnly: boolean; -}; - export type HandlePostsArgs = { actionType: string; order: string[]; @@ -239,6 +234,7 @@ export type HandleTeamMembershipArgs = PrepareOnly & { export type HandlePreferencesArgs = PrepareOnly & { preferences: PreferenceType[]; + sync?: boolean; }; export type HandleUsersArgs = PrepareOnly & { diff --git a/types/database/models/servers/group_membership.d.ts b/types/database/models/servers/group_membership.d.ts index 9ccb22ce4..dd6c1e8f3 100644 --- a/types/database/models/servers/group_membership.d.ts +++ b/types/database/models/servers/group_membership.d.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Query, Relation} from '@nozbe/watermelondb'; +import {Relation} from '@nozbe/watermelondb'; import Model, {Associations} from '@nozbe/watermelondb/Model'; /** @@ -17,19 +17,9 @@ export default class GroupMembershipModel extends Model { groupId: string; userId: string; - /** memberGroup : The related group this user belongs to */ - memberGroup: Relation; + /** group : The related group this user belongs to */ + group: Relation; - /** memberUser : The related user in the group */ - memberUser: Relation; - - /** - * getAllGroupsForUser : Retrieves all the groups that the user is part of - */ - getAllGroupsForUser: Query; - - /** - * getAllUsersInGroup : Retrieves all the users who are part of this group - */ - getAllUsersInGroup: Query; + /** user : The related user in the group */ + user: Relation; } diff --git a/types/database/models/servers/post.d.ts b/types/database/models/servers/post.d.ts index a526f508d..24899e73d 100644 --- a/types/database/models/servers/post.d.ts +++ b/types/database/models/servers/post.d.ts @@ -35,6 +35,9 @@ export default class PostModel extends Model { /** message : Message in the post */ message: string; + /** metadata: All the extra data associated with this Post */ + metadata: PostMetadata | null; + /** original_id : Any post will have this value empty unless it is updated */ originalId: string; @@ -44,6 +47,8 @@ export default class PostModel extends Model { /** previous_post_id : Id of the previous post. If this value is empty, this implies that it is not in the db and we will request it from server */ previousPostId: string; + root: Query; + /** root_id : Used in threads. All posts under a thread will have this id in common */ rootId: string; @@ -54,7 +59,7 @@ export default class PostModel extends Model { userId: string; /** props : Additional attributes for this props */ - props: object; + props: any; /** drafts : Every drafts associated with this Post */ drafts: Query; @@ -65,9 +70,6 @@ export default class PostModel extends Model { /** postsInThread: Every posts associated to a thread */ postsInThread: Query; - /** metadata: All the extra data associated with this Post */ - metadata: Relation; - /** reactions: All the reactions associated with this Post */ reactions: Query; diff --git a/types/database/models/servers/post_metadata.d.ts b/types/database/models/servers/post_metadata.d.ts deleted file mode 100644 index 99b7111cc..000000000 --- a/types/database/models/servers/post_metadata.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Relation} from '@nozbe/watermelondb'; -import Model from '@nozbe/watermelondb/Model'; - -/** - * PostMetadata provides additional information on a POST - */ -export default class PostMetadataModel extends Model { - /** table (name) : PostMetadata */ - static table: string; - - /** data : Different types of data ranging from arrays, emojis, files to images and reactions. */ - data: PostMetadata; - - /** post: The record representing the POST parent. */ - post: Relation; -} diff --git a/types/database/models/servers/user.d.ts b/types/database/models/servers/user.d.ts index 8c2f489b2..4d3a36bd8 100644 --- a/types/database/models/servers/user.d.ts +++ b/types/database/models/servers/user.d.ts @@ -90,4 +90,7 @@ export default class UserModel extends Model { /** teams : All the team that this user is part of */ teams: Query; + + /** prepareSatus: Prepare the model to update the user status in a batch operation */ + prepareSatus: (status: string) => void; } diff --git a/types/database/raw_values.d.ts b/types/database/raw_values.d.ts index eee51e286..750c21325 100644 --- a/types/database/raw_values.d.ts +++ b/types/database/raw_values.d.ts @@ -67,7 +67,7 @@ type Metadata = { type ReactionsPerPost = { post_id: string; - reactions: Reactions[]; + reactions: Reaction[]; } type IdValue = { diff --git a/types/global/markdown.d.ts b/types/global/markdown.d.ts deleted file mode 100644 index 580637487..000000000 --- a/types/global/markdown.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -type UserMentionKey= { - key: string; - caseSensitive?: boolean; -}; diff --git a/types/global/markdown.ts b/types/global/markdown.ts new file mode 100644 index 000000000..e520da24e --- /dev/null +++ b/types/global/markdown.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {TextStyle, ViewStyle} from 'react-native'; + +export type UserMentionKey= { + key: string; + caseSensitive?: boolean; +}; + +export type MarkdownBlockStyles = { + adjacentParagraph: ViewStyle; + horizontalRule: ViewStyle; + quoteBlockIcon: TextStyle; +}; + +export type MarkdownTextStyles = { + [key: string]: TextStyle; +};