From 55324127e18e6738e8f28eee43af145b61621c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Thu, 3 Feb 2022 12:59:15 +0100 Subject: [PATCH] [Gekidou] Post input (#5844) * Initial commit post input * Fix message posting, add create direct channel and minor fixes * Fix "is typing" and "react to last post" behaviour * Some reordering, better handling of upload error, properly clear draft on send message, and fix minor progress bar misbehavior * Add keyboard listener for shift-enter, add selection between video or photo while attaching, add alert when trying to attach more than you are allowed, add paste functionality, minor fixes and reordering * Add library patch * Fix lint * Address feedback * Address feedback * Add missing negation * Check for group name and fix typo on draft comparisons * Address feedback * Address feedback * Address feedback * Address feedback * Fix several bugs * Remove @app imports * Address feedback * fix post list & post draft layout on iOS * Fix post draft cursor position * Fix file upload route * Allow to pick multiple images using the image picker * accurately get the channel member count * remove android cursor workaround * Remove local const INPUT_LINE_HEIGHT * move getPlaceHolder out of the component * use substring instead of legacy substr for hardward keyboard * Move onAppStateChange above the effects * Fix camera action bottom sheet * no need to memo SendButton * properly use memberCount in sender handler * Refactor how to get memberCount * Fix queryRecentPostsInThread * Remove unused isDirectChannelVisible && isGroupChannelVisible util functions * rename errorBadUser to errorUnkownUser * extract localized strings * use ClientErrorProps instead of ClientError * Minor improvements Co-authored-by: Elias Nahum --- .../mattermost/rnbeta/MainApplication.java | 3 - .../rnbeta/RNTextInputResetModule.java | 42 --- app/actions/local/draft.ts | 135 +++++++- app/actions/local/reactions.ts | 18 +- app/actions/local/user.ts | 2 +- app/actions/remote/apps.ts | 2 +- app/actions/remote/channel.ts | 114 ++++++- app/actions/remote/command.ts | 202 +++++++++++ app/actions/remote/post.ts | 117 ++++++- app/actions/remote/reactions.ts | 30 +- app/actions/remote/team.ts | 4 +- app/actions/websocket/channel.ts | 6 +- app/actions/websocket/index.ts | 5 +- app/actions/websocket/users.ts | 52 ++- app/client/rest/base.ts | 5 + app/client/rest/files.ts | 2 +- app/client/rest/integrations.ts | 6 +- app/client/rest/posts.ts | 2 +- app/client/websocket/index.ts | 2 +- app/components/error_text/error_text.test.tsx | 4 +- app/components/error_text/index.tsx | 8 +- app/components/post_draft/archived/index.tsx | 103 ++++++ .../cursor_position_handler/index.tsx | 37 ++ .../draft_handler/draft_handler.tsx | 139 ++++++++ .../post_draft/draft_handler/index.ts | 68 ++++ .../post_draft/draft_input/index.tsx | 174 ++++++++++ app/components/post_draft/index.ts | 81 +++++ app/components/post_draft/post_draft.tsx | 107 ++++++ app/components/post_draft/post_input/index.ts | 67 ++++ .../post_draft/post_input/post_input.tsx | 319 ++++++++++++++++++ .../camera_quick_action/camera_type.tsx | 135 ++++++++ .../camera_quick_action/index.tsx | 94 ++++++ .../quick_actions/file_quick_action/index.tsx | 72 ++++ .../image_quick_action/index.tsx | 74 ++++ .../post_draft/quick_actions/index.ts | 45 +++ .../input_quick_action/index.tsx | 74 ++++ .../quick_actions/quick_actions.tsx | 101 ++++++ app/components/post_draft/read_only/index.tsx | 72 ++++ .../post_draft/send_action/index.tsx | 75 ++++ .../post_draft/send_handler/index.ts | 120 +++++++ .../post_draft/send_handler/send_handler.tsx | 286 ++++++++++++++++ app/components/post_draft/typing/index.tsx | 157 +++++++++ app/components/post_draft/uploads/index.tsx | 163 +++++++++ .../post_draft/uploads/upload_item/index.tsx | 155 +++++++++ .../uploads/upload_item/upload_remove.tsx | 75 ++++ .../uploads/upload_item/upload_retry.tsx | 42 +++ app/components/post_list/index.tsx | 4 +- .../post/body/content/image_preview/index.tsx | 1 - .../attachment_image/index.tsx | 1 - .../post/body/files/document_file.tsx | 1 - .../post_list/post/body/files/file.tsx | 2 - .../post_list/post/body/files/file_icon.tsx | 5 +- .../post_list/post/body/files/image_file.tsx | 7 +- app/components/progress_bar/index.tsx | 45 +-- app/constants/deep_linking.ts | 1 + app/constants/events.ts | 1 + app/constants/general.ts | 1 + app/constants/index.ts | 4 + app/constants/post_draft.ts | 21 ++ app/constants/server_errors.ts | 8 + app/constants/view.ts | 3 + .../server_data_operator/comparators/index.ts | 2 +- .../server_data_operator/handlers/post.ts | 2 +- .../server_data_operator/transformers/post.ts | 2 +- app/helpers/api/preference.ts | 2 +- app/init/draft_upload_manager.ts | 18 +- app/init/websocket_manager.ts | 4 + app/queries/servers/post.ts | 42 ++- app/queries/servers/team.ts | 18 +- app/screens/channel/channel.tsx | 21 +- .../channel_post_list/channel_post_list.tsx | 39 ++- .../components/disabled_fields.tsx | 2 +- app/screens/edit_profile/components/form.tsx | 4 +- .../edit_profile/components/panel_item.tsx | 1 - .../edit_profile/components/profile_error.tsx | 4 +- .../components/profile_image_picker.tsx | 1 - app/screens/edit_profile/edit_profile.tsx | 1 - app/screens/home/tab_bar/index.tsx | 4 +- app/utils/channel/index.ts | 42 ++- app/utils/draft/index.ts | 240 +++++++++++++ app/utils/emoji/helpers.ts | 96 +++++- app/utils/file/file_picker/index.ts | 2 - app/utils/file/index.ts | 1 - app/utils/role/index.ts | 35 +- app/utils/url/index.ts | 5 + assets/base/i18n/en.json | 49 +++ ios/Podfile.lock | 4 +- package-lock.json | 14 +- package.json | 2 +- ...react-native-hw-keyboard-event+0.0.4.patch | 10 + ...-native-keyboard-tracking-view+5.7.0.patch | 49 +-- types/api/client.d.ts | 1 + types/api/commands.d.ts | 7 + types/api/config.d.ts | 1 + types/api/files.d.ts | 4 +- types/api/posts.d.ts | 1 - types/components/post_draft_quick_action.d.ts | 13 + types/launch/index.ts | 7 +- .../react-native-keyboard-tracking-view.d.ts | 16 +- types/utils/file.d.ts | 8 +- 100 files changed, 4232 insertions(+), 243 deletions(-) delete mode 100644 android/app/src/main/java/com/mattermost/rnbeta/RNTextInputResetModule.java create mode 100644 app/actions/remote/command.ts create mode 100644 app/components/post_draft/archived/index.tsx create mode 100644 app/components/post_draft/cursor_position_handler/index.tsx create mode 100644 app/components/post_draft/draft_handler/draft_handler.tsx create mode 100644 app/components/post_draft/draft_handler/index.ts create mode 100644 app/components/post_draft/draft_input/index.tsx create mode 100644 app/components/post_draft/index.ts create mode 100644 app/components/post_draft/post_draft.tsx create mode 100644 app/components/post_draft/post_input/index.ts create mode 100644 app/components/post_draft/post_input/post_input.tsx create mode 100644 app/components/post_draft/quick_actions/camera_quick_action/camera_type.tsx create mode 100644 app/components/post_draft/quick_actions/camera_quick_action/index.tsx create mode 100644 app/components/post_draft/quick_actions/file_quick_action/index.tsx create mode 100644 app/components/post_draft/quick_actions/image_quick_action/index.tsx create mode 100644 app/components/post_draft/quick_actions/index.ts create mode 100644 app/components/post_draft/quick_actions/input_quick_action/index.tsx create mode 100644 app/components/post_draft/quick_actions/quick_actions.tsx create mode 100644 app/components/post_draft/read_only/index.tsx create mode 100644 app/components/post_draft/send_action/index.tsx create mode 100644 app/components/post_draft/send_handler/index.ts create mode 100644 app/components/post_draft/send_handler/send_handler.tsx create mode 100644 app/components/post_draft/typing/index.tsx create mode 100644 app/components/post_draft/uploads/index.tsx create mode 100644 app/components/post_draft/uploads/upload_item/index.tsx create mode 100644 app/components/post_draft/uploads/upload_item/upload_remove.tsx create mode 100644 app/components/post_draft/uploads/upload_item/upload_retry.tsx create mode 100644 app/constants/post_draft.ts create mode 100644 app/constants/server_errors.ts create mode 100644 patches/react-native-hw-keyboard-event+0.0.4.patch create mode 100644 types/api/commands.d.ts create mode 100644 types/components/post_draft_quick_action.d.ts diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java index 671d70eff..faef57ed1 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainApplication.java @@ -76,8 +76,6 @@ public class MainApplication extends NavigationApplication implements INotificat return new ShareModule(instance, reactContext); case "NotificationPreferences": return NotificationPreferencesModule.getInstance(instance, reactContext); - case "RNTextInputReset": - return new RNTextInputResetModule(reactContext); default: throw new IllegalArgumentException("Could not find module " + name); } @@ -90,7 +88,6 @@ public class MainApplication extends NavigationApplication implements INotificat map.put("MattermostManaged", new ReactModuleInfo("MattermostManaged", "com.mattermost.rnbeta.MattermostManagedModule", false, false, false, false, false)); map.put("MattermostShare", new ReactModuleInfo("MattermostShare", "com.mattermost.share.ShareModule", false, false, true, false, false)); map.put("NotificationPreferences", new ReactModuleInfo("NotificationPreferences", "com.mattermost.rnbeta.NotificationPreferencesModule", false, false, false, false, false)); - map.put("RNTextInputReset", new ReactModuleInfo("RNTextInputReset", "com.mattermost.rnbeta.RNTextInputResetModule", false, false, false, false, false)); return map; }; } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/RNTextInputResetModule.java b/android/app/src/main/java/com/mattermost/rnbeta/RNTextInputResetModule.java deleted file mode 100644 index 966b58495..000000000 --- a/android/app/src/main/java/com/mattermost/rnbeta/RNTextInputResetModule.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.mattermost.rnbeta; - -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.uimanager.UIManagerModule; -import com.facebook.react.uimanager.UIBlock; -import com.facebook.react.uimanager.NativeViewHierarchyManager; -import android.content.Context; -import android.view.View; -import android.view.inputmethod.InputMethodManager; - -public class RNTextInputResetModule extends ReactContextBaseJavaModule { - - private final ReactApplicationContext reactContext; - - public RNTextInputResetModule(ReactApplicationContext reactContext) { - super(reactContext); - this.reactContext = reactContext; - } - - @Override - public String getName() { - return "RNTextInputReset"; - } - - // https://github.com/facebook/react-native/pull/12462#issuecomment-298812731 - @ReactMethod - public void resetKeyboardInput(final int reactTagToReset) { - UIManagerModule uiManager = getReactApplicationContext().getNativeModule(UIManagerModule.class); - uiManager.addUIBlock(new UIBlock() { - @Override - public void execute(NativeViewHierarchyManager nativeViewHierarchyManager) { - InputMethodManager imm = (InputMethodManager) getReactApplicationContext().getBaseContext().getSystemService(Context.INPUT_METHOD_SERVICE); - if (imm != null) { - View viewToReset = nativeViewHierarchyManager.resolveView(reactTagToReset); - imm.restartInput(viewToReset); - } - } - }); - } -} \ No newline at end of file diff --git a/app/actions/local/draft.ts b/app/actions/local/draft.ts index e840d1cdd..83f810e1b 100644 --- a/app/actions/local/draft.ts +++ b/app/actions/local/draft.ts @@ -1,8 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {queryDraft} from '@app/queries/servers/drafts'; import DatabaseManager from '@database/manager'; +import {queryDraft} from '@queries/servers/drafts'; export const updateDraftFile = async (serverUrl: string, channelId: string, rootId: string, file: FileInfo, prepareRecordsOnly = false) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -20,8 +20,11 @@ export const updateDraftFile = async (serverUrl: string, channelId: string, root return {error: 'file not found'}; } + // We create a new list to make sure we re-render the draft input. + const newFiles = [...draft.files]; + newFiles[i] = file; draft.prepareUpdate((d) => { - d.files[i] = file; + d.files = newFiles; }); try { @@ -34,3 +37,131 @@ export const updateDraftFile = async (serverUrl: string, channelId: string, root return {error}; } }; + +export const removeDraftFile = async (serverUrl: string, channelId: string, rootId: string, clientId: string, prepareRecordsOnly = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const draft = await queryDraft(operator.database, channelId, rootId); + if (!draft) { + return {error: 'no draft'}; + } + + const i = draft.files.findIndex((v) => v.clientId === clientId); + if (i === -1) { + return {error: 'file not found'}; + } + + if (draft.files.length === 1 && !draft.message) { + draft.prepareDestroyPermanently(); + } else { + draft.prepareUpdate((d) => { + d.files = draft.files.filter((v, index) => index !== i); + }); + } + + try { + if (!prepareRecordsOnly) { + await operator.batchRecords([draft]); + } + + return {draft}; + } catch (error) { + return {error}; + } +}; + +export const updateDraftMessage = async (serverUrl: string, channelId: string, rootId: string, message: string, prepareRecordsOnly = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const draft = await queryDraft(operator.database, channelId, rootId); + if (!draft) { + if (!message) { + return {}; + } + + const newDraft: Draft = { + channel_id: channelId, + root_id: rootId, + message, + }; + + return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly}); + } + + if (draft.message === message) { + return {draft}; + } + + if (draft.files.length === 0 && !message) { + draft.prepareDestroyPermanently(); + } else { + draft.prepareUpdate((d) => { + d.message = message; + }); + } + + try { + if (!prepareRecordsOnly) { + await operator.batchRecords([draft]); + } + + return {draft}; + } catch (error) { + return {error}; + } +}; + +export const addFilesToDraft = async (serverUrl: string, channelId: string, rootId: string, files: FileInfo[], prepareRecordsOnly = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const draft = await queryDraft(operator.database, channelId, rootId); + if (!draft) { + const newDraft: Draft = { + channel_id: channelId, + root_id: rootId, + files, + message: '', + }; + + return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly}); + } + + draft.prepareUpdate((d) => { + d.files = [...draft.files, ...files]; + }); + + try { + if (!prepareRecordsOnly) { + await operator.batchRecords([draft]); + } + + return {draft}; + } catch (error) { + return {error}; + } +}; + +export const removeDraft = async (serverUrl: string, channelId: string, rootId = '') => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + const draft = await queryDraft(database, channelId, rootId); + if (draft) { + await database.write(async () => { + await draft.destroyPermanently(); + }); + } + + return {draft}; +}; diff --git a/app/actions/local/reactions.ts b/app/actions/local/reactions.ts index de178a3c8..846db4cdb 100644 --- a/app/actions/local/reactions.ts +++ b/app/actions/local/reactions.ts @@ -9,12 +9,16 @@ import type SystemModel from '@typings/database/models/servers/system'; const MAXIMUM_RECENT_EMOJI = 27; -export const addRecentReaction = async (serverUrl: string, emojiName: string, prepareRecordsOnly = false) => { +export const addRecentReaction = async (serverUrl: string, emojiNames: string[], prepareRecordsOnly = false) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; } + if (!emojiNames.length) { + return []; + } + let recent: string[] = []; try { const emojis = await operator.database.get(MM_TABLES.SERVER.SYSTEM).find(SYSTEM_IDENTIFIERS.RECENT_REACTIONS); @@ -25,11 +29,17 @@ export const addRecentReaction = async (serverUrl: string, emojiName: string, pr try { const recentEmojis = new Set(recent); - if (recentEmojis.has(emojiName)) { - recentEmojis.delete(emojiName); + for (const name of emojiNames) { + if (recentEmojis.has(name)) { + recentEmojis.delete(name); + } } + recent = Array.from(recentEmojis); - recent.unshift(emojiName); + + for (const name of emojiNames) { + recent.unshift(name); + } return operator.handleSystem({ systems: [{ id: SYSTEM_IDENTIFIERS.RECENT_REACTIONS, diff --git a/app/actions/local/user.ts b/app/actions/local/user.ts index aa8533deb..9e32cdca9 100644 --- a/app/actions/local/user.ts +++ b/app/actions/local/user.ts @@ -57,7 +57,7 @@ export const updateLocalCustomStatus = async (serverUrl: string, user: UserModel } if (customStatus.emoji) { - const recentEmojis = await addRecentReaction(serverUrl, customStatus.emoji, true); + const recentEmojis = await addRecentReaction(serverUrl, [customStatus.emoji], true); if (Array.isArray(recentEmojis)) { models.push(...recentEmojis); } diff --git a/app/actions/remote/apps.ts b/app/actions/remote/apps.ts index 8d3801a8f..c991ad29a 100644 --- a/app/actions/remote/apps.ts +++ b/app/actions/remote/apps.ts @@ -128,7 +128,7 @@ export function postEphemeralCallResponseForCommandArgs(serverUrl: string, respo ); } -const showAppForm = async (form: AppForm, call: AppCallRequest, theme: Theme) => { +export const showAppForm = async (form: AppForm, call: AppCallRequest, theme: Theme) => { const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor); let submitButtons = [{ diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index cc918ab6e..13221c658 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -9,12 +9,10 @@ import {General} from '@constants'; import DatabaseManager from '@database/manager'; import {privateChannelJoinPrompt} from '@helpers/api/channel'; import NetworkManager from '@init/network_manager'; -import {prepareMyChannelsForTeam, queryChannelById, queryMyChannel} from '@queries/servers/channel'; -import {queryCommonSystemValues, queryCurrentUserId} from '@queries/servers/system'; -import {prepareMyTeams, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team'; -import MyChannelModel from '@typings/database/models/servers/my_channel'; -import MyTeamModel from '@typings/database/models/servers/my_team'; -import TeamModel from '@typings/database/models/servers/team'; +import {prepareMyChannelsForTeam, queryChannelById, queryChannelByName, queryMyChannel} from '@queries/servers/channel'; +import {queryCommonSystemValues, queryCurrentTeamId, queryCurrentUserId} from '@queries/servers/system'; +import {prepareMyTeams, queryNthLastChannelFromTeam, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team'; +import {getDirectChannelName} from '@utils/channel'; import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import {displayGroupMessageName, displayUsername} from '@utils/user'; @@ -26,6 +24,9 @@ import {fetchProfilesPerChannels, fetchUsersByIds} from './user'; import type {Client} from '@client/rest'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; +import type MyTeamModel from '@typings/database/models/servers/my_team'; +import type TeamModel from '@typings/database/models/servers/team'; export type MyChannelsRequest = { channels?: Channel[]; @@ -519,6 +520,92 @@ export const switchToChannelByName = async (serverUrl: string, channelName: stri } }; +export async function getChannelMemberCountsByGroup(serverUrl: string, channelId: string, includeTimezones: boolean) { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const channelMemberCountsByGroup = await client.getChannelMemberCountsByGroup(channelId, includeTimezones); + return {channelMemberCountsByGroup}; + } catch (error) { + return {error}; + } +} + +export async function getChannelTimezones(serverUrl: string, channelId: string) { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const channelTimezones = await client.getChannelTimezones(channelId); + return {channelTimezones}; + } catch (error) { + return {error}; + } +} + +export async function getOrCreateDirectChannel(serverUrl: string, otherUserId: string, shouldSwitchToChannel = true) { + 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}; + } + + const currentUserId = await queryCurrentUserId(operator.database); + const channelName = getDirectChannelName(currentUserId, otherUserId); + + const channel = await queryChannelByName(operator.database, channelName); + let result; + if (channel) { + result = {channel}; + } else { + try { + const newChannel = await client.createDirectChannel([currentUserId, otherUserId]); + result = {channel: newChannel}; + + const member = await client.getMyChannelMember(newChannel.id); + + const modelPromises: Array> = []; + const prepare = await prepareMyChannelsForTeam(operator, '', [newChannel], [member]); + if (prepare?.length) { + modelPromises.push(...prepare); + const models = await Promise.all(modelPromises); + const flattenedModels = models.flat() as Model[]; + if (flattenedModels?.length > 0) { + try { + await operator.batchRecords(flattenedModels); + } catch { + // eslint-disable-next-line no-console + console.log('FAILED TO BATCH CHANNELS'); + } + } + } + } catch (error) { + return {error}; + } + } + + if (shouldSwitchToChannel) { + switchToChannelById(serverUrl, result.channel.id); + } + + return result; +} + export const switchToChannelById = async (serverUrl: string, channelId: string, teamId?: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { @@ -532,3 +619,18 @@ export const switchToChannelById = async (serverUrl: string, channelId: string, return {}; }; + +export const switchToPenultimateChannel = async (serverUrl: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + try { + const currentTeam = await queryCurrentTeamId(database); + const channelId = await queryNthLastChannelFromTeam(database, currentTeam, 1); + return switchToChannelById(serverUrl, channelId); + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/remote/command.ts b/app/actions/remote/command.ts new file mode 100644 index 000000000..41e514fea --- /dev/null +++ b/app/actions/remote/command.ts @@ -0,0 +1,202 @@ +// 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'; + +import {showPermalink} from '@actions/local/permalink'; +import {Client} from '@client/rest'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DeepLinkTypes from '@constants/deep_linking'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@init/network_manager'; +import {queryChannelsById} from '@queries/servers/channel'; +import {queryConfig, queryCurrentTeamId} from '@queries/servers/system'; +import {queryUsersByUsername} from '@queries/servers/user'; +import {showModal} from '@screens/navigation'; +import * as DraftUtils from '@utils/draft'; +import {matchDeepLink, tryOpenURL} from '@utils/url'; + +import {getOrCreateDirectChannel, switchToChannelById, switchToChannelByName} from './channel'; + +import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkDM, DeepLinkGM, DeepLinkPlugin} from '@typings/launch'; + +export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: 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: error as ClientErrorProps}; + } + + // TODO https://mattermost.atlassian.net/browse/MM-41234 + // const config = await queryConfig(operator.database) + // if (config.FeatureFlagAppsEnabled) { + // const parser = new AppCommandParser(serverUrl, intl, channelId, rootId); + // if (parser.isAppCommand(msg)) { + // return executeAppCommand(serverUrl, intl, parser); + // } + // } + + const channel = (await queryChannelsById(operator.database, [channelId]))?.[0]; + const teamId = channel?.teamId || (await queryCurrentTeamId(operator.database)); + + const args: CommandArgs = { + channel_id: channelId, + team_id: teamId, + root_id: rootId, + parent_id: rootId, + }; + + let msg = filterEmDashForCommand(message); + + let cmdLength = msg.indexOf(' '); + if (cmdLength < 0) { + cmdLength = msg.length; + } + + const cmd = msg.substring(0, cmdLength).toLowerCase(); + msg = cmd + msg.substring(cmdLength); + + let data; + try { + data = await client.executeCommand(msg, args); + } catch (error) { + return {error: error as ClientErrorProps}; + } + + if (data?.trigger_id) { //eslint-disable-line camelcase + operator.handleSystem({ + systems: [{id: SYSTEM_IDENTIFIERS.INTEGRATION_TRIGGER_ID, value: data.trigger_id}], + prepareRecordsOnly: false, + }); + } + + return {data}; +}; + +// TODO https://mattermost.atlassian.net/browse/MM-41234 +// const executeAppCommand = (serverUrl: string, intl: IntlShape, parser: any) => { +// const {call, errorMessage} = await parser.composeCallFromCommand(msg); +// const createErrorMessage = (errMessage: string) => { +// return {error: {message: errMessage}}; +// }; + +// if (!call) { +// return createErrorMessage(errorMessage!); +// } + +// const res = await dispatch(doAppCall(call, AppCallTypes.SUBMIT, intl)); +// if (res.error) { +// const errorResponse = res.error as AppCallResponse; +// return createErrorMessage(errorResponse.error || intl.formatMessage({ +// id: 'apps.error.unknown', +// defaultMessage: 'Unknown error.', +// })); +// } +// const callResp = res.data as AppCallResponse; +// switch (callResp.type) { +// case AppCallResponseTypes.OK: +// if (callResp.markdown) { +// dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.markdown, args)); +// } +// return {data: {}}; +// case AppCallResponseTypes.FORM: +// case AppCallResponseTypes.NAVIGATE: +// return {data: {}}; +// default: +// return createErrorMessage(intl.formatMessage({ +// id: 'apps.error.responses.unknown_type', +// defaultMessage: 'App response type not supported. Response type: {type}.', +// }, { +// type: callResp.type, +// })); +// } +// }; + +const filterEmDashForCommand = (command: string): string => { + return command.replace(/\u2014/g, '--'); +}; + +export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, location: string) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const config = await queryConfig(operator.database); + const match = matchDeepLink(location, serverUrl, config.SiteURL); + + if (match) { + switch (match.type) { + case DeepLinkTypes.CHANNEL: { + const data = match.data as DeepLinkChannel; + switchToChannelByName(data.serverUrl, data.channelName, data.teamName, DraftUtils.errorBadChannel, intl); + break; + } + case DeepLinkTypes.PERMALINK: { + const data = match.data as DeepLinkPermalink; + showPermalink(serverUrl, data.teamName, data.postId, intl); + break; + } + case DeepLinkTypes.DMCHANNEL: { + const data = match.data as DeepLinkDM; + if (!data.userName) { + DraftUtils.errorUnkownUser(intl); + return {data: false}; + } + + let serverDatabase = operator.database; + if (data.serverUrl !== serverUrl) { + serverDatabase = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!serverDatabase) { + return {error: `${serverUrl} database not found`}; + } + } + const user = (await queryUsersByUsername(serverDatabase, [data.userName]))?.[0]; + if (!user) { + DraftUtils.errorUnkownUser(intl); + return {data: false}; + } + + getOrCreateDirectChannel(data.serverUrl, user.id); + break; + } + case DeepLinkTypes.GROUPCHANNEL: { + const data = match.data as DeepLinkGM; + if (!data.channelId) { + DraftUtils.errorBadChannel(intl); + return {data: false}; + } + + switchToChannelById(data.serverUrl, data.channelId); + break; + } + case DeepLinkTypes.PLUGIN: { + const data = match.data as DeepLinkPlugin; + showModal('PluginInternal', data.id, {link: location}); + break; + } + } + } else { + const {formatMessage} = intl; + const onError = () => Alert.alert( + formatMessage({ + id: 'mobile.server_link.error.title', + defaultMessage: 'Link Error', + }), + formatMessage({ + id: 'mobile.server_link.error.text', + defaultMessage: 'The link could not be found on this server.', + }), + ); + + tryOpenURL(location, onError); + } + return {data: true}; +}; diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index f20e390ec..53d912141 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -5,16 +5,19 @@ import {DeviceEventEmitter} from 'react-native'; import {updateLastPostAt} from '@actions/local/channel'; -import {processPostsFetched} from '@actions/local/post'; -import {ActionType, Events, General} from '@constants'; +import {processPostsFetched, removePost} from '@actions/local/post'; +import {addRecentReaction} from '@actions/local/reactions'; +import {ActionType, Events, General, ServerErrors} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {getNeededAtMentionedUsernames} from '@helpers/api/user'; import NetworkManager from '@init/network_manager'; import {prepareMissingChannelsForAllTeams, queryAllMyChannelIds} from '@queries/servers/channel'; -import {queryRecentPostsInChannel} from '@queries/servers/post'; +import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; +import {queryPostById, queryRecentPostsInChannel} from '@queries/servers/post'; import {queryCurrentUserId, queryCurrentChannelId} from '@queries/servers/system'; import {queryAllUsers} from '@queries/servers/user'; +import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers'; import {forceLogoutIfNecessary} from './session'; @@ -33,6 +36,114 @@ type AuthorsRequest = { error?: unknown; } +export const createPost = async (serverUrl: string, post: Partial, files: FileInfo[] = []): Promise<{data?: boolean; error?: any}> => { + 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}; + } + + const currentUserId = queryCurrentUserId(operator.database); + const timestamp = Date.now(); + const pendingPostId = post.pending_post_id || `${currentUserId}:${timestamp}`; + + const existing = await queryPostById(operator.database, pendingPostId); + if (existing && !existing.props.failed) { + return {data: false}; + } + + let newPost = { + ...post, + id: '', + pending_post_id: pendingPostId, + create_at: timestamp, + update_at: timestamp, + } as Post; + + if (files.length) { + const fileIds = files.map((file) => file.id); + + newPost = { + ...newPost, + file_ids: fileIds, + }; + } + + const databasePost = { + ...newPost, + id: pendingPostId, + }; + + const initialPostModels: Model[] = []; + + const filesModels = await operator.handleFiles({files, prepareRecordsOnly: true}); + if (filesModels.length) { + initialPostModels.push(...filesModels); + } + + const postModels = await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [databasePost.id], + posts: [databasePost], + prepareRecordsOnly: true, + }); + if (postModels.length) { + initialPostModels.push(...postModels); + } + + const customEmojis = await queryAllCustomEmojis(operator.database); + const emojisInMessage = matchEmoticons(newPost.message); + const reactionModels = await addRecentReaction(serverUrl, getValidEmojis(emojisInMessage, customEmojis), true); + if (!('error' in reactionModels) && reactionModels.length) { + initialPostModels.push(...reactionModels); + } + + operator.batchRecords(initialPostModels); + + try { + const created = await client.createPost(newPost); + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [created.id], + posts: [created], + }); + newPost = created; + } catch (error: any) { + const errorPost = { + ...newPost, + id: pendingPostId, + props: { + ...newPost.props, + failed: true, + }, + update_at: Date.now(), + }; + + // If the failure was because: the root post was deleted or + // TownSquareIsReadOnly=true then remove the post + if (error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || + error.server_error_id === ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR || + error.server_error_id === ServerErrors.PLUGIN_DISMISSED_POST_ERROR + ) { + await removePost(serverUrl, databasePost); + } else { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_NEW, + order: [errorPost.id], + posts: [errorPost], + }); + } + } + + return {data: true}; +}; + export const fetchPostsForCurrentChannel = async (serverUrl: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { diff --git a/app/actions/remote/reactions.ts b/app/actions/remote/reactions.ts index b728203a9..3444df6ab 100644 --- a/app/actions/remote/reactions.ts +++ b/app/actions/remote/reactions.ts @@ -7,11 +7,13 @@ import {addRecentReaction} from '@actions/local/reactions'; import {MM_TABLES} from '@constants/database'; import DatabaseManager from '@database/manager'; import NetworkManager from '@init/network_manager'; -import {queryCurrentUserId} from '@queries/servers/system'; +import {queryRecentPostsInChannel, queryRecentPostsInThread} from '@queries/servers/post'; +import {queryCurrentChannelId, queryCurrentUserId} from '@queries/servers/system'; import {forceLogoutIfNecessary} from './session'; import type {Client} from '@client/rest'; +import type PostModel from '@typings/database/models/servers/post'; export const addReaction = async (serverUrl: string, postId: string, emojiName: string) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -41,7 +43,7 @@ export const addReaction = async (serverUrl: string, postId: string, emojiName: }); models.push(...reactions); - const recent = await addRecentReaction(serverUrl, emojiName, true); + const recent = await addRecentReaction(serverUrl, [emojiName], true); if (Array.isArray(recent)) { models.push(...recent); } @@ -93,3 +95,27 @@ export const removeReaction = async (serverUrl: string, postId: string, emojiNam return {error}; } }; + +export const handleReactionToLatestPost = async (serverUrl: string, emojiName: string, add: boolean, rootId?: string) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + let posts: PostModel[]; + if (rootId) { + posts = await queryRecentPostsInThread(operator.database, rootId); + } else { + const channelId = await queryCurrentChannelId(operator.database); + posts = await queryRecentPostsInChannel(operator.database, channelId); + } + + if (add) { + return addReaction(serverUrl, posts[0].id, emojiName); + } + return removeReaction(serverUrl, posts[0].id, emojiName); + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 0b80dc7f4..a8508f194 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -10,7 +10,7 @@ import DatabaseManager from '@database/manager'; import NetworkManager from '@init/network_manager'; import {prepareMyChannelsForTeam, queryDefaultChannelForTeam} from '@queries/servers/channel'; import {prepareCommonSystemValues, queryCurrentTeamId, queryWebSocketLastDisconnected} from '@queries/servers/system'; -import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, queryLastChannelFromTeam, queryTeamsById, syncTeamTable} from '@queries/servers/team'; +import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, queryNthLastChannelFromTeam, queryTeamsById, syncTeamTable} from '@queries/servers/team'; import {isTablet} from '@utils/helpers'; import {fetchMyChannelsForTeam, switchToChannelById} from './channel'; @@ -272,7 +272,7 @@ export const handleTeamChange = async (serverUrl: string, teamId: string) => { let channelId = ''; if (await isTablet()) { - channelId = await queryLastChannelFromTeam(database, teamId); + channelId = await queryNthLastChannelFromTeam(database, teamId); if (channelId) { await switchToChannelById(serverUrl, channelId, teamId); return; diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index dbeebedac..a6f061bdb 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -13,7 +13,7 @@ import DatabaseManager from '@database/manager'; import {queryActiveServer} from '@queries/app/servers'; import {deleteChannelMembership, prepareMyChannelsForTeam, queryChannelsById, queryCurrentChannel} from '@queries/servers/channel'; import {prepareCommonSystemValues, queryConfig, setCurrentChannelId} from '@queries/servers/system'; -import {queryLastChannelFromTeam} from '@queries/servers/team'; +import {queryNthLastChannelFromTeam} from '@queries/servers/team'; import {queryCurrentUser, queryUserById} from '@queries/servers/user'; import {dismissAllModals, popToRoot} from '@screens/navigation'; import {isTablet} from '@utils/helpers'; @@ -122,7 +122,7 @@ export async function handleUserRemovedFromChannelEvent(serverUrl: string, msg: await popToRoot(); if (await isTablet()) { - const channelToJumpTo = await queryLastChannelFromTeam(database.database, channel?.teamId); + const channelToJumpTo = await queryNthLastChannelFromTeam(database.database, channel?.teamId); if (channelToJumpTo) { const {models: switchChannelModels} = await switchToChannel(serverUrl, channelToJumpTo, '', true); if (switchChannelModels) { @@ -179,7 +179,7 @@ export async function handleChannelDeletedEvent(serverUrl: string, msg: WebSocke await popToRoot(); if (await isTablet()) { - const channelToJumpTo = await queryLastChannelFromTeam(database.database, currentChannel?.teamId); + const channelToJumpTo = await queryNthLastChannelFromTeam(database.database, currentChannel?.teamId); if (channelToJumpTo) { switchToChannel(serverUrl, channelToJumpTo); } // TODO else jump to "join a channel" screen diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 4f72ed73f..cf037974b 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -25,7 +25,7 @@ import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePrefe import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions'; import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles'; import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent} from './teams'; -import {handleUserUpdatedEvent} from './users'; +import {handleUserUpdatedEvent, handleUserTypingEvent} from './users'; import type {Model} from '@nozbe/watermelondb'; @@ -252,9 +252,8 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { // return dispatch(handleStatusChangedEvent(msg)); case WebsocketEvents.TYPING: + handleUserTypingEvent(serverUrl, msg); break; - - // return dispatch(handleUserTypingEvent(msg)); case WebsocketEvents.HELLO: break; diff --git a/app/actions/websocket/users.ts b/app/actions/websocket/users.ts index 015f54174..c89107599 100644 --- a/app/actions/websocket/users.ts +++ b/app/actions/websocket/users.ts @@ -2,15 +2,22 @@ // See LICENSE.txt for license information. import {Q} from '@nozbe/watermelondb'; +import {DeviceEventEmitter} from 'react-native'; import {updateChannelsDisplayName} from '@actions/local/channel'; -import {fetchMe} from '@actions/remote/user'; -import {General} from '@constants'; +import {fetchMe, fetchUsersByIds} from '@actions/remote/user'; +import {General, Events, Preferences} from '@constants'; import {MM_TABLES} from '@constants/database'; import DatabaseManager from '@database/manager'; -import {queryCurrentUser} from '@queries/servers/user'; +import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; +import WebsocketManager from '@init/websocket_manager'; +import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; +import {queryCommonSystemValues} from '@queries/servers/system'; +import {queryCurrentUser, queryUserById} from '@queries/servers/user'; +import {displayUsername} from '@utils/user'; import type ChannelModel from '@typings/database/models/servers/channel'; +import type UserModel from '@typings/database/models/servers/user'; const {SERVER: {CHANNEL, CHANNEL_MEMBERSHIP}} = MM_TABLES; @@ -60,3 +67,42 @@ export async function handleUserUpdatedEvent(serverUrl: string, msg: any) { } } } + +export async function handleUserTypingEvent(serverUrl: string, msg: any) { + const currentServerUrl = await DatabaseManager.getActiveServerUrl(); + if (currentServerUrl === serverUrl) { + const database = DatabaseManager.serverDatabases[serverUrl]; + if (!database) { + return; + } + + const {config, license} = await queryCommonSystemValues(database.database); + + let user: UserModel | UserProfile | undefined = await queryUserById(database.database, msg.data.user_id); + if (!user) { + const {users} = await fetchUsersByIds(serverUrl, [msg.data.user_id]); + user = users?.[0]; + } + const namePreference = await queryPreferencesByCategoryAndName(database.database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT); + const teammateDisplayNameSetting = await getTeammateNameDisplaySetting(namePreference, config, license); + const currentUser = await queryCurrentUser(database.database); + const username = displayUsername(user, currentUser?.locale, teammateDisplayNameSetting); + const data = { + channelId: msg.broadcast.channel_id, + rootId: msg.data.parent_id, + userId: msg.data.user_id, + username, + now: Date.now(), + }; + DeviceEventEmitter.emit(Events.USER_TYPING, data); + + setTimeout(() => { + DeviceEventEmitter.emit(Events.USER_STOP_TYPING, data); + }, parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds, 10)); + } +} + +export const userTyping = async (serverUrl: string, channelId: string, rootId?: string) => { + const client = WebsocketManager.getClient(serverUrl); + client?.sendUserTypingEvent(channelId, rootId); +}; diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index a9df4acf3..52f542def 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -214,6 +214,11 @@ export default class ClientBase { body: options.body, headers: this.getRequestHeaders(method), }; + if (options.noRetry) { + requestOptions.retryPolicyConfiguration = { + retryLimit: 0, + }; + } let response: ClientResponse; try { response = await request!(url, requestOptions); diff --git a/app/client/rest/files.ts b/app/client/rest/files.ts index e40986a8c..0e438244f 100644 --- a/app/client/rest/files.ts +++ b/app/client/rest/files.ts @@ -61,7 +61,7 @@ const ClientFiles = (superclass: any) => class extends superclass { onError: (response: ClientResponseError) => void, skipBytes = 0, ) => { - const url = `${this.apiClient.baseUrl}${this.getFilesRoute()}`; + const url = this.getFilesRoute(); const options: UploadRequestOptions = { skipBytes, method: 'POST', diff --git a/app/client/rest/integrations.ts b/app/client/rest/integrations.ts index 74aca38ad..a3cce391b 100644 --- a/app/client/rest/integrations.ts +++ b/app/client/rest/integrations.ts @@ -7,9 +7,9 @@ import {PER_PAGE_DEFAULT} from './constants'; export interface ClientIntegrationsMix { getCommandsList: (teamId: string) => Promise; - getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, commandArgs?: Record) => Promise; + getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, commandArgs?: CommandArgs) => Promise; getAutocompleteCommandsList: (teamId: string, page?: number, perPage?: number) => Promise; - executeCommand: (command: Command, commandArgs?: Record) => Promise; + executeCommand: (command: string, commandArgs?: CommandArgs) => Promise; addCommand: (command: Command) => Promise; submitInteractiveDialog: (data: DialogSubmission) => Promise; } @@ -36,7 +36,7 @@ const ClientIntegrations = (superclass: any) => class extends superclass { ); }; - executeCommand = async (command: Command, commandArgs = {}) => { + executeCommand = async (command: string, commandArgs = {}) => { this.analytics.trackAPI('api_integrations_used'); return this.doFetch( diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index 158bbfaf4..44a777286 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -41,7 +41,7 @@ const ClientPosts = (superclass: any) => class extends superclass { return this.doFetch( `${this.getPostsRoute()}`, - {method: 'post', body: post}, + {method: 'post', body: post, noRetry: true}, ); }; diff --git a/app/client/websocket/index.ts b/app/client/websocket/index.ts index 26d2c2e6b..26e0bea26 100644 --- a/app/client/websocket/index.ts +++ b/app/client/websocket/index.ts @@ -326,7 +326,7 @@ export default class WebSocketClient { } } - public sendUserTypingEvent(channelId: string, parentId: string) { + public sendUserTypingEvent(channelId: string, parentId?: string) { this.sendMessage('user_typing', { channel_id: channelId, parent_id: parentId, diff --git a/app/components/error_text/error_text.test.tsx b/app/components/error_text/error_text.test.tsx index 9cdcf6e28..011d4f8c0 100644 --- a/app/components/error_text/error_text.test.tsx +++ b/app/components/error_text/error_text.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import {Preferences} from '@constants'; -import ErrorText from './index'; +import ErrorTextComponent from './index'; describe('ErrorText', () => { const baseProps = { @@ -21,7 +21,7 @@ describe('ErrorText', () => { test('should match snapshot', () => { const wrapper = render( - , + , ); expect(wrapper.toJSON()).toMatchSnapshot(); diff --git a/app/components/error_text/index.tsx b/app/components/error_text/index.tsx index d3e8aaa76..dc5363c26 100644 --- a/app/components/error_text/index.tsx +++ b/app/components/error_text/index.tsx @@ -7,16 +7,14 @@ import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native'; import FormattedText from '@components/formatted_text'; import {makeStyleSheetFromTheme} from '@utils/theme'; -import type {ErrorText as ErrorType} from '@typings/utils/file'; - type ErrorProps = { - error: ErrorType; + error: ErrorText; testID?: string; textStyle?: StyleProp | StyleProp; theme: Theme; } -const ErrorText = ({error, testID, textStyle, theme}: ErrorProps) => { +const ErrorTextComponent = ({error, testID, textStyle, theme}: ErrorProps) => { const style = getStyleSheet(theme); const message = typeof (error) === 'string' ? error : error.message; @@ -55,4 +53,4 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -export default ErrorText; +export default ErrorTextComponent; diff --git a/app/components/post_draft/archived/index.tsx b/app/components/post_draft/archived/index.tsx new file mode 100644 index 000000000..375121c3f --- /dev/null +++ b/app/components/post_draft/archived/index.tsx @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import Button from 'react-native-button'; + +import {switchToPenultimateChannel} from '@actions/remote/channel'; +import FormattedMarkdownText from '@components/formatted_markdown_text'; +import FormattedText from '@components/formatted_text'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {t} from '@i18n'; +import {popToRoot} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + testID?: string; + deactivated?: boolean; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + archivedWrapper: { + paddingHorizontal: 20, + paddingVertical: 10, + borderTopWidth: 1, + backgroundColor: theme.centerChannelBg, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), + }, + archivedText: { + textAlign: 'center', + color: theme.centerChannelColor, + }, + closeButton: { + backgroundColor: theme.buttonBg, + alignItems: 'center', + paddingVertical: 5, + borderRadius: 4, + marginTop: 10, + height: 40, + }, + closeButtonText: { + marginTop: 7, + color: 'white', + fontWeight: 'bold', + }, +})); + +export default function Archived({ + testID, + deactivated, +}: Props) { + const theme = useTheme(); + const style = getStyleSheet(theme); + const isTablet = useIsTablet(); + const serverUrl = useServerUrl(); + + const onCloseChannelPress = useCallback(() => { + if (isTablet) { + switchToPenultimateChannel(serverUrl); + } else { + popToRoot(); + } + }, [serverUrl, isTablet]); + + let message = { + id: t('archivedChannelMessage'), + defaultMessage: 'You are viewing an **archived channel**. New messages cannot be posted.', + }; + + if (deactivated) { + // only applies to DM's when the user was deactivated + message = { + id: t('create_post.deactivated'), + defaultMessage: 'You are viewing an archived channel with a deactivated user.', + }; + } + + return ( + + + + + ); +} diff --git a/app/components/post_draft/cursor_position_handler/index.tsx b/app/components/post_draft/cursor_position_handler/index.tsx new file mode 100644 index 000000000..08c09a957 --- /dev/null +++ b/app/components/post_draft/cursor_position_handler/index.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; + +import DraftInput from '../draft_input'; + +type Props = { + testID?: string; + channelId: string; + rootId: string; + + // Send Handler + sendMessage: () => void; + maxMessageLength: number; + canSend: boolean; + + // Draft Handler + value: string; + uploadFileError: React.ReactNode; + files: FileInfo[]; + clearDraft: () => void; + updateValue: (value: string) => void; + addFiles: (files: FileInfo[]) => void; +} + +export default function CursorPositionHandler(props: Props) { + const [pos, setCursorPosition] = useState(0); + + return ( + + ); +} diff --git a/app/components/post_draft/draft_handler/draft_handler.tsx b/app/components/post_draft/draft_handler/draft_handler.tsx new file mode 100644 index 000000000..c3a1e4b0a --- /dev/null +++ b/app/components/post_draft/draft_handler/draft_handler.tsx @@ -0,0 +1,139 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import {addFilesToDraft, removeDraft} from '@actions/local/draft'; +import {useServerUrl} from '@context/server'; +import DraftUploadManager from '@init/draft_upload_manager'; +import {fileMaxWarning, fileSizeWarning, uploadDisabledWarning} from '@utils/file'; + +import SendHandler from '../send_handler'; + +type Props = { + testID?: string; + channelId: string; + rootId?: string; + files?: FileInfo[]; + message?: string; + maxFileSize: number; + maxFileCount: number; + canUploadFiles: boolean; +} + +const emptyFileList: FileInfo[] = []; +const UPLOAD_ERROR_SHOW_INTERVAL = 5000; + +type ErrorHandlers = { + [clientId: string]: (() => void) | null; +} + +export default function DraftHandler(props: Props) { + const { + testID, + channelId, + rootId = '', + files, + message, + maxFileSize, + maxFileCount, + canUploadFiles, + } = props; + + const serverUrl = useServerUrl(); + const intl = useIntl(); + + const [currentValue, setCurrentValue] = useState(message || ''); + const [uploadError, setUploadError] = useState(null); + + const uploadErrorTimeout = useRef(); + const uploadErrorHandlers = useRef({}); + + const clearDraft = useCallback(() => { + removeDraft(serverUrl, channelId, rootId); + setCurrentValue(''); + }, [serverUrl, channelId, rootId]); + + const newUploadError = useCallback((error: React.ReactNode) => { + if (uploadErrorTimeout.current) { + clearTimeout(uploadErrorTimeout.current); + } + setUploadError(error); + + uploadErrorTimeout.current = setTimeout(() => { + setUploadError(null); + }, UPLOAD_ERROR_SHOW_INTERVAL); + }, []); + + const addFiles = useCallback((newFiles: FileInfo[]) => { + if (!newFiles.length) { + return; + } + + if (!canUploadFiles) { + newUploadError(uploadDisabledWarning(intl)); + return; + } + + const currentFileCount = files?.length || 0; + const availableCount = maxFileCount - currentFileCount; + if (newFiles.length > availableCount) { + newUploadError(fileMaxWarning(intl, maxFileCount)); + return; + } + + const largeFile = newFiles.find((file) => file.size > maxFileSize); + if (largeFile) { + newUploadError(fileSizeWarning(intl, maxFileSize)); + return; + } + + addFilesToDraft(serverUrl, channelId, rootId, newFiles); + + for (const file of newFiles) { + DraftUploadManager.prepareUpload(serverUrl, file, channelId, rootId); + uploadErrorHandlers.current[file.clientId!] = DraftUploadManager.registerErrorHandler(file.clientId!, newUploadError); + } + + newUploadError(null); + }, [intl, newUploadError, maxFileCount, maxFileSize, serverUrl, files?.length, channelId, rootId]); + + // This effect mainly handles keeping clean the uploadErrorHandlers, and + // reinstantiate them on component mount and file retry. + useEffect(() => { + let loadingFiles: FileInfo[] = []; + if (files) { + loadingFiles = files.filter((v) => v.clientId && DraftUploadManager.isUploading(v.clientId)); + } + + for (const key of Object.keys(uploadErrorHandlers.current)) { + if (!loadingFiles.find((v) => v.clientId === key)) { + uploadErrorHandlers.current[key]?.(); + delete (uploadErrorHandlers.current[key]); + } + } + + for (const file of loadingFiles) { + if (!uploadErrorHandlers.current[file.clientId!]) { + uploadErrorHandlers.current[file.clientId!] = DraftUploadManager.registerErrorHandler(file.clientId!, newUploadError); + } + } + }, [files]); + + return ( + + ); +} diff --git a/app/components/post_draft/draft_handler/index.ts b/app/components/post_draft/draft_handler/index.ts new file mode 100644 index 000000000..3de779cf2 --- /dev/null +++ b/app/components/post_draft/draft_handler/index.ts @@ -0,0 +1,68 @@ +// 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 {combineLatest, of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {DEFAULT_SERVER_MAX_FILE_SIZE} from '@constants/post_draft'; +import {isMinimumServerVersion} from '@utils/helpers'; + +import DraftHandler from './draft_handler'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type DraftModel from '@typings/database/models/servers/draft'; +import type SystemModel from '@typings/database/models/servers/system'; + +const {SERVER: {SYSTEM, DRAFT}} = MM_TABLES; + +type OwnProps = { + channelId: string; + rootId?: string; +} +const enhanced = withObservables([], ({database, channelId, rootId = ''}: WithDatabaseArgs & OwnProps) => { + const draft = database.get(DRAFT).query( + Q.where('channel_id', channelId), + Q.where('root_id', rootId), + ).observeWithColumns(['message', 'files']).pipe(switchMap((v) => of$(v[0]))); + + const files = draft.pipe(switchMap((d) => of$(d?.files))); + const message = draft.pipe(switchMap((d) => of$(d?.message))); + + const config = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap(({value}) => of$(value as ClientConfig)), + ); + + const license = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe( + switchMap(({value}) => of$(value as ClientLicense)), + ); + + const canUploadFiles = combineLatest([config, license]).pipe( + switchMap(([c, l]) => of$( + c.EnableFileAttachments !== 'false' && + (l.IsLicensed === 'false' || l.Compliance === 'false' || c.EnableMobileFileUpload !== 'false'), + ), + ), + ); + + const maxFileSize = config.pipe( + switchMap((cfg) => of$(parseInt(cfg.MaxFileSize || '0', 10) || DEFAULT_SERVER_MAX_FILE_SIZE)), + ); + + const maxFileCount = config.pipe( + switchMap((cfg) => of$(isMinimumServerVersion(cfg.Version, 6, 0) ? 10 : 5)), + ); + + return { + files, + message, + maxFileSize, + maxFileCount, + canUploadFiles, + }; +}); + +export default withDatabase(enhanced(DraftHandler)); diff --git a/app/components/post_draft/draft_input/index.tsx b/app/components/post_draft/draft_input/index.tsx new file mode 100644 index 000000000..bdc221557 --- /dev/null +++ b/app/components/post_draft/draft_input/index.tsx @@ -0,0 +1,174 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Platform, ScrollView, View} from 'react-native'; +import {Edge, SafeAreaView} from 'react-native-safe-area-context'; + +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import PostInput from '../post_input'; +import QuickActions from '../quick_actions'; +import SendAction from '../send_action'; +import Typing from '../typing'; +import Uploads from '../uploads'; + +type Props = { + testID?: string; + channelId: string; + rootId?: string; + + // Cursor Position Handler + updateCursorPosition: (pos: number) => void; + cursorPosition: number; + + // Send Handler + sendMessage: () => void; + canSend: boolean; + maxMessageLength: number; + + // Draft Handler + files: FileInfo[]; + value: string; + uploadFileError: React.ReactNode; + updateValue: (value: string) => void; + addFiles: (files: FileInfo[]) => void; +} + +const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right']; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + actionsContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: Platform.select({ + ios: 1, + android: 2, + }), + }, + inputContainer: { + flex: 1, + flexDirection: 'column', + }, + inputContentContainer: { + alignItems: 'stretch', + paddingTop: Platform.select({ + ios: 7, + android: 0, + }), + }, + inputWrapper: { + alignItems: 'flex-end', + flexDirection: 'row', + justifyContent: 'center', + paddingBottom: 2, + backgroundColor: theme.centerChannelBg, + borderTopWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), + }, + }; +}); + +export default function DraftInput({ + testID, + channelId, + files, + maxMessageLength, + rootId = '', + value, + uploadFileError, + sendMessage, + canSend, + updateValue, + addFiles, + updateCursorPosition, + cursorPosition, +}: Props) { + const theme = useTheme(); + + // const [top, setTop] = useState(0); + + // const handleLayout = useCallback((e: LayoutChangeEvent) => { + // setTop(e.nativeEvent.layout.y); + // }, []); + + // Render + const postInputTestID = `${testID}.post.input`; + const quickActionsTestID = `${testID}.quick_actions`; + const sendActionTestID = `${testID}.send_action`; + const style = getStyleSheet(theme); + + return ( + <> + + {/* {Platform.OS === 'android' && + + } */} + + + + + + + + + + + + ); +} diff --git a/app/components/post_draft/index.ts b/app/components/post_draft/index.ts new file mode 100644 index 000000000..c2f745e01 --- /dev/null +++ b/app/components/post_draft/index.ts @@ -0,0 +1,81 @@ +// 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 {combineLatest, of as of$, from as from$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {General, Permissions} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {hasPermissionForChannel} from '@utils/role'; +import {isSystemAdmin, getUserIdFromChannelName} from '@utils/user'; + +import PostDraft from './post_draft'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +const {SERVER: {SYSTEM, USER, CHANNEL}} = MM_TABLES; + +type OwnProps = { + channelId?: string; + channelIsArchived?: boolean; +} + +const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => { + const database = ownProps.database; + const currentUser = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap(({value}) => database.get(USER).findAndObserve(value)), + ); + + let channelId = of$(ownProps.channelId); + if (!ownProps.channelId) { + channelId = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe( + switchMap((t) => of$(t.value)), + ); + } + + const channel = channelId.pipe( + switchMap((id) => database.get(CHANNEL).findAndObserve(id!)), + ); + + const canPost = combineLatest([channel, currentUser]).pipe(switchMap(([c, u]) => from$(hasPermissionForChannel(c, u, Permissions.CREATE_POST, false)))); + let channelIsArchived = of$(ownProps.channelIsArchived); + if (!channelIsArchived) { + channelIsArchived = channel.pipe(switchMap((c) => of$(c.deleteAt !== 0))); + } + + const experimentalTownSquareIsReadOnly = database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap(({value}: {value: ClientConfig}) => of$(value.ExperimentalTownSquareIsReadOnly === 'true')), + ); + const channelIsReadOnly = combineLatest([currentUser, channel, experimentalTownSquareIsReadOnly]).pipe( + switchMap(([u, c, readOnly]) => of$(c?.name === General.DEFAULT_CHANNEL && !isSystemAdmin(u.roles) && readOnly)), + ); + + const deactivatedChannel = combineLatest([currentUser, channel]).pipe( + switchMap(([u, c]) => { + if (c.type !== General.DM_CHANNEL) { + return of$(false); + } + const teammateId = getUserIdFromChannelName(u.id, c.name); + if (teammateId) { + return database.get(USER).findAndObserve(teammateId).pipe( + switchMap((u2) => of$(Boolean(u2.deleteAt))), // eslint-disable-line max-nested-callbacks + ); + } + return of$(true); + }), + ); + + return { + canPost, + channelIsArchived, + channelIsReadOnly, + deactivatedChannel, + }; +}); + +export default withDatabase(enhanced(PostDraft)); diff --git a/app/components/post_draft/post_draft.tsx b/app/components/post_draft/post_draft.tsx new file mode 100644 index 000000000..4b927b1af --- /dev/null +++ b/app/components/post_draft/post_draft.tsx @@ -0,0 +1,107 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef} from 'react'; +import {DeviceEventEmitter, Platform} from 'react-native'; +import {KeyboardTrackingView, KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view'; + +import {PostDraft as PostDraftConstants, View as ViewConstants} from '@constants'; +import {useIsTablet} from '@hooks/device'; + +import Archived from './archived'; +import DraftHandler from './draft_handler'; +import ReadOnly from './read_only'; + +type Props = { + testID?: string; + accessoriesContainerID?: string; + canPost: boolean; + channelId: string; + channelIsArchived?: boolean; + channelIsReadOnly: boolean; + deactivatedChannel: boolean; + rootId?: string; + scrollViewNativeID?: string; +} + +export default function PostDraft({ + testID, + accessoriesContainerID, + canPost, + channelId, + channelIsArchived, + channelIsReadOnly, + deactivatedChannel, + rootId, + scrollViewNativeID, +}: Props) { + const keyboardTracker = useRef(null); + const resetScrollViewAnimationFrame = useRef(); + const isTablet = useIsTablet(); + + const updateNativeScrollView = useCallback((scrollViewNativeIDToUpdate: string) => { + if (keyboardTracker?.current && scrollViewNativeID === scrollViewNativeIDToUpdate) { + resetScrollViewAnimationFrame.current = requestAnimationFrame(() => { + keyboardTracker.current?.resetScrollView(scrollViewNativeIDToUpdate); + if (resetScrollViewAnimationFrame.current != null) { + cancelAnimationFrame(resetScrollViewAnimationFrame.current); + } + resetScrollViewAnimationFrame.current = undefined; + }); + } + }, [scrollViewNativeID]); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(PostDraftConstants.UPDATE_NATIVE_SCROLLVIEW, updateNativeScrollView); + return () => { + listener.remove(); + if (resetScrollViewAnimationFrame.current) { + cancelAnimationFrame(resetScrollViewAnimationFrame.current); + } + }; + }, [updateNativeScrollView]); + + if (channelIsArchived || deactivatedChannel) { + const archivedTestID = `${testID}.archived`; + + return ( + + ); + } + + if (channelIsReadOnly || !canPost) { + const readOnlyTestID = `${testID}.read_only`; + + return ( + + ); + } + + const draftHandler = ( + + ); + + if (Platform.OS === 'android') { + return draftHandler; + } + + return ( + + {draftHandler} + + ); +} diff --git a/app/components/post_draft/post_input/index.ts b/app/components/post_draft/post_input/index.ts new file mode 100644 index 000000000..42e6b63d6 --- /dev/null +++ b/app/components/post_draft/post_input/index.ts @@ -0,0 +1,67 @@ +// 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 {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; + +import PostInput from './post_input'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; +import type SystemModel from '@typings/database/models/servers/system'; + +const {SERVER: {SYSTEM, CHANNEL}} = MM_TABLES; + +type OwnProps = { + channelId: string; + rootId?: string; +} + +const enhanced = withObservables([], ({database, channelId, rootId}: WithDatabaseArgs & OwnProps) => { + const config = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG); + const timeBetweenUserTypingUpdatesMilliseconds = config.pipe( + switchMap(({value}: {value: ClientConfig}) => of$(parseInt(value.TimeBetweenUserTypingUpdatesMilliseconds, 10))), + ); + + const enableUserTypingMessage = config.pipe( + switchMap(({value}: {value: ClientConfig}) => of$(value.EnableUserTypingMessages === 'true')), + ); + + const maxNotificationsPerChannel = config.pipe( + switchMap(({value}: {value: ClientConfig}) => of$(parseInt(value.MaxNotificationsPerChannel, 10))), + ); + + let channel; + if (rootId) { + channel = database.get(CHANNEL).findAndObserve(channelId); + } else { + channel = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe( + switchMap((t) => database.get(CHANNEL).findAndObserve(t.value)), + ); + } + + const channelDisplayName = channel.pipe( + switchMap((c) => of$(c.displayName)), + ); + + const channelInfo = channel.pipe(switchMap((c) => c.info.observe())); + + const membersInChannel = channelInfo.pipe( + switchMap((i: ChannelInfoModel) => of$(i.memberCount)), + ); + + return { + timeBetweenUserTypingUpdatesMilliseconds, + enableUserTypingMessage, + maxNotificationsPerChannel, + channelDisplayName, + membersInChannel, + }; +}); + +export default withDatabase(enhanced(PostInput)); diff --git a/app/components/post_draft/post_input/post_input.tsx b/app/components/post_draft/post_input/post_input.tsx new file mode 100644 index 000000000..0489dbdf7 --- /dev/null +++ b/app/components/post_draft/post_input/post_input.tsx @@ -0,0 +1,319 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useManagedConfig} from '@mattermost/react-native-emm'; +import PasteableTextInput, {PastedFile, PasteInputRef} from '@mattermost/react-native-paste-input'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {IntlShape, useIntl} from 'react-intl'; +import { + Alert, AppState, AppStateStatus, EmitterSubscription, Keyboard, + KeyboardTypeOptions, NativeSyntheticEvent, Platform, TextInputSelectionChangeEventData, +} from 'react-native'; +import HWKeyboardEvent from 'react-native-hw-keyboard-event'; + +import {updateDraftMessage} from '@actions/local/draft'; +import {userTyping} from '@actions/websocket/users'; +import {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import useDidUpdate from '@hooks/did_update'; +import {t} from '@i18n'; +import {extractFileInfo} from '@utils/file'; +import {switchKeyboardForCodeBlocks} from '@utils/markdown'; +import {changeOpacity, makeStyleSheetFromTheme, getKeyboardAppearanceFromTheme} from '@utils/theme'; + +const HW_EVENT_IN_SCREEN = ['Channel', 'Thread']; + +type Props = { + testID?: string; + channelDisplayName?: string; + channelId: string; + maxMessageLength: number; + rootId: string; + timeBetweenUserTypingUpdatesMilliseconds: number; + maxNotificationsPerChannel: number; + enableUserTypingMessage: boolean; + membersInChannel: number; + value: string; + updateValue: (value: string) => void; + addFiles: (files: ExtractedFileInfo[]) => void; + cursorPosition: number; + updateCursorPosition: (pos: number) => void; + sendMessage: () => void; +} + +const showPasteFilesErrorDialog = (intl: IntlShape) => { + Alert.alert( + intl.formatMessage({ + id: 'mobile.files_paste.error_title', + defaultMessage: 'Paste failed', + }), + intl.formatMessage({ + id: 'mobile.files_paste.error_description', + defaultMessage: 'An error occurred while pasting the file(s). Please try again.', + }), + [ + { + text: intl.formatMessage({ + id: 'mobile.files_paste.error_dismiss', + defaultMessage: 'Dismiss', + }), + }, + ], + ); +}; + +const getPlaceHolder = (rootId?: string) => { + let placeholder; + + if (rootId) { + placeholder = {id: t('create_comment.addComment'), defaultMessage: 'Add a comment...'}; + } else { + placeholder = {id: t('create_post.write'), defaultMessage: 'Write to {channelDisplayName}'}; + } + + return placeholder; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + input: { + color: theme.centerChannelColor, + fontSize: 15, + lineHeight: 20, + paddingHorizontal: 12, + paddingTop: Platform.select({ + ios: 6, + android: 8, + }), + paddingBottom: Platform.select({ + ios: 6, + android: 2, + }), + minHeight: 30, + }, +})); + +export default function PostInput({ + testID, + channelDisplayName, + channelId, + maxMessageLength, + rootId, + timeBetweenUserTypingUpdatesMilliseconds, + maxNotificationsPerChannel, + enableUserTypingMessage, + membersInChannel, + value, + updateValue, + addFiles, + cursorPosition, + updateCursorPosition, + sendMessage, +}: Props) { + const intl = useIntl(); + const isTablet = useIsTablet(); + const theme = useTheme(); + const style = getStyleSheet(theme); + const serverUrl = useServerUrl(); + const managedConfig = useManagedConfig(); + + const lastTypingEventSent = useRef(0); + const input = useRef(); + const lastNativeValue = useRef(''); + const previousAppState = useRef(AppState.currentState); + + const [keyboardType, setKeyboardType] = useState('default'); + const [longMessageAlertShown, setLongMessageAlertShown] = useState(false); + + const disableCopyAndPaste = managedConfig.copyAndPasteProtection === 'true'; + const maxHeight = isTablet ? 150 : 88; + const pasteInputStyle = useMemo(() => { + return {...style.input, maxHeight}; + }, [maxHeight]); + + const blur = () => { + input.current?.blur(); + }; + + const handleAndroidKeyboard = () => { + blur(); + }; + + const onBlur = useCallback(() => { + updateDraftMessage(serverUrl, channelId, rootId, value); + }, [channelId, rootId, value]); + + const checkMessageLength = useCallback((newValue: string) => { + const valueLength = newValue.trim().length; + + if (valueLength > maxMessageLength) { + // Check if component is already aware message is too long + if (!longMessageAlertShown) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.message_length.title', + defaultMessage: 'Message Length', + }), + intl.formatMessage({ + id: 'mobile.message_length.message', + defaultMessage: 'Your current message is too long. Current character count: {count}/{max}', + }, { + max: maxMessageLength, + count: valueLength, + }), + ); + setLongMessageAlertShown(true); + } + } else if (longMessageAlertShown) { + setLongMessageAlertShown(false); + } + }, [intl, longMessageAlertShown, maxMessageLength]); + + const handlePostDraftSelectionChanged = useCallback((event: NativeSyntheticEvent | null, fromHandleTextChange = false) => { + const cp = fromHandleTextChange ? cursorPosition : event!.nativeEvent.selection.end; + + if (Platform.OS === 'ios') { + const newKeyboardType = switchKeyboardForCodeBlocks(value, cp); + setKeyboardType(newKeyboardType); + } + + updateCursorPosition(cp); + }, [updateCursorPosition, cursorPosition]); + + const handleTextChange = useCallback((newValue: string) => { + updateValue(newValue); + lastNativeValue.current = newValue; + + checkMessageLength(newValue); + + // Workaround to avoid iOS emdash autocorrect in Code Blocks + if (Platform.OS === 'ios') { + handlePostDraftSelectionChanged(null, true); + } + + if ( + newValue && + lastTypingEventSent.current + timeBetweenUserTypingUpdatesMilliseconds < Date.now() && + membersInChannel < maxNotificationsPerChannel && + enableUserTypingMessage + ) { + userTyping(serverUrl, channelId, rootId); + lastTypingEventSent.current = Date.now(); + } + }, [ + updateValue, + checkMessageLength, + handlePostDraftSelectionChanged, + timeBetweenUserTypingUpdatesMilliseconds, + channelId, + rootId, + (membersInChannel < maxNotificationsPerChannel) && enableUserTypingMessage, + ]); + + const onPaste = useCallback(async (error: string | null | undefined, files: PastedFile[]) => { + if (error) { + showPasteFilesErrorDialog(intl); + } + + addFiles(await extractFileInfo(files)); + }, [addFiles, intl]); + + const handleHardwareEnterPress = useCallback((keyEvent: {pressedKey: string}) => { + if (HW_EVENT_IN_SCREEN.includes(rootId ? Screens.THREAD : Screens.CHANNEL)) { + switch (keyEvent.pressedKey) { + case 'enter': + sendMessage(); + break; + case 'shift-enter': + updateValue(value.substring(0, cursorPosition) + '\n' + value.substring(cursorPosition)); + updateCursorPosition(cursorPosition + 1); + break; + } + } + }, [sendMessage, updateValue, value, cursorPosition]); + + const onAppStateChange = useCallback((appState: AppStateStatus) => { + if (appState !== 'active' && previousAppState.current === 'active') { + updateDraftMessage(serverUrl, channelId, rootId, value); + } + + previousAppState.current = appState; + }, [serverUrl, channelId, rootId, value]); + + useEffect(() => { + let keyboardListener: EmitterSubscription | undefined; + if (Platform.OS === 'android') { + keyboardListener = Keyboard.addListener('keyboardDidHide', handleAndroidKeyboard); + } + + return (() => { + keyboardListener?.remove(); + }); + }, []); + + useEffect(() => { + const listener = AppState.addEventListener('change', onAppStateChange); + + return () => { + listener.remove(); + }; + }, [onAppStateChange]); + + useEffect(() => { + if (value !== lastNativeValue.current) { + // May change when we implement Fabric + input.current?.setNativeProps({ + text: value, + selection: {start: cursorPosition}, + }); + lastNativeValue.current = value; + } + }, [value]); + + useEffect(() => { + HWKeyboardEvent.onHWKeyPressed(handleHardwareEnterPress); + return () => { + HWKeyboardEvent.removeOnHWKeyPressed(); + }; + }, [handleHardwareEnterPress]); + + useDidUpdate(() => { + if (!value) { + if (Platform.OS === 'android') { + // Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages + // are typed successively without blurring the input + setKeyboardType('email-address'); + } + } + }, [value]); + + useDidUpdate(() => { + if (Platform.OS === 'android' && keyboardType === 'email-address') { + setKeyboardType('default'); + } + }, [keyboardType]); + + return ( + + ); +} diff --git a/app/components/post_draft/quick_actions/camera_quick_action/camera_type.tsx b/app/components/post_draft/quick_actions/camera_quick_action/camera_type.tsx new file mode 100644 index 000000000..c701002db --- /dev/null +++ b/app/components/post_draft/quick_actions/camera_quick_action/camera_type.tsx @@ -0,0 +1,135 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View, DeviceEventEmitter} from 'react-native'; +import {CameraOptions} from 'react-native-image-picker'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Navigation} from '@constants'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + onPress: (options: CameraOptions) => void; +} + +const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({ + center: { + alignItems: 'center', + }, + container: { + alignItems: 'center', + backgroundColor: theme.centerChannelBg, + height: 200, + paddingVertical: 10, + }, + flex: { + flex: 1, + }, + options: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + justifyContent: 'space-evenly', + width: '100%', + marginBottom: 50, + }, + optionContainer: { + alignItems: 'flex-start', + }, + title: { + color: theme.centerChannelColor, + fontSize: 18, + fontWeight: 'bold', + }, + text: { + color: theme.centerChannelColor, + fontSize: 15, + }, +})); + +const CameraType = ({onPress}: Props) => { + const theme = useTheme(); + const isTablet = useIsTablet(); + const style = getStyle(theme); + + const onPhoto = () => { + const options: CameraOptions = { + quality: 0.8, + mediaType: 'photo', + saveToPhotos: true, + }; + + onPress(options); + DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL); + }; + + const onVideo = () => { + const options: CameraOptions = { + videoQuality: 'high', + mediaType: 'video', + saveToPhotos: true, + }; + + onPress(options); + DeviceEventEmitter.emit(Navigation.NAVIGATION_CLOSE_MODAL); + }; + + return ( + + {!isTablet && + + } + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CameraType; diff --git a/app/components/post_draft/quick_actions/camera_quick_action/index.tsx b/app/components/post_draft/quick_actions/camera_quick_action/index.tsx new file mode 100644 index 000000000..2579bb6ad --- /dev/null +++ b/app/components/post_draft/quick_actions/camera_quick_action/index.tsx @@ -0,0 +1,94 @@ +// 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 {Alert, StyleSheet} from 'react-native'; +import {CameraOptions} from 'react-native-image-picker'; + +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {ICON_SIZE} from '@constants/post_draft'; +import {useTheme} from '@context/theme'; +import {bottomSheet} from '@screens/navigation'; +import {fileMaxWarning} from '@utils/file'; +import PickerUtil from '@utils/file/file_picker'; +import {changeOpacity} from '@utils/theme'; + +import CameraType from './camera_type'; + +import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action'; + +const style = StyleSheet.create({ + icon: { + alignItems: 'center', + justifyContent: 'center', + padding: 10, + }, +}); + +export default function CameraQuickAction({ + disabled, + onUploadFiles, + maxFilesReached, + maxFileCount, + testID, +}: QuickActionAttachmentProps) { + const intl = useIntl(); + const theme = useTheme(); + + const handleButtonPress = useCallback((options: CameraOptions) => { + const picker = new PickerUtil(intl, + onUploadFiles); + + picker.attachFileFromCamera(options); + }, [intl, onUploadFiles]); + + const renderContent = useCallback(() => { + return ( + + ); + }, [handleButtonPress]); + + const openSelectorModal = useCallback(() => { + if (maxFilesReached) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.link.error.title', + defaultMessage: 'Error', + }), + fileMaxWarning(intl, maxFileCount), + ); + return; + } + + bottomSheet({ + title: intl.formatMessage({id: 'camera_type.title', defaultMessage: 'Choose an action'}), + renderContent, + snapPoints: [200, 10], + theme, + closeButtonId: 'camera-close-id', + }); + }, [intl, theme, renderContent, maxFilesReached, maxFileCount]); + + const actionTestID = disabled ? `${testID}.disabled` : testID; + const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64); + + return ( + + + + ); +} diff --git a/app/components/post_draft/quick_actions/file_quick_action/index.tsx b/app/components/post_draft/quick_actions/file_quick_action/index.tsx new file mode 100644 index 000000000..1bea0b5bf --- /dev/null +++ b/app/components/post_draft/quick_actions/file_quick_action/index.tsx @@ -0,0 +1,72 @@ +// 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 {Alert, StyleSheet} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {ICON_SIZE} from '@constants/post_draft'; +import {useTheme} from '@context/theme'; +import {fileMaxWarning} from '@utils/file'; +import PickerUtil from '@utils/file/file_picker'; +import {changeOpacity} from '@utils/theme'; + +import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action'; + +const style = StyleSheet.create({ + icon: { + alignItems: 'center', + justifyContent: 'center', + padding: 10, + }, +}); + +export default function FileQuickAction({ + disabled, + onUploadFiles, + maxFilesReached, + maxFileCount, + testID = '', +}: QuickActionAttachmentProps) { + const intl = useIntl(); + const theme = useTheme(); + + const handleButtonPress = useCallback(() => { + if (maxFilesReached) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.link.error.title', + defaultMessage: 'Error', + }), + fileMaxWarning(intl, maxFileCount), + ); + return; + } + const picker = new PickerUtil(intl, + onUploadFiles); + + picker.attachFileFromFiles(); + }, [onUploadFiles]); + + const actionTestID = disabled ? `${testID}.disabled` : testID; + const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64); + + return ( + + + + ); +} + diff --git a/app/components/post_draft/quick_actions/image_quick_action/index.tsx b/app/components/post_draft/quick_actions/image_quick_action/index.tsx new file mode 100644 index 000000000..75c8e3e68 --- /dev/null +++ b/app/components/post_draft/quick_actions/image_quick_action/index.tsx @@ -0,0 +1,74 @@ +// 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 {Alert, StyleSheet} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {ICON_SIZE} from '@constants/post_draft'; +import {useTheme} from '@context/theme'; +import {fileMaxWarning} from '@utils/file'; +import PickerUtil from '@utils/file/file_picker'; +import {changeOpacity} from '@utils/theme'; + +import type {QuickActionAttachmentProps} from '@typings/components/post_draft_quick_action'; + +const style = StyleSheet.create({ + icon: { + alignItems: 'center', + justifyContent: 'center', + padding: 10, + }, +}); + +export default function ImageQuickAction({ + disabled, + fileCount = 0, + onUploadFiles, + maxFilesReached, + maxFileCount, + testID = '', +}: QuickActionAttachmentProps) { + const intl = useIntl(); + const theme = useTheme(); + + const handleButtonPress = useCallback(() => { + if (maxFilesReached) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.link.error.title', + defaultMessage: 'Error', + }), + fileMaxWarning(intl, maxFileCount), + ); + return; + } + + const picker = new PickerUtil(intl, + onUploadFiles); + + picker.attachFileFromPhotoGallery(maxFileCount - fileCount); + }, [onUploadFiles, fileCount, maxFileCount]); + + const actionTestID = disabled ? `${testID}.disabled` : testID; + const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64); + + return ( + + + + ); +} + diff --git a/app/components/post_draft/quick_actions/index.ts b/app/components/post_draft/quick_actions/index.ts new file mode 100644 index 000000000..b5b9dbef5 --- /dev/null +++ b/app/components/post_draft/quick_actions/index.ts @@ -0,0 +1,45 @@ +// 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 {combineLatest, of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {isMinimumServerVersion} from '@utils/helpers'; + +import QuickActions from './quick_actions'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type SystemModel from '@typings/database/models/servers/system'; + +const {SERVER: {SYSTEM}} = MM_TABLES; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const config = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap(({value}) => of$(value as ClientConfig)), + ); + const license = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe( + switchMap(({value}) => of$(value as ClientLicense)), + ); + + const canUploadFiles = combineLatest([config, license]).pipe( + switchMap(([c, l]) => of$( + c.EnableFileAttachments !== 'false' && + (l.IsLicensed === 'false' || l.Compliance === 'false' || c.EnableMobileFileUpload !== 'false'), + ), + ), + ); + + const maxFileCount = config.pipe( + switchMap((cfg) => of$(isMinimumServerVersion(cfg.Version, 6, 0) ? 10 : 5)), + ); + + return { + canUploadFiles, + maxFileCount, + }; +}); + +export default withDatabase(enhanced(QuickActions)); diff --git a/app/components/post_draft/quick_actions/input_quick_action/index.tsx b/app/components/post_draft/quick_actions/input_quick_action/index.tsx new file mode 100644 index 000000000..3f079c379 --- /dev/null +++ b/app/components/post_draft/quick_actions/input_quick_action/index.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; + +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {ICON_SIZE} from '@constants/post_draft'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + testID?: string; + disabled?: boolean; + inputType: 'at' | 'slash'; + onTextChange: (value: string) => void; + value: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + disabled: { + tintColor: changeOpacity(theme.centerChannelColor, 0.16), + }, + icon: { + alignItems: 'center', + justifyContent: 'center', + padding: 10, + }, + }; +}); + +export default function InputQuickAction({ + testID, + disabled, + inputType, + onTextChange, + value, +}: Props) { + const theme = useTheme(); + const onPress = useCallback(() => { + let newValue = '/'; + if (inputType === 'at') { + newValue = `${value}@`; + } + + onTextChange(newValue); + }, [value, inputType]); + + const actionTestID = disabled ? + `${testID}.disabled` : + testID; + const style = getStyleSheet(theme); + const iconName = inputType === 'at' ? inputType : 'slash-forward-box-outline'; + const iconColor = disabled ? + changeOpacity(theme.centerChannelColor, 0.16) : + changeOpacity(theme.centerChannelColor, 0.64); + + return ( + + + + ); +} diff --git a/app/components/post_draft/quick_actions/quick_actions.tsx b/app/components/post_draft/quick_actions/quick_actions.tsx new file mode 100644 index 000000000..03696f529 --- /dev/null +++ b/app/components/post_draft/quick_actions/quick_actions.tsx @@ -0,0 +1,101 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Platform, StyleSheet, View} from 'react-native'; + +import CameraAction from './camera_quick_action'; +import FileAction from './file_quick_action'; +import ImageAction from './image_quick_action'; +import InputAction from './input_quick_action'; + +type Props = { + testID?: string; + canUploadFiles: boolean; + fileCount: number; + maxFileCount: number; + + // Draft Handler + value: string; + updateValue: (value: string) => void; + addFiles: (file: FileInfo[]) => void; +} + +const style = StyleSheet.create({ + container: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: Platform.select({ + ios: 1, + android: 2, + }), + }, + quickActionsContainer: { + display: 'flex', + flexDirection: 'row', + height: 44, + }, +}); + +export default function QuickActions({ + testID, + canUploadFiles, + value, + fileCount, + maxFileCount, + updateValue, + addFiles, +}: Props) { + const atDisabled = value[value.length - 1] === '@'; + const slashDisabled = value.length > 0; + + const atInputActionTestID = `${testID}.at_input_action`; + const slashInputActionTestID = `${testID}.slash_input_action`; + const fileActionTestID = `${testID}.file_action`; + const imageActionTestID = `${testID}.image_action`; + const cameraActionTestID = `${testID}.camera_action`; + + const uploadProps = { + disabled: !canUploadFiles, + fileCount, + maxFileCount, + maxFilesReached: fileCount >= maxFileCount, + onUploadFiles: addFiles, + }; + + return ( + + + + + + + + ); +} diff --git a/app/components/post_draft/read_only/index.tsx b/app/components/post_draft/read_only/index.tsx new file mode 100644 index 000000000..e09cdb2b0 --- /dev/null +++ b/app/components/post_draft/read_only/index.tsx @@ -0,0 +1,72 @@ +// 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 {SafeAreaView} from 'react-native-safe-area-context'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +interface ReadOnlyProps { + testID?: string; +} + +const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({ + background: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), + }, + container: { + alignItems: 'center', + borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), + borderTopWidth: 1, + flexDirection: 'row', + height: 50, + paddingHorizontal: 12, + }, + icon: { + fontSize: 20, + lineHeight: 22, + opacity: 0.56, + }, + text: { + color: theme.centerChannelColor, + fontSize: 15, + lineHeight: 20, + marginLeft: 9, + opacity: 0.56, + }, +})); + +const safeAreaEdges = ['bottom' as const]; + +const ReadOnlyChannnel = ({testID}: ReadOnlyProps) => { + const theme = useTheme(); + const style = getStyle(theme); + return ( + + + + + + + ); +}; + +export default ReadOnlyChannnel; diff --git a/app/components/post_draft/send_action/index.tsx b/app/components/post_draft/send_action/index.tsx new file mode 100644 index 000000000..858383ca8 --- /dev/null +++ b/app/components/post_draft/send_action/index.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + testID: string; + disabled: boolean; + sendMessage: () => void; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + disableButton: { + backgroundColor: changeOpacity(theme.buttonBg, 0.3), + }, + sendButtonContainer: { + justifyContent: 'flex-end', + paddingRight: 8, + }, + sendButton: { + backgroundColor: theme.buttonBg, + borderRadius: 4, + height: 32, + width: 80, + alignItems: 'center', + justifyContent: 'center', + }, + }; +}); + +function SendButton({ + testID, + disabled, + sendMessage, +}: Props) { + const theme = useTheme(); + const sendButtonTestID = `${testID}.send.button`; + const style = getStyleSheet(theme); + + const viewStyle = useMemo(() => { + if (disabled) { + return [style.sendButton, style.disableButton]; + } + return style.sendButton; + }, [disabled, style]); + + const buttonColor = disabled ? changeOpacity(theme.buttonColor, 0.5) : theme.buttonColor; + + return ( + + + + + + ); +} + +export default SendButton; diff --git a/app/components/post_draft/send_handler/index.ts b/app/components/post_draft/send_handler/index.ts new file mode 100644 index 000000000..cf06913b5 --- /dev/null +++ b/app/components/post_draft/send_handler/index.ts @@ -0,0 +1,120 @@ +// 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 {combineLatest, of as of$, from as from$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {General, Permissions} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft'; +import {hasPermissionForChannel} from '@utils/role'; + +import SendHandler from './send_handler'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; +import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +import type GroupModel from '@typings/database/models/servers/group'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +const {SERVER: {SYSTEM, USER, CHANNEL, GROUP, GROUPS_TEAM, GROUPS_CHANNEL, CUSTOM_EMOJI}} = MM_TABLES; + +type OwnProps = { + rootId: string; + channelId: string; + channelIsArchived?: boolean; +} + +const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => { + const database = ownProps.database; + const {rootId, channelId} = ownProps; + let channel; + if (rootId) { + channel = database.get(CHANNEL).findAndObserve(channelId); + } else { + channel = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe( + switchMap((t) => database.get(CHANNEL).findAndObserve(t.value)), + ); + } + + const currentUserId = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap(({value}) => of$(value)), + ); + const currentUser = currentUserId.pipe( + switchMap((id) => database.get(USER).findAndObserve(id)), + ); + const userIsOutOfOffice = currentUser.pipe( + switchMap((u) => of$(u.status === General.OUT_OF_OFFICE)), + ); + + const config = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap(({value}) => of$(value as ClientConfig)), + ); + const enableConfirmNotificationsToChannel = config.pipe( + switchMap((cfg) => of$(Boolean(cfg.EnableConfirmNotificationsToChannel === 'true'))), + ); + const isTimezoneEnabled = config.pipe( + switchMap((cfg) => of$(Boolean(cfg.ExperimentalTimezone === 'true'))), + ); + const maxMessageLength = config.pipe( + switchMap((cfg) => of$(parseInt(cfg.MaxPostSize || '0', 10) || MAX_MESSAGE_LENGTH_FALLBACK)), + ); + + const useChannelMentions = combineLatest([channel, currentUser]).pipe( + switchMap(([c, u]) => { + if (!c) { + return of$(true); + } + + return from$(hasPermissionForChannel(c, u, Permissions.USE_CHANNEL_MENTIONS, false)); + }), + ); + + const license = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe( + switchMap(({value}) => of$(value as ClientLicense)), + ); + + const useGroupMentions = combineLatest([channel, currentUser, license]).pipe( + switchMap(([c, u, l]) => { + if (!c || l?.IsLicensed !== 'true') { + return of$(false); + } + + return from$(hasPermissionForChannel(c, u, Permissions.USE_GROUP_MENTIONS, true)); + }), + ); + + const groupsWithAllowReference = channel.pipe(switchMap( + (c) => database.get(GROUP).query( + Q.experimentalJoinTables([GROUPS_TEAM, GROUPS_CHANNEL]), + Q.or(Q.on(GROUPS_TEAM, 'team_id', c.teamId), Q.on(GROUPS_CHANNEL, 'channel_id', c.id)), + ).observeWithColumns(['name'])), + ); + + const channelInfo = channel.pipe(switchMap((c) => c.info.observe())); + const membersCount = channelInfo.pipe( + switchMap((i: ChannelInfoModel) => of$(i.memberCount)), + ); + + const customEmojis = database.get(CUSTOM_EMOJI).query().observe(); + + return { + currentUserId, + enableConfirmNotificationsToChannel, + isTimezoneEnabled, + maxMessageLength, + membersCount, + userIsOutOfOffice, + useChannelMentions, + useGroupMentions, + groupsWithAllowReference, + customEmojis, + }; +}); + +export default withDatabase(enhanced(SendHandler)); diff --git a/app/components/post_draft/send_handler/send_handler.tsx b/app/components/post_draft/send_handler/send_handler.tsx new file mode 100644 index 000000000..27fe7694c --- /dev/null +++ b/app/components/post_draft/send_handler/send_handler.tsx @@ -0,0 +1,286 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter} from 'react-native'; + +import {getChannelMemberCountsByGroup, getChannelTimezones} from '@actions/remote/channel'; +import {executeCommand, handleGotoLocation} from '@actions/remote/command'; +import {createPost} from '@actions/remote/post'; +import {handleReactionToLatestPost} from '@actions/remote/reactions'; +import {setStatus} from '@actions/remote/user'; +import {Events, Screens} from '@constants'; +import {NOTIFY_ALL_MEMBERS} from '@constants/post_draft'; +import {useServerUrl} from '@context/server'; +import DraftUploadManager from '@init/draft_upload_manager'; +import * as DraftUtils from '@utils/draft'; +import {isReactionMatch} from '@utils/emoji/helpers'; +import {preventDoubleTap} from '@utils/tap'; +import {confirmOutOfOfficeDisabled} from '@utils/user'; + +import CursorPositionHandler from '../cursor_position_handler'; + +import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +import type GroupModel from '@typings/database/models/servers/group'; + +type Props = { + testID?: string; + channelId: string; + rootId: string; + + // From database + currentUserId: string; + enableConfirmNotificationsToChannel?: boolean; + isTimezoneEnabled: boolean; + maxMessageLength: number; + membersCount?: number; + useChannelMentions: boolean; + userIsOutOfOffice: boolean; + useGroupMentions: boolean; + groupsWithAllowReference: GroupModel[]; + customEmojis: CustomEmojiModel[]; + + // DRAFT Handler + value: string; + files: FileInfo[]; + clearDraft: () => void; + updateValue: (message: string) => void; + addFiles: (file: FileInfo[]) => void; + uploadFileError: React.ReactNode; +} + +export default function SendHandler({ + testID, + channelId, + currentUserId, + enableConfirmNotificationsToChannel, + files, + isTimezoneEnabled, + maxMessageLength, + membersCount = 0, + rootId, + useChannelMentions, + userIsOutOfOffice, + customEmojis, + value, + useGroupMentions, + groupsWithAllowReference, + clearDraft, + updateValue, + addFiles, + uploadFileError, +}: Props) { + const intl = useIntl(); + const serverUrl = useServerUrl(); + + const [channelTimezoneCount, setChannelTimezoneCount] = useState(0); + const [sendingMessage, setSendingMessage] = useState(false); + const [channelMemberCountsByGroup, setChannelMemberCountsByGroup] = useState([]); + + const canSend = useCallback(() => { + if (sendingMessage) { + return false; + } + + const messageLength = value.trim().length; + + if (messageLength > maxMessageLength) { + return false; + } + + if (files.length) { + const loadingComplete = !files.some((file) => DraftUploadManager.isUploading(file.clientId!)); + return loadingComplete; + } + + return messageLength > 0; + }, [sendingMessage, value, files, maxMessageLength]); + + const handleReaction = useCallback((emoji: string, add: boolean) => { + handleReactionToLatestPost(serverUrl, emoji, add, rootId); + clearDraft(); + setSendingMessage(false); + }, [serverUrl, rootId, clearDraft]); + + const doSubmitMessage = useCallback(() => { + const postFiles = files.filter((f) => !f.failed); + const post = { + user_id: currentUserId, + channel_id: channelId, + root_id: rootId, + message: value, + }; + + createPost(serverUrl, post, postFiles); + + clearDraft(); + setSendingMessage(false); + DeviceEventEmitter.emit(Events.POST_LIST_SCROLL_TO_BOTTOM, rootId ? Screens.THREAD : Screens.CHANNEL); + }, [files, currentUserId, channelId, rootId, value, clearDraft]); + + const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean) => { + const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, Boolean(isTimezoneEnabled), channelTimezoneCount, atHere); + const cancel = () => { + setSendingMessage(false); + }; + + DraftUtils.alertChannelWideMention(intl, notifyAllMessage, doSubmitMessage, cancel); + }, [intl, isTimezoneEnabled, channelTimezoneCount, doSubmitMessage]); + + const showSendToGroupsAlert = useCallback((groupMentions: string[], memberNotifyCount: number, calculatedChannelTimezoneCount: number) => { + const notifyAllMessage = DraftUtils.buildGroupMentionsMessage(intl, groupMentions, memberNotifyCount, calculatedChannelTimezoneCount); + const cancel = () => { + setSendingMessage(false); + }; + + DraftUtils.alertSendToGroups(intl, notifyAllMessage, doSubmitMessage, cancel); + }, [intl, doSubmitMessage]); + + const sendCommand = useCallback(async () => { + const status = DraftUtils.getStatusFromSlashCommand(value); + if (userIsOutOfOffice && status) { + const updateStatus = (newStatus: string) => { + setStatus(serverUrl, { + status: newStatus, + last_activity_at: Date.now(), + manual: true, + user_id: currentUserId, + }); + }; + confirmOutOfOfficeDisabled(intl, status, updateStatus); + setSendingMessage(false); + return; + } + + const {data, error} = await executeCommand(serverUrl, intl, value, channelId, rootId); + setSendingMessage(false); + + if (error) { + const errorMessage = typeof (error) === 'string' ? error : error.message; + DraftUtils.alertSlashCommandFailed(intl, errorMessage); + return; + } + + clearDraft(); + + // TODO Apps related https://mattermost.atlassian.net/browse/MM-41233 + // if (data?.form) { + // showAppForm(data.form, data.call, theme); + // } + + if (data?.goto_location) { + handleGotoLocation(serverUrl, intl, data.goto_location); + } + }, [userIsOutOfOffice, currentUserId, intl, value, serverUrl, channelId, rootId]); + + const sendMessage = useCallback(() => { + const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions; + const notificationsToGroups = enableConfirmNotificationsToChannel && useGroupMentions; + const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value); + const toHere = DraftUtils.textContainsAtHere(value); + const groupMentions = (!toAllOrChannel && !toHere && notificationsToGroups) ? DraftUtils.groupsMentionedInText(groupsWithAllowReference, value) : []; + + if (value.indexOf('/') === 0) { + sendCommand(); + } else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && (toAllOrChannel || toHere)) { + showSendToAllOrChannelOrHereAlert(membersCount, toHere && !toAllOrChannel); + } else if (groupMentions.length > 0) { + const { + groupMentionsSet, + memberNotifyCount, + channelTimezoneCount: calculatedChannelTimezoneCount, + } = DraftUtils.mapGroupMentions(channelMemberCountsByGroup, groupMentions); + if (memberNotifyCount > 0) { + showSendToGroupsAlert(Array.from(groupMentionsSet), memberNotifyCount, calculatedChannelTimezoneCount); + } else { + doSubmitMessage(); + } + } else { + doSubmitMessage(); + } + }, [ + enableConfirmNotificationsToChannel, + useChannelMentions, + useGroupMentions, + value, + groupsWithAllowReference, + channelTimezoneCount, + channelMemberCountsByGroup, + sendCommand, + showSendToAllOrChannelOrHereAlert, + showSendToGroupsAlert, + doSubmitMessage, + ]); + + const handleSendMessage = useCallback(preventDoubleTap(() => { + if (!canSend()) { + return; + } + + setSendingMessage(true); + + const match = isReactionMatch(value, customEmojis); + if (match && !files.length) { + handleReaction(match.emoji, match.add); + return; + } + + const hasFailedAttachments = files.some((f) => f.failed); + if (hasFailedAttachments) { + const cancel = () => { + setSendingMessage(false); + }; + const accept = () => { + // Files are filtered on doSubmitMessage + sendMessage(); + }; + + DraftUtils.alertAttachmentFail(intl, accept, cancel); + } else { + sendMessage(); + } + }), [canSend, value, handleReaction, files, sendMessage, customEmojis]); + + useEffect(() => { + if (useGroupMentions) { + getChannelMemberCountsByGroup(serverUrl, channelId, isTimezoneEnabled).then((resp) => { + if (resp.error) { + return; + } + + const received = resp.channelMemberCountsByGroup || []; + if (received.length || channelMemberCountsByGroup.length) { + setChannelMemberCountsByGroup(received); + } + }); + } + }, [useGroupMentions, channelId, isTimezoneEnabled, channelMemberCountsByGroup.length]); + + useEffect(() => { + getChannelTimezones(serverUrl, channelId).then(({channelTimezones}) => { + setChannelTimezoneCount(channelTimezones?.length || 0); + }); + }, [serverUrl, channelId]); + + return ( + + ); +} diff --git a/app/components/post_draft/typing/index.tsx b/app/components/post_draft/typing/index.tsx new file mode 100644 index 000000000..5e157e6d3 --- /dev/null +++ b/app/components/post_draft/typing/index.tsx @@ -0,0 +1,157 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import { + DeviceEventEmitter, + Platform, + Text, +} from 'react-native'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; + +import FormattedText from '@components/formatted_text'; +import {Events} from '@constants'; +import {TYPING_HEIGHT} from '@constants/post_draft'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + channelId: string; + rootId: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + typing: { + position: 'absolute', + paddingLeft: 10, + paddingTop: 3, + fontSize: 11, + ...Platform.select({ + android: { + marginBottom: 5, + }, + ios: { + marginBottom: 2, + }, + }), + color: theme.centerChannelColor, + backgroundColor: 'transparent', + }, + }; +}); + +export default function Typing({ + channelId, + rootId, +}: Props) { + const typingHeight = useSharedValue(0); + const typing = useRef>([]); + const [refresh, setRefresh] = useState(0); + + const theme = useTheme(); + const style = getStyleSheet(theme); + + // This moves the list of post up. This may be rethought by UX in https://mattermost.atlassian.net/browse/MM-39681 + const typingAnimatedStyle = useAnimatedStyle(() => { + return { + height: withTiming(typingHeight.value), + }; + }); + + const onUserStartTyping = useCallback((msg: any) => { + if (channelId !== msg.channelId) { + return; + } + + const msgRootId = msg.parentId || ''; + if (rootId !== msgRootId) { + return; + } + + typing.current = typing.current.filter(({id}) => id !== msg.userId); + typing.current.push({id: msg.userId, now: msg.now, username: msg.username}); + setRefresh(Date.now()); + }, [channelId, rootId]); + + const onUserStopTyping = useCallback((msg: any) => { + if (channelId !== msg.channelId) { + return; + } + + const msgRootId = msg.parentId || ''; + if (rootId !== msgRootId) { + return; + } + + typing.current = typing.current.filter(({id, now}) => id !== msg.userId && now !== msg.now); + setRefresh(Date.now()); + }, []); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.USER_TYPING, onUserStartTyping); + return () => { + listener.remove(); + }; + }, [onUserStartTyping]); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.USER_STOP_TYPING, onUserStopTyping); + return () => { + listener.remove(); + }; + }, [onUserStopTyping]); + + useEffect(() => { + typingHeight.value = typing.current.length ? TYPING_HEIGHT : 0; + }, [refresh]); + + const renderTyping = () => { + const nextTyping = typing.current.map(({username}) => username); + + // Max three names + nextTyping.splice(3); + + const numUsers = nextTyping.length; + + switch (numUsers) { + case 0: + return null; + case 1: + return ( + + ); + default: { + const last = nextTyping.pop(); + return ( + + ); + } + } + }; + + return ( + + + {renderTyping()} + + + ); +} diff --git a/app/components/post_draft/uploads/index.tsx b/app/components/post_draft/uploads/index.tsx new file mode 100644 index 000000000..e46351e84 --- /dev/null +++ b/app/components/post_draft/uploads/index.tsx @@ -0,0 +1,163 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect} from 'react'; +import { + ScrollView, + Text, + View, + Platform, +} from 'react-native'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; + +import {useTheme} from '@context/theme'; +import DraftUploadManager from '@init/draft_upload_manager'; +import {openGalleryAtIndex} from '@utils/gallery'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import UploadItem from './upload_item'; + +const CONTAINER_HEIGHT_MAX = 67; +const CONATINER_HEIGHT_MIN = 0; +const ERROR_HEIGHT_MAX = 20; +const ERROR_HEIGHT_MIN = 0; + +type Props = { + files: FileInfo[]; + uploadFileError: React.ReactNode; + channelId: string; + rootId: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + previewContainer: { + display: 'flex', + flexDirection: 'column', + }, + fileContainer: { + display: 'flex', + flexDirection: 'row', + height: 0, + }, + errorContainer: { + height: 0, + }, + errorTextContainer: { + marginTop: Platform.select({ + ios: 4, + android: 2, + }), + marginHorizontal: 12, + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollViewContent: { + alignItems: 'flex-end', + paddingRight: 12, + }, + warning: { + color: theme.errorTextColor, + flex: 1, + flexWrap: 'wrap', + }, + }; +}); + +export default function Uploads({ + files, + uploadFileError, + channelId, + rootId, +}: Props) { + const theme = useTheme(); + const style = getStyleSheet(theme); + + const errorHeight = useSharedValue(ERROR_HEIGHT_MIN); + const containerHeight = useSharedValue(CONTAINER_HEIGHT_MAX); + + const errorAnimatedStyle = useAnimatedStyle(() => { + return { + height: withTiming(errorHeight.value), + }; + }); + + const containerAnimatedStyle = useAnimatedStyle(() => { + return { + height: withTiming(containerHeight.value), + }; + }); + + const fileContainerStyle = { + paddingBottom: files.length ? 5 : 0, + }; + + useEffect(() => { + if (uploadFileError) { + errorHeight.value = ERROR_HEIGHT_MAX; + } else { + errorHeight.value = ERROR_HEIGHT_MIN; + } + }, [uploadFileError]); + + useEffect(() => { + if (files.length) { + containerHeight.value = CONTAINER_HEIGHT_MAX; + return; + } + containerHeight.value = CONATINER_HEIGHT_MIN; + }, [files.length > 0]); + + const openGallery = useCallback((file: FileInfo) => { + const galleryFiles = files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!)); + const index = galleryFiles.indexOf(file); + openGalleryAtIndex(index, galleryFiles); + }, [files]); + + const buildFilePreviews = () => { + return files.map((file) => { + return ( + + ); + }); + }; + + return ( + + + + {buildFilePreviews()} + + + + + {Boolean(uploadFileError) && + + + + {uploadFileError} + + + + } + + + ); +} diff --git a/app/components/post_draft/uploads/upload_item/index.tsx b/app/components/post_draft/uploads/upload_item/index.tsx new file mode 100644 index 000000000..93efd940f --- /dev/null +++ b/app/components/post_draft/uploads/upload_item/index.tsx @@ -0,0 +1,155 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {StyleSheet, TouchableOpacity, View} from 'react-native'; + +import {updateDraftFile} from '@actions/local/draft'; +import FileIcon from '@components/post_list/post/body/files/file_icon'; +import ImageFile from '@components/post_list/post/body/files/image_file'; +import ProgressBar from '@components/progress_bar'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import useDidUpdate from '@hooks/did_update'; +import DraftUploadManager from '@init/draft_upload_manager'; +import {isImage} from '@utils/file'; +import {changeOpacity} from '@utils/theme'; + +import UploadRemove from './upload_remove'; +import UploadRetry from './upload_retry'; + +type Props = { + file: FileInfo; + channelId: string; + rootId: string; + openGallery: (file: FileInfo) => void; +} + +const styles = StyleSheet.create({ + preview: { + paddingTop: 5, + marginLeft: 12, + }, + previewContainer: { + height: 56, + width: 56, + borderRadius: 4, + }, + progress: { + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.1)', + height: 53, + width: 53, + justifyContent: 'flex-end', + position: 'absolute', + borderRadius: 4, + paddingLeft: 3, + }, + filePreview: { + width: 56, + height: 56, + }, +}); + +export default function UploadItem({ + file, + channelId, + rootId, + openGallery, +}: Props) { + const theme = useTheme(); + const serverUrl = useServerUrl(); + const removeCallback = useRef<(() => void)|null>(null); + const [progress, setProgress] = useState(0); + + const loading = DraftUploadManager.isUploading(file.clientId!); + + const handlePress = useCallback(() => { + openGallery(file); + }, [openGallery, file]); + + useEffect(() => { + if (file.clientId) { + removeCallback.current = DraftUploadManager.registerProgressHandler(file.clientId, setProgress); + } + return () => { + removeCallback.current?.(); + removeCallback.current = null; + }; + }, []); + + useDidUpdate(() => { + if (loading && file.clientId) { + removeCallback.current = DraftUploadManager.registerProgressHandler(file.clientId, setProgress); + } + return () => { + removeCallback.current?.(); + removeCallback.current = null; + }; + }, [file.failed, file.id]); + + const retryFileUpload = useCallback(() => { + if (!file.failed) { + return; + } + + const newFile = {...file}; + newFile.failed = false; + + updateDraftFile(serverUrl, channelId, rootId, newFile); + DraftUploadManager.prepareUpload(serverUrl, newFile, channelId, rootId, newFile.bytesRead); + DraftUploadManager.registerProgressHandler(newFile.clientId!, setProgress); + }, [serverUrl, channelId, rootId, file]); + + const filePreviewComponent = useMemo(() => { + if (isImage(file)) { + return ( + + ); + } + return ( + + ); + }, [file]); + + return ( + + + + + {filePreviewComponent} + + + {file.failed && + + } + {loading && !file.failed && + + + + } + + + + ); +} + diff --git a/app/components/post_draft/uploads/upload_item/upload_remove.tsx b/app/components/post_draft/uploads/upload_item/upload_remove.tsx new file mode 100644 index 000000000..b3c836c0b --- /dev/null +++ b/app/components/post_draft/uploads/upload_item/upload_remove.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View, Platform} from 'react-native'; + +import {removeDraftFile} from '@actions/local/draft'; +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import DraftUploadManager from '@init/draft_upload_manager'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; + +type Props = { + channelId: string; + rootId: string; + clientId: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + tappableContainer: { + position: 'absolute', + elevation: 11, + top: -7, + right: -8, + width: 24, + height: 24, + }, + removeButton: { + borderRadius: 12, + alignSelf: 'center', + marginTop: Platform.select({ + ios: 5.4, + android: 4.75, + }), + backgroundColor: theme.centerChannelBg, + width: 24, + height: 25, + }, + }; +}); + +export default function UploadRemove({ + channelId, + rootId, + clientId, +}: Props) { + const theme = useTheme(); + const style = getStyleSheet(theme); + const serverUrl = useServerUrl(); + + const onPress = () => { + DraftUploadManager.cancel(clientId); + removeDraftFile(serverUrl, channelId, rootId, clientId); + }; + + return ( + + + + + + ); +} diff --git a/app/components/post_draft/uploads/upload_item/upload_retry.tsx b/app/components/post_draft/uploads/upload_item/upload_retry.tsx new file mode 100644 index 000000000..695e4be5f --- /dev/null +++ b/app/components/post_draft/uploads/upload_item/upload_retry.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 {StyleSheet} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; + +type Props = { + onPress: () => void; +} + +const style = StyleSheet.create({ + failed: { + backgroundColor: 'rgba(0, 0, 0, 0.8)', + position: 'absolute', + height: '100%', + width: '100%', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 4, + }, +}); + +export default function UploadRetry({ + onPress, +}: Props) { + return ( + + + + ); +} diff --git a/app/components/post_list/index.tsx b/app/components/post_list/index.tsx index 848ada82c..41b3b8477 100644 --- a/app/components/post_list/index.tsx +++ b/app/components/post_list/index.tsx @@ -11,7 +11,7 @@ import CombinedUserActivity from '@components/post_list/combined_user_activity'; import DateSeparator from '@components/post_list/date_separator'; import NewMessagesLine from '@components/post_list/new_message_line'; import Post from '@components/post_list/post'; -import {Screens} from '@constants'; +import {Events, Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList, START_OF_NEW_MESSAGES} from '@utils/post_list'; @@ -126,7 +126,7 @@ const PostList = ({ } }; - const scrollBottomListener = DeviceEventEmitter.addListener('scroll-to-bottom', scrollToBottom); + const scrollBottomListener = DeviceEventEmitter.addListener(Events.POST_LIST_SCROLL_TO_BOTTOM, scrollToBottom); return () => { scrollBottomListener.remove(); 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 index 099f56f60..aff931e43 100644 --- a/app/components/post_list/post/body/content/image_preview/index.tsx +++ b/app/components/post_list/post/body/content/image_preview/index.tsx @@ -93,7 +93,6 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme} 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 index 9ea824886..ff6d97bea 100644 --- 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 @@ -66,7 +66,6 @@ const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => { diff --git a/app/components/post_list/post/body/files/document_file.tsx b/app/components/post_list/post/body/files/document_file.tsx index b2f79cc87..9e23b6966 100644 --- a/app/components/post_list/post/body/files/document_file.tsx +++ b/app/components/post_list/post/body/files/document_file.tsx @@ -171,7 +171,6 @@ const DocumentFile = forwardRef(({background ); diff --git a/app/components/post_list/post/body/files/file.tsx b/app/components/post_list/post/body/files/file.tsx index 2252f79e3..27be5832f 100644 --- a/app/components/post_list/post/body/files/file.tsx +++ b/app/components/post_list/post/body/files/file.tsx @@ -76,7 +76,6 @@ const File = ({ wrapperWidth={wrapperWidth} isSingleImage={isSingleImage} resizeMode={'cover'} - theme={theme} /> {Boolean(nonVisibleImagesCount) && diff --git a/app/components/post_list/post/body/files/file_icon.tsx b/app/components/post_list/post/body/files/file_icon.tsx index 16413fc1a..9d83bd432 100644 --- a/app/components/post_list/post/body/files/file_icon.tsx +++ b/app/components/post_list/post/body/files/file_icon.tsx @@ -5,6 +5,7 @@ import React from 'react'; import {View, StyleSheet} from 'react-native'; import CompassIcon from '@components/compass_icon'; +import {useTheme} from '@context/theme'; import {getFileType} from '@utils/file'; type FileIconProps = { @@ -15,7 +16,6 @@ type FileIconProps = { iconColor?: string; iconSize?: number; smallImage?: boolean; - theme: Theme; } const BLUE_ICON = '#338AFF'; @@ -49,8 +49,9 @@ const styles = StyleSheet.create({ const FileIcon = ({ backgroundColor, defaultImage = false, failed = false, file, - iconColor, iconSize = 48, smallImage = false, theme, + iconColor, iconSize = 48, smallImage = false, }: FileIconProps) => { + const theme = useTheme(); const getFileIconNameAndColor = () => { if (failed) { return FAILED_ICON_NAME_AND_COLOR; diff --git a/app/components/post_list/post/body/files/image_file.tsx b/app/components/post_list/post/body/files/image_file.tsx index 8bab05779..90f5a5189 100644 --- a/app/components/post_list/post/body/files/image_file.tsx +++ b/app/components/post_list/post/body/files/image_file.tsx @@ -6,6 +6,7 @@ import {StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle} from 'react import ProgressiveImage from '@components/progressive_image'; import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; import NetworkManager from '@init/network_manager'; import {calculateDimensions} from '@utils/images'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -21,7 +22,6 @@ type ImageFileProps = { inViewPort?: boolean; isSingleImage?: boolean; resizeMode?: ResizeMode; - theme: Theme; wrapperWidth?: number; } @@ -71,11 +71,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const ImageFile = ({ backgroundColor, file, inViewPort, isSingleImage, - resizeMode = 'cover', theme, wrapperWidth, + resizeMode = 'cover', wrapperWidth, }: ImageFileProps) => { const serverUrl = useServerUrl(); const [failed, setFailed] = useState(false); const dimensions = useWindowDimensions(); + const theme = useTheme(); const style = getStyleSheet(theme); let image; let client: Client | undefined; @@ -142,7 +143,6 @@ const ImageFile = ({ failed={failed} file={file} backgroundColor={backgroundColor} - theme={theme} /> ); } @@ -185,7 +185,6 @@ const ImageFile = ({ failed={failed} file={file} backgroundColor={backgroundColor} - theme={theme} /> ); diff --git a/app/components/progress_bar/index.tsx b/app/components/progress_bar/index.tsx index 911fc531c..27b99a951 100644 --- a/app/components/progress_bar/index.tsx +++ b/app/components/progress_bar/index.tsx @@ -1,8 +1,9 @@ // 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'; +import React, {useCallback, useEffect, useState} from 'react'; +import {LayoutChangeEvent, StyleSheet, StyleProp, View, ViewStyle} from 'react-native'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; type ProgressBarProps = { color: string; @@ -24,35 +25,27 @@ const styles = StyleSheet.create({ }); const ProgressBar = ({color, progress, style}: ProgressBarProps) => { - const timer = useRef(new Animated.Value(progress)).current; const [width, setWidth] = useState(0); + const progressValue = useSharedValue(progress); + + const progressAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [ + {translateX: withTiming(((progressValue.value * 0.5) - 0.5) * width, {duration: 200})}, + {scaleX: withTiming(progressValue.value ? progressValue.value : 0.0001, {duration: 200})}, + ], + }; + }, [width]); + useEffect(() => { - const animation = Animated.timing(timer, { - duration: 200, - useNativeDriver: true, - isInteraction: false, - toValue: progress, - }); - - animation.start(); - - return animation.stop; + progressValue.value = progress; }, [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 ( { > diff --git a/app/constants/deep_linking.ts b/app/constants/deep_linking.ts index 31988cb4d..06c282fe8 100644 --- a/app/constants/deep_linking.ts +++ b/app/constants/deep_linking.ts @@ -6,5 +6,6 @@ export default { DMCHANNEL: 'dmchannel', GROUPCHANNEL: 'groupchannel', PERMALINK: 'permalink', + PLUGIN: 'plugin', OTHER: 'other', }; diff --git a/app/constants/events.ts b/app/constants/events.ts index e1247d3f7..9e55a5b20 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -16,5 +16,6 @@ export default keyMirror({ TEAM_LOAD_ERROR: null, USER_TYPING: null, USER_STOP_TYPING: null, + POST_LIST_SCROLL_TO_BOTTOM: null, SWIPEABLE: null, }); diff --git a/app/constants/general.ts b/app/constants/general.ts index 2fbe43d9f..91ca21098 100644 --- a/app/constants/general.ts +++ b/app/constants/general.ts @@ -12,6 +12,7 @@ export default { AWAY: 'away', ONLINE: 'online', DND: 'dnd', + STATUS_COMMANDS: ['offline', 'away', 'online', 'dnd'], DEFAULT_CHANNEL: 'town-square', DM_CHANNEL: 'D', OPEN_CHANNEL: 'O', diff --git a/app/constants/index.ts b/app/constants/index.ts index c6c24fd9a..c6dd4b430 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -15,9 +15,11 @@ import Navigation from './navigation'; import Network from './network'; import Permissions from './permissions'; import Post from './post'; +import PostDraft from './post_draft'; import Preferences from './preferences'; import Profile from './profile'; import Screens from './screens'; +import ServerErrors from './server_errors'; import Sso from './sso'; import SupportedServer from './supported_server'; import View from './view'; @@ -38,9 +40,11 @@ export { Network, Permissions, Post, + PostDraft, Preferences, Profile, Screens, + ServerErrors, SupportedServer, Sso, View, diff --git a/app/constants/post_draft.ts b/app/constants/post_draft.ts new file mode 100644 index 000000000..2cb6e1906 --- /dev/null +++ b/app/constants/post_draft.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const MAX_MESSAGE_LENGTH_FALLBACK = 4000; +export const DEFAULT_SERVER_MAX_FILE_SIZE = 50 * 1024 * 1024;// 50 Mb +export const ICON_SIZE = 24; +export const UPDATE_NATIVE_SCROLLVIEW = 'onUpdateNativeScrollView'; +export const TYPING_HEIGHT = 26; +export const ACCESSORIES_CONTAINER_NATIVE_ID = 'channelAccessoriesContainer'; + +export const NOTIFY_ALL_MEMBERS = 5; + +export default { + ACCESSORIES_CONTAINER_NATIVE_ID, + DEFAULT_SERVER_MAX_FILE_SIZE, + ICON_SIZE, + MAX_MESSAGE_LENGTH_FALLBACK, + NOTIFY_ALL_MEMBERS, + TYPING_HEIGHT, + UPDATE_NATIVE_SCROLLVIEW, +}; diff --git a/app/constants/server_errors.ts b/app/constants/server_errors.ts new file mode 100644 index 000000000..cca506356 --- /dev/null +++ b/app/constants/server_errors.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export default { + DELETED_ROOT_POST_ERROR: 'api.post.create_post.root_id.app_error', + TOWN_SQUARE_READ_ONLY_ERROR: 'api.post.create_post.town_square_read_only', + PLUGIN_DISMISSED_POST_ERROR: 'plugin.message_will_be_posted.dismiss_post', +}; diff --git a/app/constants/view.ts b/app/constants/view.ts index c2458686c..47cc07217 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -3,6 +3,7 @@ import {Platform} from 'react-native'; +export const BOTTOM_TAB_HEIGHT = 52; export const BOTTOM_TAB_ICON_SIZE = 31.2; export const PROFILE_PICTURE_SIZE = 32; export const PROFILE_PICTURE_EMOJI_SIZE = 28; @@ -24,6 +25,7 @@ export const ANDROID_HEADER_SEARCH_INSET = 11; export const INDICATOR_BAR_HEIGHT = 38; export default { + BOTTOM_TAB_HEIGHT, BOTTOM_TAB_ICON_SIZE, PROFILE_PICTURE_SIZE, PROFILE_PICTURE_EMOJI_SIZE, @@ -43,3 +45,4 @@ export default { ANDROID_HEADER_SEARCH_INSET, INDICATOR_BAR_HEIGHT, }; + diff --git a/app/database/operator/server_data_operator/comparators/index.ts b/app/database/operator/server_data_operator/comparators/index.ts index 0b52726e4..1962f0035 100644 --- a/app/database/operator/server_data_operator/comparators/index.ts +++ b/app/database/operator/server_data_operator/comparators/index.ts @@ -56,7 +56,7 @@ export const isRecordTermsOfServiceEqualToRaw = (record: TermsOfServiceModel, ra }; export const isRecordDraftEqualToRaw = (record: DraftModel, raw: Draft) => { - return raw.channel_id === record.channelId; + return raw.channel_id === record.channelId && raw.root_id === record.rootId; }; export const isRecordPostEqualToRaw = (record: PostModel, raw: Post) => { diff --git a/app/database/operator/server_data_operator/handlers/post.ts b/app/database/operator/server_data_operator/handlers/post.ts index 79f22e44a..345c19385 100644 --- a/app/database/operator/server_data_operator/handlers/post.ts +++ b/app/database/operator/server_data_operator/handlers/post.ts @@ -92,7 +92,7 @@ const PostHandler = (superclass: any) => class extends superclass { // Let's process the post data for (const post of posts) { // Find any pending posts that matches the ones received to mark for deletion - if (post.pending_post_id) { + if (post.pending_post_id && post.id !== post.pending_post_id) { pendingPostsToDelete.push({ ...post, id: post.pending_post_id, diff --git a/app/database/operator/server_data_operator/transformers/post.ts b/app/database/operator/server_data_operator/transformers/post.ts index 605c6bfd7..39971e6e6 100644 --- a/app/database/operator/server_data_operator/transformers/post.ts +++ b/app/database/operator/server_data_operator/transformers/post.ts @@ -42,7 +42,7 @@ export const transformPostRecord = ({action, database, value}: TransformerArgs): post.updateAt = raw.update_at; post.isPinned = Boolean(raw.is_pinned); post.message = raw.message; - post.metadata = Object.keys(raw.metadata).length ? raw.metadata : null; + post.metadata = raw.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; diff --git a/app/helpers/api/preference.ts b/app/helpers/api/preference.ts index bbf8bf4f9..0f53995b1 100644 --- a/app/helpers/api/preference.ts +++ b/app/helpers/api/preference.ts @@ -31,7 +31,7 @@ export function getPreferenceAsInt(preferences: PreferenceType[] | PreferenceMod export function getTeammateNameDisplaySetting(preferences: PreferenceType[] | PreferenceModel[], config?: ClientConfig, license?: ClientLicense) { const useAdminTeammateNameDisplaySetting = license?.LockTeammateNameDisplay === 'true' && config?.LockTeammateNameDisplay === 'true'; - const preference = getPreferenceValue(preferences as PreferenceType[], Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT, '') as string; + const preference = getPreferenceValue(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT, '') as string; if (preference && !useAdminTeammateNameDisplaySetting) { return preference; } else if (config?.TeammateNameDisplay) { diff --git a/app/init/draft_upload_manager.ts b/app/init/draft_upload_manager.ts index c57562e14..320e9d1eb 100644 --- a/app/init/draft_upload_manager.ts +++ b/app/init/draft_upload_manager.ts @@ -56,7 +56,8 @@ class DraftUploadManager { }; const onError = (response: ClientResponseError) => { - this.handleError(response.message, file.clientId!); + const message = response.message || 'Unkown error'; + this.handleError(message, file.clientId!); }; const {error, cancel} = uploadFile(serverUrl, file, channelId, onProgress, onComplete, onError, skipBytes); @@ -68,9 +69,9 @@ class DraftUploadManager { }; public cancel = (clientId: string) => { - const {cancel} = this.handlers[clientId]; + const h = this.handlers[clientId]; delete this.handlers[clientId]; - cancel?.(); + h?.cancel?.(); }; public isUploading = (clientId: string) => { @@ -113,7 +114,6 @@ class DraftUploadManager { return; } - h.fileInfo.progress = progress; h.fileInfo.bytesRead = bytes; h.onProgress.forEach((c) => c(progress, bytes)); @@ -142,24 +142,28 @@ class DraftUploadManager { return; } - delete this.handlers[clientId!]; + delete this.handlers[clientId]; const fileInfo = data[0]; fileInfo.clientId = h.fileInfo.clientId; fileInfo.localPath = h.fileInfo.localPath; - updateDraftFile(h.serverUrl, h.channelId, h.rootId, this.handlers[clientId].fileInfo); + updateDraftFile(h.serverUrl, h.channelId, h.rootId, fileInfo); }; private handleError = (errorMessage: string, clientId: string) => { const h = this.handlers[clientId]; + if (!h) { + return; + } + delete this.handlers[clientId]; h.onError.forEach((c) => c(errorMessage)); const fileInfo = {...h.fileInfo}; fileInfo.failed = true; - updateDraftFile(h.serverUrl, h.channelId, h.rootId, this.handlers[clientId].fileInfo); + updateDraftFile(h.serverUrl, h.channelId, h.rootId, fileInfo); }; private onAppStateChange = async (appState: AppStateStatus) => { diff --git a/app/init/websocket_manager.ts b/app/init/websocket_manager.ts index 91591c1ac..4f97d0869 100644 --- a/app/init/websocket_manager.ts +++ b/app/init/websocket_manager.ts @@ -180,6 +180,10 @@ class WebsocketManager { this.closeAll(); }; + + public getClient = (serverUrl: string): WebSocketClient | undefined => { + return this.clients[serverUrl]; + }; } export default new WebsocketManager(); diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index 094c258f1..d029a05a7 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -7,8 +7,9 @@ import {MM_TABLES} from '@constants/database'; import type PostModel from '@typings/database/models/servers/post'; import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel'; +import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; -const {SERVER: {POST, POSTS_IN_CHANNEL}} = MM_TABLES; +const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES; export const prepareDeletePost = async (post: PostModel): Promise => { const preparedModels: Model[] = [post.prepareDestroyPermanently()]; @@ -48,12 +49,39 @@ export const queryPostById = async (database: Database, postId: string) => { export const queryPostsInChannel = (database: Database, channelId: string): Promise => { try { - return database.get(POSTS_IN_CHANNEL).query( + return database.get(POSTS_IN_CHANNEL).query( Q.where('channel_id', channelId), Q.sortBy('latest', Q.desc), - ).fetch() as Promise; + ).fetch(); } catch { - return Promise.resolve([] as PostInChannelModel[]); + return Promise.resolve([]); + } +}; + +export const queryPostsInThread = (database: Database, rootId: string): Promise => { + try { + return database.get(POSTS_IN_THREAD).query( + Q.where('root_id', rootId), + Q.sortBy('latest', Q.desc), + ).fetch(); + } catch { + return Promise.resolve([]); + } +}; + +export const queryRecentPostsInThread = async (database: Database, rootId: string): Promise => { + try { + const chunks = await queryPostsInThread(database, rootId); + if (chunks.length) { + const recent = chunks[0]; + const post = await queryPostById(database, rootId); + if (post) { + return queryPostsChunk(database, post.channelId, recent.earliest, recent.latest); + } + } + return Promise.resolve([]); + } catch { + return Promise.resolve([]); } }; @@ -68,7 +96,7 @@ export const queryPostsChunk = (database: Database, channelId: string, earliest: Q.sortBy('create_at', Q.desc), ).fetch() as Promise; } catch { - return Promise.resolve([] as PostModel[]); + return Promise.resolve([]); } }; @@ -79,9 +107,9 @@ export const queryRecentPostsInChannel = async (database: Database, channelId: s const recent = chunks[0]; return queryPostsChunk(database, channelId, recent.earliest, recent.latest); } - return Promise.resolve([] as PostModel[]); + return Promise.resolve([]); } catch { - return Promise.resolve([] as PostModel[]); + return Promise.resolve([]); } }; diff --git a/app/queries/servers/team.ts b/app/queries/servers/team.ts index 0f2e24bab..118aec015 100644 --- a/app/queries/servers/team.ts +++ b/app/queries/servers/team.ts @@ -51,19 +51,21 @@ export const addChannelToTeamHistory = async (operator: ServerDataOperator, team return operator.handleTeamChannelHistory({teamChannelHistories: [tch], prepareRecordsOnly}); }; -export const queryLastChannelFromTeam = async (database: Database, teamId: string) => { +export const queryNthLastChannelFromTeam = async (database: Database, teamId: string, n = 0) => { let channelId = ''; try { const teamChannelHistory = await database.get(TEAM_CHANNEL_HISTORY).find(teamId); - if (teamChannelHistory.channelIds.length) { - channelId = teamChannelHistory.channelIds[0]; + if (teamChannelHistory.channelIds.length > n + 1) { + channelId = teamChannelHistory.channelIds[n]; } - } catch { - // No channel history for the team - const channel = await queryDefaultChannelForTeam(database, teamId); - if (channel) { - channelId = channel.id; + } finally { + if (!channelId) { + // No channel history for the team + const channel = await queryDefaultChannelForTeam(database, teamId); + if (channel) { + channelId = channel.id; + } } } diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index 6a6083e1a..9f23908fc 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -8,7 +8,9 @@ import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-cont import CompassIcon from '@components/compass_icon'; import NavigationHeader from '@components/navigation_header'; +import PostDraft from '@components/post_draft'; import {Navigation} from '@constants'; +import {ACCESSORIES_CONTAINER_NATIVE_ID} from '@constants/post_draft'; import {useTheme} from '@context/theme'; import {useAppState, useIsTablet} from '@hooks/device'; import {useDefaultHeaderHeight} from '@hooks/header'; @@ -101,6 +103,7 @@ const Channel = ({channelId, componentId, displayName, isOwnDirectMessage, membe } const marginTop = defaultHeight + (isTablet ? insets.top : 0); + const channelIsSet = Boolean(channelId); return ( <> @@ -120,12 +123,22 @@ const Channel = ({channelId, componentId, displayName, isOwnDirectMessage, membe subtitleCompanion={subtitleCompanion} title={title} /> - - + + + + - + + } ); diff --git a/app/screens/channel/channel_post_list/channel_post_list.tsx b/app/screens/channel/channel_post_list/channel_post_list.tsx index d3f7f74fc..d7865236c 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.tsx +++ b/app/screens/channel/channel_post_list/channel_post_list.tsx @@ -1,14 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo, useRef} from 'react'; -import {StyleProp, ViewStyle} from 'react-native'; +import React, {useCallback, useRef} from 'react'; +import {StyleProp, StyleSheet, ViewStyle} from 'react-native'; +import {Edge, SafeAreaView} from 'react-native-safe-area-context'; import {fetchPostsBefore} from '@actions/remote/post'; import PostList from '@components/post_list'; import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {debounce} from '@helpers/api/general'; +import {useIsTablet} from '@hooks/device'; import {sortPostsByNewest} from '@utils/post'; import Intro from './intro'; @@ -22,11 +24,21 @@ type Props = { currentUsername: string; isTimezoneEnabled: boolean; lastViewedAt: number; + nativeID: string; posts: PostModel[]; shouldShowJoinLeaveMessages: boolean; } -const ChannelPostList = ({channelId, contentContainerStyle, currentTimezone, currentUsername, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages}: Props) => { +const edges: Edge[] = ['bottom']; +const styles = StyleSheet.create({ + flex: {flex: 1}, +}); + +const ChannelPostList = ({ + channelId, contentContainerStyle, currentTimezone, currentUsername, + isTimezoneEnabled, lastViewedAt, nativeID, posts, shouldShowJoinLeaveMessages, +}: Props) => { + const isTablet = useIsTablet(); const serverUrl = useServerUrl(); const canLoadPosts = useRef(true); const fetchingPosts = useRef(false); @@ -41,11 +53,9 @@ const ChannelPostList = ({channelId, contentContainerStyle, currentTimezone, cur } }, 500), [channelId, posts]); - const intro = useMemo(() => ( - - ), [channelId]); + const intro = ; - return ( + const postList = ( ); + + if (isTablet) { + return postList; + } + + return ( + + {postList} + + ); }; export default ChannelPostList; diff --git a/app/screens/edit_profile/components/disabled_fields.tsx b/app/screens/edit_profile/components/disabled_fields.tsx index 8b1dc4829..395248c77 100644 --- a/app/screens/edit_profile/components/disabled_fields.tsx +++ b/app/screens/edit_profile/components/disabled_fields.tsx @@ -5,7 +5,7 @@ import React, {useMemo} from 'react'; import {useIntl} from 'react-intl'; import {Text, View} from 'react-native'; -import {useTheme} from '@app/context/theme'; +import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; diff --git a/app/screens/edit_profile/components/form.tsx b/app/screens/edit_profile/components/form.tsx index 24965153f..5a8b85bf3 100644 --- a/app/screens/edit_profile/components/form.tsx +++ b/app/screens/edit_profile/components/form.tsx @@ -5,9 +5,9 @@ import React, {useCallback, useMemo, useRef} from 'react'; import {MessageDescriptor, useIntl} from 'react-intl'; import {Keyboard, StyleSheet, View} from 'react-native'; -import {FloatingTextInputRef} from '@app/components/floating_text_input_label'; -import {t} from '@app/i18n'; +import {FloatingTextInputRef} from '@components/floating_text_input_label'; import {useTheme} from '@context/theme'; +import {t} from '@i18n'; import DisabledFields from './disabled_fields'; import EmailField from './email_field'; diff --git a/app/screens/edit_profile/components/panel_item.tsx b/app/screens/edit_profile/components/panel_item.tsx index eb18d7aba..dfb66a6e2 100644 --- a/app/screens/edit_profile/components/panel_item.tsx +++ b/app/screens/edit_profile/components/panel_item.tsx @@ -8,7 +8,6 @@ import DocumentPicker from 'react-native-document-picker'; import SlideUpPanelItem from '@components/slide_up_panel_item'; import type {MessageDescriptor} from '@formatjs/intl/src/types'; -import type {UploadExtractedFile} from '@typings/utils/file'; import type PickerUtil from '@utils/file/file_picker'; import type {Source} from 'react-native-fast-image'; diff --git a/app/screens/edit_profile/components/profile_error.tsx b/app/screens/edit_profile/components/profile_error.tsx index 826bd10b9..f6283ead8 100644 --- a/app/screens/edit_profile/components/profile_error.tsx +++ b/app/screens/edit_profile/components/profile_error.tsx @@ -5,7 +5,7 @@ import React from 'react'; import {View} from 'react-native'; import CompassIcon from '@components/compass_icon'; -import ErrorText from '@components/error_text'; +import ErrorTextComponent from '@components/error_text'; import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -47,7 +47,7 @@ const ProfileError = ({error}: DisplayErrorProps) => { size={18} name='alert-outline' /> - ({ backgroundColor: theme.centerChannelBg, alignContent: 'center', flexDirection: 'row', - height: 52, + height: ViewConstants.BOTTOM_TAB_HEIGHT, justifyContent: 'center', }, item: { diff --git a/app/utils/channel/index.ts b/app/utils/channel/index.ts index 378f2d463..309c8a89a 100644 --- a/app/utils/channel/index.ts +++ b/app/utils/channel/index.ts @@ -5,8 +5,22 @@ import {General, Permissions} from '@constants'; import {DEFAULT_LOCALE} from '@i18n'; import {hasPermission} from '@utils/role'; -export function selectDefaultChannelForTeam(channels: Channel[], memberships: ChannelMembership[], teamId: string, roles?: Role[], locale = DEFAULT_LOCALE) { - let channel: Channel|undefined; +import type ChannelModel from '@typings/database/models/servers/channel'; + +export function getDirectChannelName(id: string, otherId: string): string { + let handle; + + if (otherId > id) { + handle = id + '__' + otherId; + } else { + handle = otherId + '__' + id; + } + + return handle; +} + +export function selectDefaultChannelForTeam(channels: T[], memberships: ChannelMembership[], teamId: string, roles?: Role[], locale = DEFAULT_LOCALE) { + let channel: T|undefined; let canIJoinPublicChannelsInTeam = false; if (roles) { @@ -14,8 +28,11 @@ export function selectDefaultChannelForTeam(channels: Channel[], memberships: Ch } const defaultChannel = channels?.find((c) => c.name === General.DEFAULT_CHANNEL); const iAmMemberOfTheTeamDefaultChannel = Boolean(defaultChannel && memberships?.find((m) => m.channel_id === defaultChannel.id)); - const myFirstTeamChannel = channels?.filter((c) => c.team_id === teamId && c.type === General.OPEN_CHANNEL && Boolean(memberships?.find((m) => c.id === m.channel_id))). - sort(sortChannelsByDisplayName.bind(null, locale))[0]; + const myFirstTeamChannel = channels?.filter((c) => + (('team_id' in c) ? c.team_id : c.teamId) === teamId && + c.type === General.OPEN_CHANNEL && + Boolean(memberships?.find((m) => c.id === m.channel_id), + )).sort(sortChannelsByDisplayName.bind(null, locale))[0]; if (iAmMemberOfTheTeamDefaultChannel || canIJoinPublicChannelsInTeam) { channel = defaultChannel; @@ -26,10 +43,21 @@ export function selectDefaultChannelForTeam(channels: Channel[], memberships: Ch return channel; } -export function sortChannelsByDisplayName(locale: string, a: Channel, b: Channel): number { +export function sortChannelsByDisplayName(locale: string, a: T, b: T): number { // if both channels have the display_name defined - if (a.display_name && b.display_name && a.display_name !== b.display_name) { - return a.display_name.toLowerCase().localeCompare(b.display_name.toLowerCase(), locale, {numeric: true}); + const aDisplayName = 'display_name' in a ? a.display_name : a.displayName; + const bDisplayName = 'display_name' in b ? b.display_name : b.displayName; + if (aDisplayName && bDisplayName && aDisplayName !== bDisplayName) { + return aDisplayName.toLowerCase().localeCompare(bDisplayName.toLowerCase(), locale, {numeric: true}); + } + + return a.name.toLowerCase().localeCompare(b.name.toLowerCase(), locale, {numeric: true}); +} + +export function sortChannelsModelByDisplayName(locale: string, a: ChannelModel, b: ChannelModel): number { + // if both channels have the display_name defined + if (a.displayName && b.displayName && a.displayName !== b.displayName) { + return a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase(), locale, {numeric: true}); } return a.name.toLowerCase().localeCompare(b.name.toLowerCase(), locale, {numeric: true}); diff --git a/app/utils/draft/index.ts b/app/utils/draft/index.ts index 8b3771a19..b0aceacc5 100644 --- a/app/utils/draft/index.ts +++ b/app/utils/draft/index.ts @@ -4,10 +4,16 @@ import {MessageDescriptor} from '@formatjs/intl/src/types'; import {Alert, AlertButton} from 'react-native'; +import {General} from '@constants'; +import {AT_MENTION_REGEX_GLOBAL, CODE_REGEX} from '@constants/autocomplete'; +import {NOTIFY_ALL_MEMBERS} from '@constants/post_draft'; import {t} from '@i18n'; +import type GroupModel from '@typings/database/models/servers/group'; import type {IntlShape} from 'react-intl'; +type AlertCallback = (value?: string) => void; + export function errorBadChannel(intl: IntlShape) { const message = { id: t('mobile.server_link.unreachable_channel.error'), @@ -17,6 +23,15 @@ export function errorBadChannel(intl: IntlShape) { return alertErrorWithFallback(intl, {}, message); } +export function errorUnkownUser(intl: IntlShape) { + const message = { + id: t('mobile.server_link.unreachable_user.error'), + defaultMessage: 'We can\'t redirect you to the DM. The user specified is unknown.', + }; + + alertErrorWithFallback(intl, {}, message); +} + export function permalinkBadTeam(intl: IntlShape) { const message = { id: t('mobile.server_link.unreachable_team.error'), @@ -33,3 +48,228 @@ export function alertErrorWithFallback(intl: IntlShape, error: any, fallback: Me } Alert.alert('', msg, buttons); } + +export function alertAttachmentFail(intl: IntlShape, accept: AlertCallback, cancel: AlertCallback) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.post_textbox.uploadFailedTitle', + defaultMessage: 'Attachment failure', + }), + intl.formatMessage({ + id: 'mobile.post_textbox.uploadFailedDesc', + defaultMessage: 'Some attachments failed to upload to the server. Are you sure you want to post the message?', + }), + [{ + text: intl.formatMessage({id: 'mobile.channel_info.alertNo', defaultMessage: 'No'}), + onPress: cancel, + }, { + text: intl.formatMessage({id: 'mobile.channel_info.alertYes', defaultMessage: 'Yes'}), + onPress: accept, + }], + ); +} + +export const textContainsAtAllAtChannel = (text: string) => { + const textWithoutCode = text.replace(CODE_REGEX, ''); + return (/(?:\B|\b_+)@(channel|all)(?!(\.|-|_)*[^\W_])/i).test(textWithoutCode); +}; + +export const textContainsAtHere = (text: string) => { + const textWithoutCode = text.replace(CODE_REGEX, ''); + return (/(?:\B|\b_+)@(here)(?!(\.|-|_)*[^\W_])/i).test(textWithoutCode); +}; + +export const groupsMentionedInText = (groupsWithAllowReference: GroupModel[], text: string) => { + if (!groupsWithAllowReference.length) { + return []; + } + const textWithoutCode = text.replace(CODE_REGEX, ''); + const mentions = textWithoutCode.match(AT_MENTION_REGEX_GLOBAL) || []; + return groupsWithAllowReference.filter((g) => mentions.includes(g.id)); +}; + +// mapGroupMentions remove duplicates from the groupMentions, and if any of the +// groups has more members than the NOTIFY_ALL_MEMBERS, return the highest +// number of notifications and the timezones of that group. +export const mapGroupMentions = (channelMemberCountsByGroup: ChannelMemberCountByGroup[], groupMentions: GroupModel[]) => { + let memberNotifyCount = 0; + let channelTimezoneCount = 0; + const groupMentionsSet = new Set(); + const mappedChannelMemberCountsByGroup: ChannelMemberCountsByGroup = {}; + channelMemberCountsByGroup.forEach((group) => { + mappedChannelMemberCountsByGroup[group.group_id] = group; + }); + groupMentions. + forEach((group) => { + const mappedValue = mappedChannelMemberCountsByGroup[group.id]; + if (mappedValue?.channel_member_count > NOTIFY_ALL_MEMBERS && mappedValue?.channel_member_count > memberNotifyCount) { + memberNotifyCount = mappedValue.channel_member_count; + channelTimezoneCount = mappedValue.channel_member_timezones_count; + } + if (group.name) { + groupMentionsSet.add(`@${group.name}`); + } + }); + return {groupMentionsSet, memberNotifyCount, channelTimezoneCount}; +}; + +export function buildGroupMentionsMessage(intl: IntlShape, groupMentions: string[], memberNotifyCount: number, channelTimezoneCount: number) { + let notifyAllMessage = ''; + + if (groupMentions.length === 1) { + if (channelTimezoneCount > 0) { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.one_group.message.with_timezones', + defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', + }, + { + mention: groupMentions[0], + totalMembers: memberNotifyCount, + timezones: channelTimezoneCount, + }, + ) + ); + } else { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.one_group.message.without_timezones', + defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people. Are you sure you want to do this?', + }, + { + mention: groupMentions[0], + totalMembers: memberNotifyCount, + }, + ) + ); + } + } else if (channelTimezoneCount > 0) { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.multi_group.message.with_timezones', + defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', + }, + { + mentions: groupMentions.slice(0, -1).join(', '), + finalMention: groupMentions[groupMentions.length - 1], + totalMembers: memberNotifyCount, + timezones: channelTimezoneCount, + }, + ) + ); + } else { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.multi_group.message.without_timezones', + defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people. Are you sure you want to do this?', + }, + { + mentions: groupMentions.slice(0, -1).join(', '), + finalMention: groupMentions[groupMentions.length - 1], + totalMembers: memberNotifyCount, + }, + ) + ); + } + + return notifyAllMessage; +} + +export function buildChannelWideMentionMessage(intl: IntlShape, membersCount: number, isTimezoneEnabled: boolean, channelTimezoneCount: number, atHere: boolean) { + let notifyAllMessage = ''; + if (isTimezoneEnabled && channelTimezoneCount) { + const msgID = atHere ? t('mobile.post_textbox.entire_channel_here.message.with_timezones') : t('mobile.post_textbox.entire_channel.message.with_timezones'); + const atHereMsg = 'By using @here you are about to send notifications up to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?'; + const atAllOrChannelMsg = 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?'; + + notifyAllMessage = ( + intl.formatMessage( + { + id: msgID, + defaultMessage: atHere ? atHereMsg : atAllOrChannelMsg, + }, + { + totalMembers: membersCount - 1, + timezones: channelTimezoneCount, + }, + ) + ); + } else { + const msgID = atHere ? t('mobile.post_textbox.entire_channel_here.message') : t('mobile.post_textbox.entire_channel.message'); + const atHereMsg = 'By using @here you are about to send notifications to up to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?'; + const atAllOrChannelMsg = 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?'; + + notifyAllMessage = ( + intl.formatMessage( + { + id: msgID, + defaultMessage: atHere ? atHereMsg : atAllOrChannelMsg, + }, + { + totalMembers: membersCount - 1, + }, + ) + ); + } + + return notifyAllMessage; +} + +export function alertChannelWideMention(intl: IntlShape, notifyAllMessage: string, accept: AlertCallback, cancel: AlertCallback) { + const message = intl.formatMessage({ + id: 'mobile.post_textbox.entire_channel.title', + defaultMessage: 'Confirm sending notifications to entire channel', + }); + alertMessage(intl, message, notifyAllMessage, accept, cancel); +} + +export function alertSendToGroups(intl: IntlShape, notifyAllMessage: string, accept: AlertCallback, cancel: AlertCallback) { + const message = intl.formatMessage({ + id: 'mobile.post_textbox.groups.title', + defaultMessage: 'Confirm sending notifications to groups', + }); + alertMessage(intl, message, notifyAllMessage, accept, cancel); +} + +function alertMessage(intl: IntlShape, message: string, notifyAllMessage: string, accept: AlertCallback, cancel: AlertCallback) { + Alert.alert( + message, + notifyAllMessage, + [ + { + text: intl.formatMessage({ + id: 'mobile.post_textbox.entire_channel.cancel', + defaultMessage: 'Cancel', + }), + onPress: cancel, + }, + { + text: intl.formatMessage({ + id: 'mobile.post_textbox.entire_channel.confirm', + defaultMessage: 'Confirm', + }), + onPress: accept, + }, + ], + ); +} + +export const getStatusFromSlashCommand = (message: string) => { + const tokens = message.split(' '); + const command = tokens[0]?.substring(1); + return General.STATUS_COMMANDS.includes(command) ? command : ''; +}; + +export function alertSlashCommandFailed(intl: IntlShape, error: string) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.commands.error_title', + defaultMessage: 'Error Executing Command', + }), + error, + ); +} diff --git a/app/utils/emoji/helpers.ts b/app/utils/emoji/helpers.ts index fb2fb82b4..746112639 100644 --- a/app/utils/emoji/helpers.ts +++ b/app/utils/emoji/helpers.ts @@ -5,7 +5,9 @@ import emojiRegex from 'emoji-regex'; import SystemModel from '@database/models/server/system'; -import {Emojis, EmojiIndicesByAlias} from './'; +import {Emojis, EmojiIndicesByAlias, EmojiIndicesByUnicode} from './'; + +import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; const RE_NAMED_EMOJI = /(:([a-zA-Z0-9_+-]+):)/g; @@ -32,6 +34,9 @@ const RE_EMOTICON: Record = { broken_heart: /(^|\s)(<\/3|</3)(?=$|\s)/g, // value.match(RE_EMOTICON[key]) !== null); } +export function matchEmoticons(text: string): string[] { + let emojis: string[] = text.match(RE_NAMED_EMOJI) || []; + + for (const name of Object.keys(RE_EMOTICON)) { + const pattern = RE_EMOTICON[name]; + + const matches = text.match(pattern); + if (matches) { + emojis = emojis.concat(matches); + } + } + + const matchUnicodeEmoji = text.match(RE_UNICODE_EMOJI); + if (matchUnicodeEmoji) { + emojis = emojis.concat(matchUnicodeEmoji); + } + + return emojis; +} + +export function getValidEmojis(emojis: string[], customEmojis: CustomEmojiModel[]) { + const emojiNames = new Set(); + const customEmojiNames = customEmojis.map((v) => v.name); + + for (const emoji of emojis) { + const emojiName = getEmojiName(emoji, customEmojiNames); + if (emojiName) { + emojiNames.add(emojiName); + } + } + + return Array.from(emojiNames); +} + +export function getEmojiName(emoji: string, customEmojiNames: string[]) { + if (doesMatchNamedEmoji(emoji)) { + const emojiName = emoji.substring(1, emoji.length - 1); + if (isValidNamedEmoji(emojiName, customEmojiNames)) { + return emojiName; + } + } + + const matchUnicodeEmoji = emoji.match(RE_UNICODE_EMOJI); + if (matchUnicodeEmoji) { + const index = EmojiIndicesByUnicode.get(matchUnicodeEmoji[0]); + if (index != null) { + return fillEmoji(Emojis[index]).name; + } + return undefined; + } + + const emojiName = getEmoticonName(emoji); + if (emojiName) { + return emojiName; + } + + return undefined; +} + +export function isReactionMatch(value: string, customEmojis: CustomEmojiModel[]) { + const customEmojiNames = customEmojis.map((v) => v.name); + + const match = value.match(RE_REACTION); + if (!match) { + return null; + } + + if (!isValidNamedEmoji(match[2], customEmojiNames)) { + return null; + } + + return { + add: match[1] === '+', + emoji: match[2], + }; +} + +export function isValidNamedEmoji(emojiName: string, customEmojiNames: string[]) { + if (EmojiIndicesByAlias.has(emojiName)) { + return true; + } + + if (customEmojiNames.includes(emojiName)) { + return true; + } + + return 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); diff --git a/app/utils/file/file_picker/index.ts b/app/utils/file/file_picker/index.ts index 28f78e95c..39dbf444b 100644 --- a/app/utils/file/file_picker/index.ts +++ b/app/utils/file/file_picker/index.ts @@ -12,8 +12,6 @@ import Permissions, {AndroidPermission, IOSPermission} from 'react-native-permis import {Navigation} from '@constants'; import {extractFileInfo, lookupMimeType} from '@utils/file'; -import type {ExtractedFileInfo} from '@typings/utils/file'; - const ShareExtension = NativeModules.MattermostShare; type PermissionSource = 'camera' | 'storage' | 'denied_android' | 'denied_ios' | 'photo'; diff --git a/app/utils/file/index.ts b/app/utils/file/index.ts index 3087ffbc9..c7ccdb412 100644 --- a/app/utils/file/index.ts +++ b/app/utils/file/index.ts @@ -17,7 +17,6 @@ import {hashCode} from '@utils/security'; import {removeProtocol} from '@utils/url'; import type FileModel from '@typings/database/models/servers/file'; -import type {ExtractedFileInfo} from '@typings/utils/file'; const EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/; const CONTENT_DISPOSITION_REGEXP = /inline;filename=".*\.([a-z]+)";/i; diff --git a/app/utils/role/index.ts b/app/utils/role/index.ts index 5db2ffa62..3da72a4c0 100644 --- a/app/utils/role/index.ts +++ b/app/utils/role/index.ts @@ -24,32 +24,39 @@ export function hasPermission(roles: RoleModel[] | Role[], permission: string, d return defaultValue === true || exists; } -export async function hasPermissionForPost(post: PostModel, user: UserModel, permission: string, defaultValue: boolean) { +export async function hasPermissionForChannel(channel: ChannelModel, 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(' ')); - } + 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 roles = await user.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 hasPermissionForPost(post: PostModel, user: UserModel, permission: string, defaultValue: boolean) { + const channel = await post.channel.fetch() as ChannelModel | undefined; + if (channel) { + return hasPermissionForChannel(channel, user, 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; diff --git a/app/utils/url/index.ts b/app/utils/url/index.ts index 93db88e34..b7de29e35 100644 --- a/app/utils/url/index.ts +++ b/app/utils/url/index.ts @@ -159,6 +159,11 @@ export function parseDeepLink(deepLinkUrl: string): DeepLinkWithData { return {type: DeepLinkType.GroupMessage, data: {serverUrl: match[1], teamName: match[2], channelId: match[3]}}; } + match = new RegExp('(.*)\\/plugins\\/([^\\/]+)\\/(\\S+)').exec(url); + if (match) { + return {type: DeepLinkType.Plugin, data: {serverUrl: match[1], id: match[2], teamName: ''}}; + } + return {type: DeepLinkType.Invalid}; } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index ae42daa39..458d075c6 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -24,6 +24,11 @@ "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.", + "archivedChannelMessage": "You are viewing an **archived channel**. New messages cannot be posted.", + "camera_type.photo.option": "Capture Photo", + "camera_type.title": "Choose an action", + "camera_type.video.option": "Record Video", + "center_panel.archived.closeChannel": "Close Channel", "channel": "{count, plural, one {# member} other {# members}}", "channel_header.directchannel.you": "{displayname} (you)", "channel_loader.someone": "Someone", @@ -61,6 +66,9 @@ "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", + "create_comment.addComment": "Add a comment...", + "create_post.deactivated": "You are viewing an archived channel with a deactivated user.", + "create_post.write": "Write to {channelDisplayName}", "custom_status.expiry_dropdown.custom": "Custom", "custom_status.expiry_dropdown.date_and_time": "Date and Time", "custom_status.expiry_dropdown.dont_clear": "Don't clear", @@ -102,6 +110,7 @@ "emoji_skin.medium_dark_skin_tone": "medium dark skin tone", "emoji_skin.medium_light_skin_tone": "medium light skin tone", "emoji_skin.medium_skin_tone": "medium skin tone", + "file_upload.fileAbove": "Files must be less than {max}", "intro.add_people": "Add People", "intro.channel_details": "Details", "intro.created_by": "created by {creator} on {date}.", @@ -154,6 +163,13 @@ "mobile.action_menu.select": "Select an option", "mobile.add_team.create_team": "Create a New Team", "mobile.add_team.join_team": "Join Another Team", + "mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.", + "mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos", + "mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your camera.", + "mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera", + "mobile.channel_info.alertNo": "No", + "mobile.channel_info.alertYes": "Yes", + "mobile.commands.error_title": "Error Executing Command", "mobile.components.select_server_view.connect": "Connect", "mobile.components.select_server_view.connecting": "Connecting", "mobile.components.select_server_view.displayHelp": "Choose a display name for your server", @@ -163,6 +179,7 @@ "mobile.components.select_server_view.msg_description": "A Server is your team's communication hub which is accessed through a unique URL", "mobile.components.select_server_view.msg_welcome": "Welcome", "mobile.components.select_server_view.proceed": "Proceed", + "mobile.create_post.read_only": "This channel is read-only.", "mobile.custom_status.choose_emoji": "Choose an emoji", "mobile.custom_status.clear_after": "Clear After", "mobile.custom_status.clear_after.title": "Clear Custom Status After", @@ -178,6 +195,13 @@ "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", + "mobile.file_upload.disabled2": "File uploads from mobile are disabled.", + "mobile.file_upload.max_warning": "Uploads limited to {count} files maximum.", + "mobile.files_paste.error_description": "An error occurred while pasting the file(s). Please try again.", + "mobile.files_paste.error_dismiss": "Dismiss", + "mobile.files_paste.error_title": "Paste failed", + "mobile.ios.photos_permission_denied_description": "Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.", + "mobile.ios.photos_permission_denied_title": "{applicationName} would like to access your photos", "mobile.join_channel.error": "We couldn't join the channel {displayName}. Please check your connection and try again.", "mobile.link.error.text": "Unable to open the link.", "mobile.link.error.title": "Error", @@ -206,6 +230,8 @@ "mobile.markdown.image.too_large": "Image exceeds max dimensions of {maxWidth} by {maxHeight}:", "mobile.markdown.link.copy_url": "Copy URL", "mobile.mention.copy_mention": "Copy Mention", + "mobile.message_length.message": "Your current message is too long. Current character count: {count}/{max}", + "mobile.message_length.title": "Message Length", "mobile.notice_mobile_link": "mobile apps", "mobile.notice_platform_link": "server", "mobile.notice_text": "Mattermost is made possible by the open source software used in our {platform} and {mobile}.", @@ -218,10 +244,26 @@ "mobile.oauth.switch_to_browser.error_title": "Sign in error", "mobile.oauth.switch_to_browser.title": "Redirecting...", "mobile.oauth.try_again": "Try again", + "mobile.permission_denied_dismiss": "Don't Allow", + "mobile.permission_denied_retry": "Settings", "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_textbox.entire_channel_here.message": "By using @here you are about to send notifications to up to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?", + "mobile.post_textbox.entire_channel_here.message.with_timezones": "By using @here you are about to send notifications up to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?", + "mobile.post_textbox.entire_channel.cancel": "Cancel", + "mobile.post_textbox.entire_channel.confirm": "Confirm", + "mobile.post_textbox.entire_channel.message": "By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?", + "mobile.post_textbox.entire_channel.message.with_timezones": "By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?", + "mobile.post_textbox.entire_channel.title": "Confirm sending notifications to entire channel", + "mobile.post_textbox.groups.title": "Confirm sending notifications to groups", + "mobile.post_textbox.multi_group.message.with_timezones": "By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?", + "mobile.post_textbox.multi_group.message.without_timezones": "By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people. Are you sure you want to do this?", + "mobile.post_textbox.one_group.message.with_timezones": "By using {mention} you are about to send notifications to {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?", + "mobile.post_textbox.one_group.message.without_timezones": "By using {mention} you are about to send notifications to {totalMembers} people. Are you sure you want to do this?", + "mobile.post_textbox.uploadFailedDesc": "Some attachments failed to upload to the server. Are you sure you want to post the message?", + "mobile.post_textbox.uploadFailedTitle": "Attachment failure", "mobile.post.cancel": "Cancel", "mobile.post.failed_delete": "Delete Message", "mobile.post.failed_retry": "Try Again", @@ -242,8 +284,11 @@ "mobile.routes.user_profile": "Profile", "mobile.screen.your_profile": "Your Profile", "mobile.server_identifier.exists": "You are already connected to this server.", + "mobile.server_link.error.text": "The link could not be found on this server.", + "mobile.server_link.error.title": "Link Error", "mobile.server_link.unreachable_channel.error": "This link belongs to a deleted channel or to a channel to which you do not have access.", "mobile.server_link.unreachable_team.error": "This link belongs to a deleted team or to a team to which you do not have access.", + "mobile.server_link.unreachable_user.error": "We can't redirect you to the DM. The user specified is unknown.", "mobile.server_name.exists": "You are using this name for another server.", "mobile.server_ping_failed": "Cannot connect to the server.", "mobile.server_requires_client_certificate": "Server requires client certificate for authentication.", @@ -261,6 +306,8 @@ "mobile.set_status.dnd": "Do Not Disturb", "mobile.set_status.offline": "Offline", "mobile.set_status.online": "Online", + "mobile.storage_permission_denied_description": "Upload files to your server. Open Settings to grant {applicationName} Read and Write access to files on this device.", + "mobile.storage_permission_denied_title": "{applicationName} would like to access your files", "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}", @@ -282,6 +329,8 @@ "modal.manual_status.auto_responder.message_offline": "Would you like to switch your status to \"Offline\" and disable Automatic Replies?", "modal.manual_status.auto_responder.message_online": "Would you like to switch your status to \"Online\" and disable Automatic Replies?", "more_messages.text": "{count} new {count, plural, one {message} other {messages}}", + "msg_typing.areTyping": "{users} and {last} are typing...", + "msg_typing.isTyping": "{user} is typing...", "notification.message_not_found": "Message not found", "notification.not_channel_member": "This message belongs to a channel where you are not a member.", "notification.not_team_member": "This message belongs to a team where you are not a member.", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d8fd74be3..224e06a39 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -281,7 +281,7 @@ PODS: - SwiftyJSON (~> 5.0) - react-native-notifications (4.1.3): - React-Core - - react-native-paste-input (0.3.6): + - react-native-paste-input (0.3.7): - React-Core - Swime (= 3.0.6) - react-native-safe-area-context (3.3.2): @@ -782,7 +782,7 @@ SPEC CHECKSUMS: react-native-netinfo: 87e5bfaf21ea5c6c110941290aa481dd8e849f98 react-native-network-client: 30ab97e7e6c8d6f2d2b10cc1ebad0cbf9c894c6e react-native-notifications: 805108822ceff3440644d5701944f0cda35f5b4b - react-native-paste-input: 80c06e2c5c65afd696f9bd43cfd371141d8b3a1b + react-native-paste-input: 7d19610119115a3434c145867775723a06052362 react-native-safe-area-context: 584dc04881deb49474363f3be89e4ca0e854c057 react-native-video: a4c2635d0802f983594b7057e1bce8f442f0ad28 react-native-webview: 162b6453d074e0b1c7025242bb7a939b6f72b9e7 diff --git a/package-lock.json b/package-lock.json index fed661cfb..22cc50cb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@formatjs/intl-relativetimeformat": "9.5.1", "@mattermost/react-native-emm": "1.1.8", "@mattermost/react-native-network-client": "github:mattermost/react-native-network-client", - "@mattermost/react-native-paste-input": "0.3.6", + "@mattermost/react-native-paste-input": "0.3.7", "@nozbe/watermelondb": "0.24.0", "@nozbe/with-observables": "1.4.0", "@react-native-async-storage/async-storage": "1.15.17", @@ -3562,9 +3562,9 @@ } }, "node_modules/@mattermost/react-native-paste-input": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-0.3.6.tgz", - "integrity": "sha512-/XwUkWfkXDPv1/N+3sILKRoqa4sElqN/fADQzkC2KHYxVKN72297vMm8s+X1n2l+y7phNQ8ZmhjjL0ghuz/1og==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-0.3.7.tgz", + "integrity": "sha512-QrvKjA7m963ad8q5BRCXeHv7RID9YmWrnUe+nxBJrqiJI0lomepxHoDd9hhlCADqwKU2ufYEnpeGNblwzG+BaA==", "peerDependencies": { "react": "*", "react-native": "*" @@ -26806,9 +26806,9 @@ } }, "@mattermost/react-native-paste-input": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-0.3.6.tgz", - "integrity": "sha512-/XwUkWfkXDPv1/N+3sILKRoqa4sElqN/fADQzkC2KHYxVKN72297vMm8s+X1n2l+y7phNQ8ZmhjjL0ghuz/1og==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-0.3.7.tgz", + "integrity": "sha512-QrvKjA7m963ad8q5BRCXeHv7RID9YmWrnUe+nxBJrqiJI0lomepxHoDd9hhlCADqwKU2ufYEnpeGNblwzG+BaA==", "requires": {} }, "@nicolo-ribaudo/chokidar-2": { diff --git a/package.json b/package.json index ed73501c2..0a08ff189 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@formatjs/intl-relativetimeformat": "9.5.1", "@mattermost/react-native-emm": "1.1.8", "@mattermost/react-native-network-client": "github:mattermost/react-native-network-client", - "@mattermost/react-native-paste-input": "0.3.6", + "@mattermost/react-native-paste-input": "0.3.7", "@nozbe/watermelondb": "0.24.0", "@nozbe/with-observables": "1.4.0", "@react-native-async-storage/async-storage": "1.15.17", diff --git a/patches/react-native-hw-keyboard-event+0.0.4.patch b/patches/react-native-hw-keyboard-event+0.0.4.patch new file mode 100644 index 000000000..b1f2cf33b --- /dev/null +++ b/patches/react-native-hw-keyboard-event+0.0.4.patch @@ -0,0 +1,10 @@ +diff --git a/node_modules/react-native-hw-keyboard-event/index.d.ts b/node_modules/react-native-hw-keyboard-event/index.d.ts +index 91999f1..53c7a42 100644 +--- a/node_modules/react-native-hw-keyboard-event/index.d.ts ++++ b/node_modules/react-native-hw-keyboard-event/index.d.ts +@@ -1,4 +1,4 @@ + declare module "react-native-hw-keyboard-event"; + +-export function onHWKeyPressed(hwKeyEvent: { pressedKey: string }): void; ++export function onHWKeyPressed(callback: (hwKeyEvent: { pressedKey: string }) => void): void; + export function removeOnHWKeyPressed(): void; diff --git a/patches/react-native-keyboard-tracking-view+5.7.0.patch b/patches/react-native-keyboard-tracking-view+5.7.0.patch index 7a4470a10..ada195c1b 100644 --- a/patches/react-native-keyboard-tracking-view+5.7.0.patch +++ b/patches/react-native-keyboard-tracking-view+5.7.0.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-keyboard-tracking-view/lib/KeyboardTrackingViewManager.m b/node_modules/react-native-keyboard-tracking-view/lib/KeyboardTrackingViewManager.m -index 1333a10..6922a17 100644 +index 1333a10..f0515fd 100644 --- a/node_modules/react-native-keyboard-tracking-view/lib/KeyboardTrackingViewManager.m +++ b/node_modules/react-native-keyboard-tracking-view/lib/KeyboardTrackingViewManager.m @@ -23,7 +23,7 @@ @@ -11,7 +11,7 @@ index 1333a10..6922a17 100644 typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { KeyboardTrackingScrollBehaviorNone, -@@ -40,6 +40,7 @@ @interface KeyboardTrackingView : UIView +@@ -40,6 +40,7 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { CGFloat _bottomViewHeight; } @@ -19,13 +19,14 @@ index 1333a10..6922a17 100644 @property (nonatomic, strong) UIScrollView *scrollViewToManage; @property (nonatomic) BOOL scrollIsInverted; @property (nonatomic) BOOL revealKeyboardInteractive; -@@ -53,6 +54,13 @@ @interface KeyboardTrackingView : UIView +@@ -53,6 +54,14 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { @property (nonatomic) BOOL scrollToFocusedInput; @property (nonatomic) BOOL allowHitsOutsideBounds; +@property (nonatomic) BOOL normalList; +@property (nonatomic) NSString* scrollViewNativeID; +@property (nonatomic) CGFloat initialOffsetY; ++@property (nonatomic) NSInteger viewInitialOffsetY; +@property (nonatomic) BOOL initialOffsetIsSet; +@property (nonatomic, strong) UIView *accessoriesContainer; +@property (nonatomic) NSString* accessoriesContainerID; @@ -33,7 +34,7 @@ index 1333a10..6922a17 100644 @end @interface KeyboardTrackingView () -@@ -70,12 +78,17 @@ -(instancetype)init +@@ -70,12 +79,18 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { [self addObserver:self forKeyPath:@"bounds" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:NULL]; _inputViewsMap = [NSMapTable weakToWeakObjectsMapTable]; _deferedInitializeAccessoryViewsCount = 0; @@ -44,6 +45,7 @@ index 1333a10..6922a17 100644 + _initialOffsetY = 0; + _initialOffsetIsSet = NO; ++ _viewInitialOffsetY = 0; + _manageScrollView = YES; _allowHitsOutsideBounds = NO; @@ -51,7 +53,7 @@ index 1333a10..6922a17 100644 _bottomViewHeight = kBottomViewHeight; -@@ -134,7 +147,7 @@ -(void)_swizzleWebViewInputAccessory:(WKWebView*)webview +@@ -134,7 +149,7 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { UIView* subview; for (UIView* view in webview.scrollView.subviews) { @@ -60,7 +62,7 @@ index 1333a10..6922a17 100644 { subview = view; } -@@ -167,33 +180,32 @@ -(void)layoutSubviews +@@ -167,33 +182,32 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { - (void)initializeAccessoryViewsAndHandleInsets { NSArray* allSubviews = [self getBreadthFirstSubviewsForView:[self getRootView]]; @@ -109,7 +111,7 @@ index 1333a10..6922a17 100644 } if ([subview isKindOfClass:NSClassFromString(@"RCTTextField")]) -@@ -242,7 +254,7 @@ - (void)initializeAccessoryViewsAndHandleInsets +@@ -242,7 +256,7 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { } } @@ -118,7 +120,7 @@ index 1333a10..6922a17 100644 { if(scrollView.scrollView == _scrollViewToManage) { -@@ -267,6 +279,21 @@ - (void)initializeAccessoryViewsAndHandleInsets +@@ -267,6 +281,21 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { [self addBottomViewIfNecessary]; } @@ -140,7 +142,7 @@ index 1333a10..6922a17 100644 - (void)setupTextView:(UITextView*)textView { if (textView != nil) -@@ -343,7 +370,7 @@ - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(N +@@ -343,7 +372,7 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { - (void)observingInputAccessoryViewKeyboardWillDisappear:(ObservingInputAccessoryView *)observingInputAccessoryView { @@ -149,7 +151,7 @@ index 1333a10..6922a17 100644 [self updateBottomViewFrame]; } -@@ -388,32 +415,42 @@ - (void)_updateScrollViewInsets +@@ -388,32 +417,42 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { { if(self.scrollViewToManage != nil) { @@ -197,7 +199,7 @@ index 1333a10..6922a17 100644 } } else if(self.scrollBehavior == KeyboardTrackingScrollBehaviorFixedOffset && !self.isDraggingScrollView) -@@ -422,16 +459,21 @@ - (void)_updateScrollViewInsets +@@ -422,16 +461,21 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { self.scrollViewToManage.contentOffset = CGPointMake(originalOffset.x, originalOffset.y + insetsDiff); } @@ -207,7 +209,7 @@ index 1333a10..6922a17 100644 - insets.top = bottomInset; + CGFloat kHeight = _observingInputAccessoryView.keyboardHeight; + if (kHeight != 0 && (_observingInputAccessoryView.keyboardState == KeyboardStateShown || _observingInputAccessoryView.keyboardState == KeyboardStateWillShow)) { -+ kHeight -= bottomSafeArea; ++ kHeight -= (bottomSafeArea + _viewInitialOffsetY); } - else - { @@ -219,7 +221,7 @@ index 1333a10..6922a17 100644 + self.scrollViewToManage.frame = frame; + + if (self.accessoriesContainer) { -+ CGFloat containerPositionY = self.normalList ? 0 : _observingInputAccessoryView.keyboardHeight; ++ CGFloat containerPositionY = self.normalList ? 0 : kHeight; + self.accessoriesContainer.bounds = CGRectMake(self.accessoriesContainer.bounds.origin.x, containerPositionY, + self.accessoriesContainer.bounds.size.width, self.accessoriesContainer.bounds.size.height); } @@ -227,7 +229,7 @@ index 1333a10..6922a17 100644 } } -@@ -448,7 +490,6 @@ -(void)addBottomViewIfNecessary +@@ -448,7 +492,6 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { if (self.addBottomView && _bottomView == nil) { _bottomView = [UIView new]; @@ -235,7 +237,7 @@ index 1333a10..6922a17 100644 [self addSubview:_bottomView]; [self updateBottomViewFrame]; } -@@ -467,6 +508,12 @@ -(void)updateBottomViewFrame +@@ -467,6 +510,12 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { } } @@ -248,8 +250,12 @@ index 1333a10..6922a17 100644 #pragma mark - safe area -(void)safeAreaInsetsDidChange -@@ -510,7 +557,7 @@ -(void)updateTransformAndInsets - CGFloat accessoryTranslation = MIN(-bottomSafeArea, -_observingInputAccessoryView.keyboardHeight); +@@ -507,10 +556,10 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { + -(void)updateTransformAndInsets + { + CGFloat bottomSafeArea = [self getBottomSafeArea]; +- CGFloat accessoryTranslation = MIN(-bottomSafeArea, -_observingInputAccessoryView.keyboardHeight); ++ CGFloat accessoryTranslation = MIN(-bottomSafeArea, -(_observingInputAccessoryView.keyboardHeight - _viewInitialOffsetY)); if (_observingInputAccessoryView.keyboardHeight <= bottomSafeArea) { - _bottomViewHeight = kBottomViewHeight; @@ -257,7 +263,7 @@ index 1333a10..6922a17 100644 } else if (_observingInputAccessoryView.keyboardState != KeyboardStateWillHide) { _bottomViewHeight = 0; } -@@ -582,6 +629,8 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView +@@ -582,6 +631,8 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { self.isDraggingScrollView = YES; @@ -266,7 +272,7 @@ index 1333a10..6922a17 100644 } - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset -@@ -592,6 +641,15 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoi +@@ -592,6 +643,15 @@ typedef NS_ENUM(NSUInteger, KeyboardTrackingScrollBehavior) { - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { self.isDraggingScrollView = NO; @@ -282,11 +288,12 @@ index 1333a10..6922a17 100644 } - (CGFloat)getKeyboardHeight -@@ -634,6 +692,12 @@ @implementation KeyboardTrackingViewManager +@@ -634,6 +694,13 @@ RCT_REMAP_VIEW_PROPERTY(requiresSameParentToManageScrollView, requiresSameParent RCT_REMAP_VIEW_PROPERTY(addBottomView, addBottomView, BOOL) RCT_REMAP_VIEW_PROPERTY(scrollToFocusedInput, scrollToFocusedInput, BOOL) RCT_REMAP_VIEW_PROPERTY(allowHitsOutsideBounds, allowHitsOutsideBounds, BOOL) +RCT_REMAP_VIEW_PROPERTY(normalList, normalList, BOOL) ++RCT_REMAP_VIEW_PROPERTY(viewInitialOffsetY, viewInitialOffsetY, NSInteger) +RCT_EXPORT_VIEW_PROPERTY(scrollViewNativeID, NSString) +RCT_EXPORT_VIEW_PROPERTY(accessoriesContainerID, NSString) +RCT_CUSTOM_VIEW_PROPERTY(backgroundColor, UIColor, KeyboardTrackingView) { @@ -295,7 +302,7 @@ index 1333a10..6922a17 100644 + (BOOL)requiresMainQueueSetup { -@@ -654,6 +718,20 @@ - (UIView *)view +@@ -654,6 +721,20 @@ RCT_REMAP_VIEW_PROPERTY(allowHitsOutsideBounds, allowHitsOutsideBounds, BOOL) return [[KeyboardTrackingView alloc] init]; } diff --git a/types/api/client.d.ts b/types/api/client.d.ts index 27a2fb8a5..1402eb2e9 100644 --- a/types/api/client.d.ts +++ b/types/api/client.d.ts @@ -6,6 +6,7 @@ type logLevel = 'ERROR' | 'WARNING' | 'INFO'; type ClientOptions = { body?: any; method?: string; + noRetry?: boolean; }; interface ClientErrorProps extends Error { diff --git a/types/api/commands.d.ts b/types/api/commands.d.ts new file mode 100644 index 000000000..b8e948731 --- /dev/null +++ b/types/api/commands.d.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +interface CommandResponse { + goto_location?: string; + trigger_id?: string; +} diff --git a/types/api/config.d.ts b/types/api/config.d.ts index 1d58ad501..4d1aeb4b9 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -133,6 +133,7 @@ interface ClientConfig { LdapPositionAttributeSet: string; LockTeammateNameDisplay: string; MaxFileSize: string; + MaxPostSize: string; MaxNotificationsPerChannel: string; MinimumHashtagLength: string; OpenIdButtonColor: string; diff --git a/types/api/files.d.ts b/types/api/files.d.ts index b78574b41..b6d0f5366 100644 --- a/types/api/files.d.ts +++ b/types/api/files.d.ts @@ -3,6 +3,7 @@ type FileInfo = { id?: string; + bytesRead?: number; clientId?: string; create_at: number; delete_at: number; @@ -10,14 +11,11 @@ type FileInfo = { failed?: boolean; has_preview_image: boolean; height: number; - loading?: boolean; localPath?: string; mime_type: string; mini_preview?: string; name: string; post_id: string; - progress?: number; - bytesRead?: number; size: number; update_at: number; uri?: string; diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index 044e7fe1b..5845f3b6e 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -61,7 +61,6 @@ type Post = { file_ids?: any[]; metadata: PostMetadata; last_reply_at?: number; - failed?: boolean; user_activity_posts?: Post[]; state?: 'DELETED'; prev_post_id?: string; diff --git a/types/components/post_draft_quick_action.d.ts b/types/components/post_draft_quick_action.d.ts new file mode 100644 index 000000000..310bd5b0d --- /dev/null +++ b/types/components/post_draft_quick_action.d.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Asset} from 'react-native-image-picker'; + +export interface QuickActionAttachmentProps { + disabled: boolean; + fileCount?: number; + maxFilesReached: boolean; + maxFileCount: number; + onUploadFiles: (files: Asset[]) => void; + testID?: string; +} diff --git a/types/launch/index.ts b/types/launch/index.ts index e61ea11f6..f59654026 100644 --- a/types/launch/index.ts +++ b/types/launch/index.ts @@ -22,19 +22,24 @@ export interface DeepLinkGM extends DeepLink { channelId: string; } +export interface DeepLinkPlugin extends DeepLink { + id: string; +} + export const DeepLinkType = { Channel: 'channel', DirectMessage: 'dm', GroupMessage: 'gm', Invalid: 'invalid', Permalink: 'permalink', + Plugin: 'plugin', } as const; export type DeepLinkType = typeof DeepLinkType[keyof typeof DeepLinkType]; export interface DeepLinkWithData { type: DeepLinkType; - data?: DeepLinkChannel | DeepLinkDM | DeepLinkGM | DeepLinkPermalink; + data?: DeepLinkChannel | DeepLinkDM | DeepLinkGM | DeepLinkPermalink | DeepLinkPlugin; } export const LaunchType = { diff --git a/types/modules/react-native-keyboard-tracking-view.d.ts b/types/modules/react-native-keyboard-tracking-view.d.ts index eadbd65bd..316d164f7 100644 --- a/types/modules/react-native-keyboard-tracking-view.d.ts +++ b/types/modules/react-native-keyboard-tracking-view.d.ts @@ -1,4 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -declare module 'react-native-keyboard-tracking-view' +declare module 'react-native-keyboard-tracking-view' { + import {ViewProps} from 'react-native'; + export interface KeyboardTrackingViewRef { + resetScrollView: (id: string) => void; + setNativeProps(nativeProps: object): void; + } + + interface KeyboardTrackingViewProps extends ViewProps{ + accessoriesContainerID?: string; + normalList?: boolean; + scrollViewNativeID?: string; + viewInitialOffsetY?: number; + } + export const KeyboardTrackingView: React.ForwardRefExoticComponent>; +} diff --git a/types/utils/file.d.ts b/types/utils/file.d.ts index 3edf71fa4..70eb5b152 100644 --- a/types/utils/file.d.ts +++ b/types/utils/file.d.ts @@ -1,10 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import * as FileSystem from 'expo-file-system'; +type ErrorText = Partial | string; -export type ErrorText = Partial | string; +type ExtractedFileInfo = Partial & { name: string; mime_type: string} -export type ExtractedFileInfo = Partial & { name: string; mime_type: string} - -export type UploadExtractedFile = (files?: ExtractedFileInfo[]) => void; +type UploadExtractedFile = (files?: ExtractedFileInfo[]) => void;