From 7f91a6a78ad8767d3806ed9ccfdd0d77ac2bf462 Mon Sep 17 00:00:00 2001 From: Avinash Lingaloo Date: Tue, 12 Oct 2021 19:24:24 +0400 Subject: [PATCH] Gekidou - Account Screen (#5708) * Added DrawerItem component * WIP Account Screen * Added react-native-paper * Added StatusLabel Component * Extracted i18n * TS fix DrawerItem component * WIP Account Screen * Added server name label under log out * Updated translation * WIP * Fixes the Offline text style * Added Metropolis fonts * WIP * Typo clean up * WIP * WIP * WIP * Added server display name * Writing OpenSans properly * WIP * WIP * Added OptionsModal * Opening OptionsModal * Added translation keys * Writes status to local db * Fix missing translation * Fix OptionModal not dismissing * Pushing status to server * Refactored * Added CustomStatusExpiry component * Added sub components * Added CustomLabel * CustomStatus WIP * Added Custom Status screen WIP * WIP - unsetCustomStatus and CustomStatus constant * WIP * WIP * WIP * WIP * WIP * WIP * WIP * Retrieving RecentCustomStatuses from Preferences table * WIP * WIP * WIP * Added Clear After Modal * WIP - Transations * WIP * Done with showing modal cst * wip * Clear After Modal - DONE * fix * Added missing API calls * wip * Causing screen refresh * wip * WIP * WIP * WIP * Code clean up * Added OOO alert box * Refactored Options-Item * Refactored OptionsModalList component * Opening 'status' in BottomSheet instead of OptionsModal * AddReaction screen - WIP * Add Reaction screen - WIP * Added EmojiPickerRow * Added @components/emoji_picker - WIP * Emoji Picker - WIP * WIP * WIP * WIP * SectionList - WIP * Installed react-native-section_list_get_item_layout * Adding API calls - WIP * WIP * Search Bar component - WIP * WIP * WIP * WIP * Rendering Emoticons now - have to tackle some fixmes * Code clean up * Code clean up - WIP * Code clean up * WIP * Major clean up * wip * WIP * Fix rendering issue with SectionIcons and SearchBar * Tackled the CustomEmojiPage * Code clean up * WIP * Done with loading User Profiles for Custom Emoji * Code clean up * Code Clean up * Fix screen Account * Added missing sql file for IOS Pod * Updated Podfile.lock * Using queryConfig instead of queryCommonSystemValues * Fix - Custom status * Fix - Custom Status - Error * Fix - Clear Pass Status - WIP * Fix - Custom Status Clear * Need to fix CST clear * WIP * Status clear - working * Using catchError operator * remove unnecessary prop * Status BottomSheet now has colored indicators * Added KeyboardTrackingView from 'react-native-keyboard-tracking-view' * Code clean up * WIP * code clean up * Added a safety check * Fix - Display suggestions * Code clean up based on PR Review * Code clean up * Code clean up * Code clean up * Corrections * Fix tsc * TS fix * Removed unnecessary prop * Fix SearchBar Ts * Updated tests * Delete search_bar.test.js.snap * Merge branch 'gekidou' into gekidou_account_screen * Revert "Merge branch 'gekidou' into gekidou_account_screen" This reverts commit 5defc313212478a55abf2f92cb4bd64dc7877342. * Fix fonts * Refactor home account screen * fix theme provider * refactor bottom sheet * remove paper provider * update drawer item snapshots * Remove options modal screen * remove react-native-ui-lib dependency * Refactor & fix custom status & navigation (including tablet) * Refactor emoji picker Co-authored-by: Avinash Lingaloo <> Co-authored-by: Elias Nahum --- ...ghtItalic.ttf => OpenSansLight-Italic.ttf} | Bin app/actions/local/reactions.ts | 43 ++ app/actions/local/user.ts | 92 ++++ app/actions/remote/custom_emoji.ts | 71 +++ app/actions/remote/reactions.ts | 51 +-- app/actions/remote/user.ts | 87 +++- app/client/rest/users.ts | 24 + .../custom_status_emoji.test.tsx.snap | 28 +- .../custom_status_emoji.test.tsx | 1 + .../custom_status/custom_status_emoji.tsx | 6 +- .../custom_status/custom_status_expiry.tsx | 141 ++++++ .../custom_status/custom_status_text.tsx | 3 +- .../__snapshots__/index.test.tsx.snap | 220 +++++++++ app/components/drawer_item/index.test.tsx | 42 ++ app/components/drawer_item/index.tsx | 149 ++++++ app/components/emoji/index.tsx | 53 +-- .../emoji_picker/filtered/emoji_item.tsx | 63 +++ .../emoji_picker/filtered/index.tsx | 102 +++++ .../emoji_picker/filtered/no_results.tsx | 90 ++++ app/components/emoji_picker/index.tsx | 172 +++++++ .../emoji_picker/sections/icons_bar/icon.tsx | 53 +++ .../emoji_picker/sections/icons_bar/index.tsx | 75 ++++ .../emoji_picker/sections/index.tsx | 242 ++++++++++ .../emoji_picker/sections/section_footer.tsx | 30 ++ .../emoji_picker/sections/section_header.tsx | 50 +++ .../emoji_picker/sections/touchable_emoji.tsx | 35 ++ .../combined_user_activity.tsx | 6 +- .../post/body/files/image_file_overlay.tsx | 2 +- .../post/body/reactions/reaction.tsx | 2 +- .../post_list/post/header/header.tsx | 2 +- .../search_bar/components/clear_icon.tsx | 44 ++ .../search_bar/components/search_icon.tsx | 53 +++ app/components/search_bar/index.tsx | 316 +++++++++++++ app/components/search_bar/styles.ts | 125 ++++++ app/components/slide_up_panel_item/index.tsx | 49 +- app/components/status_label/index.tsx | 66 +++ app/components/tablet_title/index.tsx | 78 ++++ .../touchable_with_feedback.android.tsx | 7 +- .../touchable_with_feedback.ios.tsx | 5 +- app/constants/custom_status.ts | 61 +++ app/constants/database.ts | 1 + app/constants/emoji.ts | 4 +- app/constants/events.ts | 8 + app/constants/index.ts | 8 +- app/constants/preferences.ts | 2 + app/constants/screens.ts | 10 +- app/context/theme/index.tsx | 18 +- app/database/components/index.tsx | 19 +- app/database/models/server/user.ts | 2 +- app/init/launch.ts | 10 +- app/queries/servers/custom_emoji.ts | 24 + app/queries/servers/preference.ts | 11 +- app/queries/servers/system.ts | 9 + app/screens/bottom_sheet/index.tsx | 63 ++- .../custom_status/components/clear_after.tsx | 102 +++++ .../components/custom_status_emoji.tsx | 60 +++ .../components/custom_status_input.tsx | 103 +++++ .../components/custom_status_suggestion.tsx | 144 ++++++ .../components/custom_status_suggestions.tsx | 107 +++++ .../components/recent_custom_statuses.tsx | 77 ++++ app/screens/custom_status/index.tsx | 425 ++++++++++++++++++ .../components/clear_after_menu_item.tsx | 142 ++++++ .../components/date_time_selector.tsx | 131 ++++++ .../custom_status_clear_after/index.tsx | 219 +++++++++ app/screens/emoji_picker/index.tsx | 56 +++ .../options/custom_status/custom_label.tsx | 92 ++++ .../custom_status/custom_status_emoji.tsx | 48 ++ .../custom_status/custom_status_text.tsx | 46 ++ .../options/custom_status/index.tsx | 97 ++++ .../home/account/components/options/index.tsx | 105 +++++ .../components/options/logout/index.tsx | 81 ++++ .../options/saved_messages/index.tsx | 40 ++ .../components/options/settings/index.tsx | 40 ++ .../options/user_presence/index.tsx | 140 ++++++ .../components/options/your_profile/index.tsx | 40 ++ .../account/components/tablet_view/index.ts | 45 ++ .../account/components/user_info/index.tsx | 73 +++ app/screens/home/account/index.tsx | 151 ++++++- app/screens/home/tab_bar/index.tsx | 2 +- app/screens/index.tsx | 14 +- app/screens/modal/index.tsx | 15 - app/screens/navigation.ts | 132 +++--- app/store/ephemeral_store.ts | 4 + app/utils/emoji/helpers.ts | 37 +- app/utils/emoji/index.js | 4 +- app/utils/helpers.ts | 37 +- app/utils/theme/index.ts | 58 ++- app/utils/user/index.ts | 58 ++- assets/base/i18n/en.json | 52 +++ assets/fonts/Metropolis-SemiBold.ttf | Bin 0 -> 76016 bytes babel.config.js | 2 +- index.ts | 6 +- ios/Mattermost.xcodeproj/project.pbxproj | 56 +-- ios/Podfile.lock | 6 + package-lock.json | 103 +++++ package.json | 4 + types/api/preferences.d.ts | 11 +- types/api/users.d.ts | 10 - types/database/models/servers/user.d.ts | 4 +- .../react-native-keyboard-tracking-view.d.ts | 4 + types/screens/emoji_selector.d.ts | 22 + 101 files changed, 5818 insertions(+), 315 deletions(-) rename android/app/src/main/assets/fonts/{OpenSans-LightItalic.ttf => OpenSansLight-Italic.ttf} (100%) create mode 100644 app/actions/local/reactions.ts create mode 100644 app/actions/local/user.ts create mode 100644 app/actions/remote/custom_emoji.ts create mode 100644 app/components/custom_status/custom_status_expiry.tsx create mode 100644 app/components/drawer_item/__snapshots__/index.test.tsx.snap create mode 100644 app/components/drawer_item/index.test.tsx create mode 100644 app/components/drawer_item/index.tsx create mode 100644 app/components/emoji_picker/filtered/emoji_item.tsx create mode 100644 app/components/emoji_picker/filtered/index.tsx create mode 100644 app/components/emoji_picker/filtered/no_results.tsx create mode 100644 app/components/emoji_picker/index.tsx create mode 100644 app/components/emoji_picker/sections/icons_bar/icon.tsx create mode 100644 app/components/emoji_picker/sections/icons_bar/index.tsx create mode 100644 app/components/emoji_picker/sections/index.tsx create mode 100644 app/components/emoji_picker/sections/section_footer.tsx create mode 100644 app/components/emoji_picker/sections/section_header.tsx create mode 100644 app/components/emoji_picker/sections/touchable_emoji.tsx create mode 100644 app/components/search_bar/components/clear_icon.tsx create mode 100644 app/components/search_bar/components/search_icon.tsx create mode 100644 app/components/search_bar/index.tsx create mode 100644 app/components/search_bar/styles.ts create mode 100644 app/components/status_label/index.tsx create mode 100644 app/components/tablet_title/index.tsx create mode 100644 app/constants/custom_status.ts create mode 100644 app/constants/events.ts create mode 100644 app/queries/servers/custom_emoji.ts create mode 100644 app/screens/custom_status/components/clear_after.tsx create mode 100644 app/screens/custom_status/components/custom_status_emoji.tsx create mode 100644 app/screens/custom_status/components/custom_status_input.tsx create mode 100644 app/screens/custom_status/components/custom_status_suggestion.tsx create mode 100644 app/screens/custom_status/components/custom_status_suggestions.tsx create mode 100644 app/screens/custom_status/components/recent_custom_statuses.tsx create mode 100644 app/screens/custom_status/index.tsx create mode 100644 app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx create mode 100644 app/screens/custom_status_clear_after/components/date_time_selector.tsx create mode 100644 app/screens/custom_status_clear_after/index.tsx create mode 100644 app/screens/emoji_picker/index.tsx create mode 100644 app/screens/home/account/components/options/custom_status/custom_label.tsx create mode 100644 app/screens/home/account/components/options/custom_status/custom_status_emoji.tsx create mode 100644 app/screens/home/account/components/options/custom_status/custom_status_text.tsx create mode 100644 app/screens/home/account/components/options/custom_status/index.tsx create mode 100644 app/screens/home/account/components/options/index.tsx create mode 100644 app/screens/home/account/components/options/logout/index.tsx create mode 100644 app/screens/home/account/components/options/saved_messages/index.tsx create mode 100644 app/screens/home/account/components/options/settings/index.tsx create mode 100644 app/screens/home/account/components/options/user_presence/index.tsx create mode 100644 app/screens/home/account/components/options/your_profile/index.tsx create mode 100644 app/screens/home/account/components/tablet_view/index.ts create mode 100644 app/screens/home/account/components/user_info/index.tsx delete mode 100644 app/screens/modal/index.tsx create mode 100644 assets/fonts/Metropolis-SemiBold.ttf create mode 100644 types/modules/react-native-keyboard-tracking-view.d.ts create mode 100644 types/screens/emoji_selector.d.ts diff --git a/android/app/src/main/assets/fonts/OpenSans-LightItalic.ttf b/android/app/src/main/assets/fonts/OpenSansLight-Italic.ttf similarity index 100% rename from android/app/src/main/assets/fonts/OpenSans-LightItalic.ttf rename to android/app/src/main/assets/fonts/OpenSansLight-Italic.ttf diff --git a/app/actions/local/reactions.ts b/app/actions/local/reactions.ts new file mode 100644 index 0000000000..de178a3c8a --- /dev/null +++ b/app/actions/local/reactions.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {safeParseJSON} from '@utils/helpers'; + +import type SystemModel from '@typings/database/models/servers/system'; + +const MAXIMUM_RECENT_EMOJI = 27; + +export const addRecentReaction = async (serverUrl: string, emojiName: string, prepareRecordsOnly = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + let recent: string[] = []; + try { + const emojis = await operator.database.get(MM_TABLES.SERVER.SYSTEM).find(SYSTEM_IDENTIFIERS.RECENT_REACTIONS); + recent.push(...(safeParseJSON(emojis.value) as string[] || [])); + } catch { + // no previous values.. continue + } + + try { + const recentEmojis = new Set(recent); + if (recentEmojis.has(emojiName)) { + recentEmojis.delete(emojiName); + } + recent = Array.from(recentEmojis); + recent.unshift(emojiName); + return operator.handleSystem({ + systems: [{ + id: SYSTEM_IDENTIFIERS.RECENT_REACTIONS, + value: JSON.stringify(recent.slice(0, MAXIMUM_RECENT_EMOJI)), + }], + prepareRecordsOnly, + }); + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/local/user.ts b/app/actions/local/user.ts new file mode 100644 index 0000000000..58d74564b2 --- /dev/null +++ b/app/actions/local/user.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {queryRecentCustomStatuses} from '@queries/servers/system'; +import {queryUserById} from '@queries/servers/user'; + +import {addRecentReaction} from './reactions'; + +import type Model from '@nozbe/watermelondb/Model'; +import type UserModel from '@typings/database/models/servers/user'; + +export const updateLocalCustomStatus = async (serverUrl: string, user: UserModel, customStatus?: UserCustomStatus) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const models: Model[] = []; + const currentProps = {...user.props, customStatus: customStatus || {}}; + const userModel = user.prepareUpdate((u: UserModel) => { + u.props = currentProps; + }); + + models.push(userModel); + if (customStatus) { + const recent = await updateRecentCustomStatuses(serverUrl, customStatus, true); + if (Array.isArray(recent)) { + models.push(...recent); + } + + if (customStatus.emoji) { + const recentEmojis = await addRecentReaction(serverUrl, customStatus.emoji, true); + if (Array.isArray(recentEmojis)) { + models.push(...recentEmojis); + } + } + } + + await operator.batchRecords(models); + return {}; +}; + +export const updateRecentCustomStatuses = async (serverUrl: string, customStatus: UserCustomStatus, prepareRecordsOnly = false, remove = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const recent = await queryRecentCustomStatuses(operator.database); + const recentStatuses = (recent ? recent.value : []) as UserCustomStatus[]; + const index = recentStatuses.findIndex((cs) => ( + cs.emoji === customStatus.emoji && + cs.text === customStatus.text && + cs.duration === customStatus.duration + )); + + if (index !== -1) { + recentStatuses.splice(index, 1); + } + + if (!remove) { + recentStatuses.unshift(customStatus); + } + + return operator.handleSystem({ + systems: [{ + id: SYSTEM_IDENTIFIERS.RECENT_CUSTOM_STATUS, + value: JSON.stringify(recentStatuses), + }], + prepareRecordsOnly, + }); +}; + +export const updateUserPresence = async (serverUrl: string, userStatus: UserStatus) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const user = await queryUserById(operator.database, userStatus.user_id); + if (user) { + user.prepareUpdate((record) => { + record.status = userStatus.status; + }); + await operator.batchRecords([user]); + } + + return {}; +}; + diff --git a/app/actions/remote/custom_emoji.ts b/app/actions/remote/custom_emoji.ts new file mode 100644 index 0000000000..a139ae71d8 --- /dev/null +++ b/app/actions/remote/custom_emoji.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import {Client} from '@client/rest'; +import {Emoji, General} from '@constants'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@init/network_manager'; +import {queryCustomEmojisByName} from '@queries/servers/custom_emoji'; + +export const fetchCustomEmojis = async (serverUrl: string, page = 0, perPage = General.PAGE_SIZE_DEFAULT, sort = Emoji.SORT_BY_NAME) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + const data = await client.getCustomEmojis(page, perPage, sort); + if (data.length) { + await operator.handleCustomEmojis({ + emojis: data, + prepareRecordsOnly: false, + }); + } + + return {data}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const searchCustomEmojis = async (serverUrl: string, term: string) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + const data = await client.searchCustomEmoji(term); + if (data.length) { + const names = data.map((c) => c.name); + const exist = await queryCustomEmojisByName(operator.database, names); + const emojis = data.filter((d) => exist.findIndex((c) => c.name === d.name)); + if (emojis.length) { + await operator.handleCustomEmojis({ + emojis, + prepareRecordsOnly: false, + }); + } + } + return {data}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; diff --git a/app/actions/remote/reactions.ts b/app/actions/remote/reactions.ts index 12ea5cf6a8..e9dab56728 100644 --- a/app/actions/remote/reactions.ts +++ b/app/actions/remote/reactions.ts @@ -1,9 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Q} from '@nozbe/watermelondb'; +import {Model, Q} from '@nozbe/watermelondb'; -import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +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'; @@ -11,7 +12,6 @@ import {queryCurrentUserId} from '@queries/servers/system'; import {forceLogoutIfNecessary} from './session'; import type {Client} from '@client/rest'; -import type SystemModel from '@typings/database/models/servers/system'; export const addReaction = async (serverUrl: string, postId: string, emojiName: string) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -29,16 +29,25 @@ export const addReaction = async (serverUrl: string, postId: string, emojiName: try { const currentUserId = await queryCurrentUserId(operator.database); const reaction = await client.addReaction(currentUserId, postId, emojiName); + const models: Model[] = []; - await operator.handleReactions({ + const reactions = await operator.handleReactions({ postsReactions: [{ post_id: postId, reactions: [reaction], }], - prepareRecordsOnly: false, + prepareRecordsOnly: true, }); + models.push(...reactions); - addRecentReaction(serverUrl, emojiName); + const recent = await addRecentReaction(serverUrl, emojiName, true); + if (Array.isArray(recent)) { + models.push(...recent); + } + + if (models.length) { + await operator.batchRecords(models); + } return {reaction}; } catch (error) { @@ -83,33 +92,3 @@ export const removeReaction = async (serverUrl: string, postId: string, emojiNam return {error}; } }; - -export const addRecentReaction = async (serverUrl: string, emojiName: string) => { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - return {error: `${serverUrl} database not found`}; - } - - const recent = []; - try { - const emojis = await operator.database.get(MM_TABLES.SERVER.SYSTEM).find(SYSTEM_IDENTIFIERS.RECENT_REACTIONS) as SystemModel; - recent.push(...emojis.value); - } catch { - // no previous values.. continue - } - - try { - recent.unshift(emojiName); - await operator.handleSystem({ - systems: [{ - id: SYSTEM_IDENTIFIERS.RECENT_REACTIONS, - value: JSON.stringify(recent), - }], - prepareRecordsOnly: false, - }); - - return {error: undefined}; - } catch (error) { - return {error}; - } -}; diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index e127af8b51..f597be12a1 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -3,6 +3,7 @@ import {Q} from '@nozbe/watermelondb'; +import {updateRecentCustomStatuses, updateUserPresence} from '@actions/local/user'; import {fetchRolesIfNeeded} from '@actions/remote/role'; import {Database, General} from '@constants'; import DatabaseManager from '@database/manager'; @@ -322,7 +323,7 @@ export const fetchStatusByIds = async (serverUrl: string, userIds: string[], fet const users = await database.get(Database.MM_TABLES.SERVER.USER).query(Q.where('id', Q.oneOf(userIds))).fetch() as UserModel[]; for (const user of users) { const status = statuses.find((s) => s.user_id === user.id); - user.prepareSatus(status?.status || General.OFFLINE); + user.prepareStatus(status?.status || General.OFFLINE); } await operator.batchRecords(users); @@ -357,7 +358,6 @@ export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetc const exisingUsers = await queryUsersById(operator.database, userIds); const usersToLoad = userIds.filter((id) => (id !== currentUserId && !exisingUsers.find((u) => u.id === id))); const users = await client.getProfilesByIds([...new Set(usersToLoad)]); - if (!fetchOnly) { await operator.handleUsers({ users, @@ -408,7 +408,7 @@ export const fetchUsersByUsernames = async (serverUrl: string, usernames: string } }; -export const fetchMissinProfilesByIds = async (serverUrl: string, userIds: string[]) => { +export const fetchMissingProfilesByIds = async (serverUrl: string, userIds: string[]) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; @@ -427,7 +427,7 @@ export const fetchMissinProfilesByIds = async (serverUrl: string, userIds: strin } }; -export const fetchMissinProfilesByUsernames = async (serverUrl: string, usernames: string[]) => { +export const fetchMissingProfilesByUsernames = async (serverUrl: string, usernames: string[]) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; @@ -445,3 +445,82 @@ export const fetchMissinProfilesByUsernames = async (serverUrl: string, username return {error}; } }; + +export const setStatus = async (serverUrl: string, status: UserStatus) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const data = await client.updateStatus(status); + updateUserPresence(serverUrl, status); + + return { + data, + }; + } catch (error: any) { + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; + +export const updateCustomStatus = async (serverUrl: string, user: UserModel, customStatus: UserCustomStatus) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + await client.updateCustomStatus(customStatus); + return {data: true}; + } catch (error) { + return {error}; + } +}; + +export const removeRecentCustomStatus = async (serverUrl: string, customStatus: UserCustomStatus) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + updateRecentCustomStatuses(serverUrl, customStatus, false, true); + + try { + await client.removeRecentCustomStatus(customStatus); + } catch (error) { + return {error}; + } + + return {data: true}; +}; + +export const unsetCustomStatus = async (serverUrl: string) => { + let client: Client; + + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + await client.unsetCustomStatus(); + } catch (error) { + return {error}; + } + + return {data: true}; +}; diff --git a/app/client/rest/users.ts b/app/client/rest/users.ts index e151fc1203..6efbb23836 100644 --- a/app/client/rest/users.ts +++ b/app/client/rest/users.ts @@ -41,6 +41,9 @@ export interface ClientUsersMix { getStatusesByIds: (userIds: string[]) => Promise; getStatus: (userId: string) => Promise; updateStatus: (status: UserStatus) => Promise; + updateCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>; + unsetCustomStatus: () => Promise<{status: string}>; + removeRecentCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>; } const ClientUsers = (superclass: any) => class extends superclass { @@ -380,6 +383,27 @@ const ClientUsers = (superclass: any) => class extends superclass { {method: 'put', body: status}, ); }; + + updateCustomStatus = async (customStatus: UserCustomStatus) => { + return this.doFetch( + `${this.getUserRoute('me')}/status/custom`, + {method: 'put', body: customStatus}, + ); + }; + + unsetCustomStatus = async () => { + return this.doFetch( + `${this.getUserRoute('me')}/status/custom`, + {method: 'delete'}, + ); + }; + + removeRecentCustomStatus = async (customStatus: UserCustomStatus) => { + return this.doFetch( + `${this.getUserRoute('me')}/status/custom/recent/delete`, + {method: 'post', body: customStatus}, + ); + }; }; export default ClientUsers; diff --git a/app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap b/app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap index ade473c70e..3eb0152a65 100644 --- a/app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap +++ b/app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap @@ -4,8 +4,18 @@ exports[`components/custom_status/custom_status_emoji should match snapshot 1`] - - + + 📆 `; @@ -14,8 +24,18 @@ exports[`components/custom_status/custom_status_emoji should match snapshot with - - + + 📆 `; diff --git a/app/components/custom_status/custom_status_emoji.test.tsx b/app/components/custom_status/custom_status_emoji.test.tsx index 1b76eee9ec..7c72526455 100644 --- a/app/components/custom_status/custom_status_emoji.test.tsx +++ b/app/components/custom_status/custom_status_emoji.test.tsx @@ -5,6 +5,7 @@ import Database from '@nozbe/watermelondb/Database'; import React from 'react'; import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; +import {CustomStatusDuration} from '@constants'; import {renderWithEverything} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; diff --git a/app/components/custom_status/custom_status_emoji.tsx b/app/components/custom_status/custom_status_emoji.tsx index 382d5b5b9c..c58d97f084 100644 --- a/app/components/custom_status/custom_status_emoji.tsx +++ b/app/components/custom_status/custom_status_emoji.tsx @@ -13,7 +13,7 @@ interface ComponentProps { testID?: string; } -const CustomStatusEmoji = ({customStatus, emojiSize, style, testID}: ComponentProps) => { +const CustomStatusEmoji = ({customStatus, emojiSize = 16, style, testID}: ComponentProps) => { const testIdPrefix = testID ? `${testID}.` : ''; return ( { + return { + text: { + fontSize: 15, + color: theme.centerChannelColor, + }, + }; +}); + +const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCompulsory, showToday, testID = '', textStyles = {}, theme, time, withinBrackets}: Props) => { + const userTimezone = getUserTimezone(currentUser); + const timezone = userTimezone.useAutomaticTimezone ? userTimezone.automaticTimezone : userTimezone.manualTimezone; + const styles = getStyleSheet(theme); + const currentMomentTime = getCurrentMomentForTimezone(timezone); + const expiryMomentTime = timezone ? moment(time).tz(timezone) : moment(time); + const plusSixDaysEndTime = currentMomentTime.clone().add(6, 'days').endOf('day'); + const tomorrowEndTime = currentMomentTime.clone().add(1, 'day').endOf('day'); + const todayEndTime = currentMomentTime.clone().endOf('day'); + const isCurrentYear = currentMomentTime.get('y') === expiryMomentTime.get('y'); + + let dateComponent; + if ((showToday && expiryMomentTime.isBefore(todayEndTime)) || expiryMomentTime.isSame(todayEndTime)) { + dateComponent = ( + + ); + } else if (expiryMomentTime.isAfter(todayEndTime) && expiryMomentTime.isSameOrBefore(tomorrowEndTime)) { + dateComponent = ( + + ); + } else if (expiryMomentTime.isAfter(tomorrowEndTime)) { + let format = 'dddd'; + if (expiryMomentTime.isAfter(plusSixDaysEndTime) && isCurrentYear) { + format = 'MMM DD'; + } else if (!isCurrentYear) { + format = 'MMM DD, YYYY'; + } + + dateComponent = ( + + ); + } + + const useTime = showTimeCompulsory || !(expiryMomentTime.isSame(todayEndTime) || expiryMomentTime.isAfter(tomorrowEndTime)); + + return ( + + {withinBrackets && '('} + {showPrefix && ( + + )} + {showPrefix && ' '} + {dateComponent} + {useTime && dateComponent && ( + <> + {' '} + + {' '} + + )} + {useTime && ( + + )} + {withinBrackets && ')'} + + ); +}; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ + isMilitaryTime: database.get(MM_TABLES.SERVER.PREFERENCE). + query( + Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS), + ).observe().pipe( + switchMap( + (preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)), + ), + ), +})); + +export default withDatabase(enhanced(CustomStatusExpiry)); diff --git a/app/components/custom_status/custom_status_text.tsx b/app/components/custom_status/custom_status_text.tsx index f3a864bcdb..5cd482efb2 100644 --- a/app/components/custom_status/custom_status_text.tsx +++ b/app/components/custom_status/custom_status_text.tsx @@ -4,11 +4,10 @@ import React from 'react'; import {Text, TextStyle} from 'react-native'; -import FormattedText from '@components/formatted_text'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; interface ComponentProps { - text: string | typeof FormattedText; + text: string | React.ReactNode; theme: Theme; textStyle?: TextStyle; ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip'; diff --git a/app/components/drawer_item/__snapshots__/index.test.tsx.snap b/app/components/drawer_item/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..ca7c2cce06 --- /dev/null +++ b/app/components/drawer_item/__snapshots__/index.test.tsx.snap @@ -0,0 +1,220 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DrawerItem should match snapshot 1`] = ` + + + + + + + + + + default message + + + + + + + +`; + +exports[`DrawerItem should match snapshot without separator and centered false 1`] = ` + + + + + + + + + + default message + + + + + + +`; diff --git a/app/components/drawer_item/index.test.tsx b/app/components/drawer_item/index.test.tsx new file mode 100644 index 0000000000..aad6edf9cd --- /dev/null +++ b/app/components/drawer_item/index.test.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {Preferences} from '@constants'; +import {renderWithIntl} from '@test/intl-test-helper'; + +import DrawerItem from './'; + +describe('DrawerItem', () => { + const baseProps = { + onPress: () => null, + testID: 'test-id', + centered: true, + defaultMessage: 'default message', + i18nId: 'i18-id', + iconName: 'icon-name', + isDestructor: true, + separator: true, + theme: Preferences.THEMES.denim, + }; + + test('should match snapshot', () => { + const wrapper = renderWithIntl(); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + test('should match snapshot without separator and centered false', () => { + const props = { + ...baseProps, + centered: false, + separator: false, + }; + const wrapper = renderWithIntl( + , + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/drawer_item/index.tsx b/app/components/drawer_item/index.tsx new file mode 100644 index 0000000000..e3086b7d97 --- /dev/null +++ b/app/components/drawer_item/index.tsx @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {ReactNode} from 'react'; +import {Platform, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type DrawerItemProps = { + centered?: boolean; + defaultMessage?: string; + i18nId?: string; + iconName?: string; + isDestructor?: boolean; + labelComponent?: ReactNode; + leftComponent?: ReactNode; + onPress: () => void; + separator?: boolean; + testID: string; + theme: Theme; +}; + +const DrawerItem = (props: DrawerItemProps) => { + const { + centered, + defaultMessage = '', + i18nId, + iconName, + isDestructor = false, + labelComponent, + leftComponent, + onPress, + separator = true, + testID, + theme, + } = props; + + const style = getStyleSheet(theme); + + const destructor: any = {}; + if (isDestructor) { + destructor.color = theme.errorTextColor; + } + + let divider; + if (separator) { + divider = (); + } + + let icon; + if (leftComponent) { + icon = leftComponent; + } else if (iconName) { + icon = ( + + ); + } + + let label; + if (labelComponent) { + label = labelComponent; + } else if (i18nId) { + label = ( + + ); + } + + return ( + + + {icon && ( + + {icon} + + )} + + + {label} + + {divider} + + + + ); +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + container: { + backgroundColor: theme.centerChannelBg, + flexDirection: 'row', + minHeight: 50, + }, + iconContainer: { + width: 45, + height: 50, + alignItems: 'center', + justifyContent: 'center', + marginLeft: 5, + }, + icon: { + color: changeOpacity(theme.centerChannelColor, 0.64), + fontSize: 24, + }, + wrapper: { + flex: 1, + }, + labelContainer: { + flex: 1, + justifyContent: 'center', + paddingTop: 14, + paddingBottom: 14, + }, + centerLabel: { + textAlign: 'center', + textAlignVertical: 'center', + }, + label: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 17, + textAlignVertical: 'center', + includeFontPadding: false, + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1, + }, + }; +}); + +export default DrawerItem; diff --git a/app/components/emoji/index.tsx b/app/components/emoji/index.tsx index 2937703ad3..aa45d710bc 100644 --- a/app/components/emoji/index.tsx +++ b/app/components/emoji/index.tsx @@ -11,7 +11,6 @@ import { StyleSheet, Text, TextStyle, - View, } from 'react-native'; import FastImage, {ImageStyle} from 'react-native-fast-image'; import {of as of$} from 'rxjs'; @@ -102,7 +101,7 @@ const Emoji = (props: Props) => { return ( {code} @@ -118,15 +117,13 @@ const Emoji = (props: Props) => { return null; } return ( - - - + ); } @@ -139,31 +136,27 @@ const Emoji = (props: Props) => { const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null; return ( - - - + ); }; -const withSystemIds = withObservables([], ({database}: WithDatabaseArgs) => ({ - enableCustomEmoji: database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( - switchMap((config: SystemModel) => of$(config.value.EnableCustomEmoji)), - ), -})); +const withCustomEmojis = withObservables(['emojiName'], ({database, emojiName}: WithDatabaseArgs & {emojiName: string}) => { + const hasEmojiBuiltIn = EmojiIndicesByAlias.has(emojiName); -const withCustomEmojis = withObservables(['enableCustomEmoji', 'emojiName'], ({enableCustomEmoji, database, emojiName}: WithDatabaseArgs & {enableCustomEmoji: string; emojiName: string}) => { - const displayTextOnly = enableCustomEmoji !== 'true'; + const displayTextOnly = hasEmojiBuiltIn ? of$(false) : database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((config) => of$(config.value.EnableCustomEmoji !== 'true')), + ); return { - displayTextOnly: of$(displayTextOnly), - customEmojis: database.get(MM_TABLES.SERVER.CUSTOM_EMOJI).query(Q.where('name', emojiName)).observe(), + displayTextOnly, + customEmojis: hasEmojiBuiltIn ? of$([]) : database.get(MM_TABLES.SERVER.CUSTOM_EMOJI).query(Q.where('name', emojiName)).observe(), }; }); -export default withDatabase(withSystemIds(withCustomEmojis(Emoji))); +export default withDatabase(withCustomEmojis(Emoji)); diff --git a/app/components/emoji_picker/filtered/emoji_item.tsx b/app/components/emoji_picker/filtered/emoji_item.tsx new file mode 100644 index 0000000000..4c1035fc68 --- /dev/null +++ b/app/components/emoji_picker/filtered/emoji_item.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {memo, useCallback} from 'react'; +import {Text, TouchableOpacity, View} from 'react-native'; + +import Emoji from '@components/emoji'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type TouchableEmojiProps = { + name: string; + onEmojiPress: (emojiName: string) => void; +} + +const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + height: 40, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + borderBottomWidth: 1, + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2), + overflow: 'hidden', + }, + emojiContainer: { + marginRight: 5, + }, + emoji: { + color: '#000', + }, + emojiText: { + fontSize: 13, + color: theme.centerChannelColor, + }, + }; +}); + +const EmojiTouchable = ({name, onEmojiPress}: TouchableEmojiProps) => { + const theme = useTheme(); + const style = getStyleSheetFromTheme(theme); + + const onPress = useCallback(() => onEmojiPress(name), []); + + return ( + + + + + {`:${name}:`} + + ); +}; + +export default memo(EmojiTouchable); diff --git a/app/components/emoji_picker/filtered/index.tsx b/app/components/emoji_picker/filtered/index.tsx new file mode 100644 index 0000000000..a3e5c6bcde --- /dev/null +++ b/app/components/emoji_picker/filtered/index.tsx @@ -0,0 +1,102 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Fuse from 'fuse.js'; +import React, {useCallback, useMemo} from 'react'; +import {FlatList} from 'react-native'; + +import {Emojis, EmojiIndicesByAlias} from '@utils/emoji'; +import {compareEmojis, getSkin} from '@utils/emoji/helpers'; + +import EmojiItem from './emoji_item'; +import NoResults from './no_results'; + +import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; + +type Props = { + customEmojis: CustomEmojiModel[]; + skinTone: string; + searchTerm: string; + onEmojiPress: (emojiName: string) => void; +}; + +const EmojiFiltered = ({customEmojis, skinTone, searchTerm, onEmojiPress}: Props) => { + const emojis = useMemo(() => { + const emoticons = new Set(); + for (const [key, index] of EmojiIndicesByAlias.entries()) { + const skin = getSkin(Emojis[index]); + if (!skin || skin === skinTone) { + emoticons.add(key); + } + } + + for (const custom of customEmojis) { + emoticons.add(custom.name); + } + + return Array.from(emoticons); + }, [skinTone, customEmojis]); + + const fuse = useMemo(() => { + const options = {findAllMatches: true, ignoreLocation: true, includeMatches: true, shouldSort: false, includeScore: true}; + return new Fuse(emojis, options); + }, [emojis]); + + const data = useMemo(() => { + const searchTermLowerCase = searchTerm.toLowerCase(); + + if (!searchTerm) { + return []; + } + + const sorter = (a: string, b: string) => { + return compareEmojis(a, b, searchTermLowerCase); + }; + + const fuzz = fuse.search(searchTermLowerCase); + + if (fuzz) { + const results = fuzz.reduce((values, r) => { + const score = r?.score === undefined ? 1 : r.score; + const v = r?.matches?.[0]?.value; + if (score < 0.2 && v) { + values.push(v); + } + + return values; + }, [] as string[]); + + return results.sort(sorter); + } + + return []; + }, [fuse, searchTerm]); + + const keyExtractor = useCallback((item) => item, []); + + const renderItem = useCallback(({item}) => { + return ( + + ); + }, []); + + if (!data.length) { + return ; + } + + return ( + + ); +}; + +export default EmojiFiltered; diff --git a/app/components/emoji_picker/filtered/no_results.tsx b/app/components/emoji_picker/filtered/no_results.tsx new file mode 100644 index 0000000000..f853095cb9 --- /dev/null +++ b/app/components/emoji_picker/filtered/no_results.tsx @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {Text, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + searchTerm: string; +} + +const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme) => { + return { + flex: { + flex: 1, + }, + flexCenter: { + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, + notFoundIcon: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), + width: 120, + height: 120, + borderRadius: 60, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + notFoundText: { + color: theme.centerChannelColor, + marginTop: 16, + }, + notFoundText20: { + fontSize: 20, + fontWeight: '600', + }, + notFoundText15: { + fontSize: 15, + }, + }; +}); + +const NoResults = ({searchTerm}: Props) => { + const theme = useTheme(); + const intl = useIntl(); + + const styles = getStyleSheetFromTheme(theme); + const title = intl.formatMessage( + { + id: 'mobile.emoji_picker.search.not_found_title', + defaultMessage: 'No results found for "{searchTerm}"', + }, + { + searchTerm, + }, + ); + + const description = intl.formatMessage({ + id: 'mobile.emoji_picker.search.not_found_description', + defaultMessage: 'Check the spelling or try another search.', + }); + + return ( + + + + + + + {title} + + + {description} + + + + ); +}; + +export default NoResults; diff --git a/app/components/emoji_picker/index.tsx b/app/components/emoji_picker/index.tsx new file mode 100644 index 0000000000..a12c0d89bd --- /dev/null +++ b/app/components/emoji_picker/index.tsx @@ -0,0 +1,172 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import React, {useCallback, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {LayoutChangeEvent, Platform, View} from 'react-native'; +import {Edge, SafeAreaView} from 'react-native-safe-area-context'; +import {of as of$} from 'rxjs'; +import {catchError, switchMap} from 'rxjs/operators'; + +import {searchCustomEmojis} from '@actions/remote/custom_emoji'; +import SearchBar from '@components/search_bar'; +import {Preferences} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {useServerUrl} from '@context/server_url'; +import {useTheme} from '@context/theme'; +import {debounce} from '@helpers/api/general'; +import {safeParseJSON} from '@utils/helpers'; +import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; + +import EmojiFiltered from './filtered'; +import EmojiSections from './sections'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +import type PreferenceModel from '@typings/database/models/servers/preference'; +import type SystemModel from '@typings/database/models/servers/system'; + +export const SCROLLVIEW_NATIVE_ID = 'emojiSelector'; +const edges: Edge[] = ['bottom', 'left', 'right']; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + flex: { + flex: 1, + }, + container: { + flex: 1, + marginHorizontal: 12, + }, + searchBar: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + paddingVertical: 5, + ...Platform.select({ + ios: { + paddingLeft: 8, + }, + }), + height: 50, + }, + searchBarInput: { + backgroundColor: theme.centerChannelBg, + color: theme.centerChannelColor, + fontSize: 13, + }, +})); + +type Props = { + customEmojis: CustomEmojiModel[]; + customEmojisEnabled: boolean; + onEmojiPress: (emoji: string) => void; + recentEmojis: string[]; + skinTone: string; + testID?: string; +} + +const EmojiPicker = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmojis, skinTone, testID = ''}: Props) => { + const theme = useTheme(); + const intl = useIntl(); + const serverUrl = useServerUrl(); + const [width, setWidth] = useState(0); + const [searchTerm, setSearchTerm] = useState(); + const styles = getStyleSheet(theme); + const onLayout = useCallback(({nativeEvent}: LayoutChangeEvent) => setWidth(nativeEvent.layout.width), []); + const onCancelSearch = useCallback(() => setSearchTerm(undefined), []); + const onChangeSearchTerm = useCallback((text) => { + setSearchTerm(text); + searchCustom(text); + }, []); + const searchCustom = debounce((text: string) => { + if (text && text.length > 1) { + searchCustomEmojis(serverUrl, text); + } + }, 500); + + let EmojiList: React.ReactNode = null; + if (searchTerm) { + EmojiList = ( + + ); + } else { + EmojiList = ( + + ); + } + + return ( + + + + + + {Boolean(width) && + <> + {EmojiList} + + } + + + ); +}; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ + customEmojisEnabled: database.get(MM_TABLES.SERVER.SYSTEM). + findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap((config) => of$(config.value.EnableCustomEmoji === 'true')), + ), + customEmojis: database.get(MM_TABLES.SERVER.CUSTOM_EMOJI).query().observe(), + recentEmojis: database.get(MM_TABLES.SERVER.SYSTEM). + findAndObserve(SYSTEM_IDENTIFIERS.RECENT_REACTIONS). + pipe( + switchMap((recent) => of$(safeParseJSON(recent.value) as string[])), + catchError(() => of$([])), + ), + skinTone: database.get(MM_TABLES.SERVER.PREFERENCE).query( + Q.where('category', Preferences.CATEGORY_EMOJI), + Q.where('name', Preferences.EMOJI_SKINTONE), + ).observe().pipe( + switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')), + ), +})); + +export default withDatabase(enhanced(EmojiPicker)); diff --git a/app/components/emoji_picker/sections/icons_bar/icon.tsx b/app/components/emoji_picker/sections/icons_bar/icon.tsx new file mode 100644 index 0000000000..c2191ffc79 --- /dev/null +++ b/app/components/emoji_picker/sections/icons_bar/icon.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {TouchableOpacity} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + currentIndex: number; + icon: string; + index: number; + scrollToIndex: (index: number) => void; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + alignItems: 'center', + flex: 1, + height: 35, + justifyContent: 'center', + zIndex: 10, + }, + icon: { + color: changeOpacity(theme.centerChannelColor, 0.4), + }, + selected: { + color: theme.centerChannelColor, + }, +})); + +const SectionIcon = ({currentIndex, icon, index, scrollToIndex, theme}: Props) => { + const style = getStyleSheet(theme); + const onPress = useCallback(preventDoubleTap(() => scrollToIndex(index)), []); + + return ( + + + + ); +}; + +export default SectionIcon; diff --git a/app/components/emoji_picker/sections/icons_bar/index.tsx b/app/components/emoji_picker/sections/icons_bar/index.tsx new file mode 100644 index 0000000000..390bc30e10 --- /dev/null +++ b/app/components/emoji_picker/sections/icons_bar/index.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} from 'react-native'; +import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view'; + +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import SectionIcon from './icon'; + +export const SCROLLVIEW_NATIVE_ID = 'emojiSelector'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + bottom: 10, + height: 35, + position: 'absolute', + width: '100%', + }, + background: { + backgroundColor: theme.centerChannelBg, + }, + pane: { + flexDirection: 'row', + borderRadius: 10, + paddingHorizontal: 10, + width: '100%', + borderColor: changeOpacity(theme.centerChannelColor, 0.3), + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + borderWidth: 1, + justifyContent: 'space-between', + }, +})); + +export type SectionIconType = { + key: string; + icon: string; +} + +type Props = { + currentIndex: number; + sections: SectionIconType[]; + scrollToIndex: (index: number) => void; +} + +const EmojiSectionBar = ({currentIndex, sections, scrollToIndex}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + return ( + + + + {sections.map((section, index) => ( + + ))} + + + + ); +}; + +export default EmojiSectionBar; diff --git a/app/components/emoji_picker/sections/index.tsx b/app/components/emoji_picker/sections/index.tsx new file mode 100644 index 0000000000..462f67bdc4 --- /dev/null +++ b/app/components/emoji_picker/sections/index.tsx @@ -0,0 +1,242 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {chunk} from 'lodash'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; +import {NativeScrollEvent, NativeSyntheticEvent, SectionList, StyleSheet, View} from 'react-native'; +import sectionListGetItemLayout from 'react-native-section-list-get-item-layout'; + +import {fetchCustomEmojis} from '@actions/remote/custom_emoji'; +import {EMOJIS_PER_PAGE} from '@constants/emoji'; +import {useServerUrl} from '@context/server_url'; +import {CategoryNames, EmojiIndicesByCategory, CategoryTranslations, CategoryMessage} from '@utils/emoji'; +import {fillEmoji} from '@utils/emoji/helpers'; + +import EmojiSectionBar, {SCROLLVIEW_NATIVE_ID, SectionIconType} from './icons_bar'; +import SectionFooter from './section_footer'; +import SectionHeader, {SECTION_HEADER_HEIGHT} from './section_header'; +import TouchableEmoji from './touchable_emoji'; + +import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; + +export const EMOJI_SIZE = 30; +export const EMOJI_GUTTER = 8; + +const ICONS: Record = { + recent: 'clock-outline', + 'smileys-emotion': 'emoticon-happy-outline', + 'people-body': 'eye-outline', + 'animals-nature': 'leaf-outline', + 'food-drink': 'food-apple', + 'travel-places': 'airplane-variant', + activities: 'basketball', + objects: 'lightbulb-outline', + symbols: 'heart-outline', + flags: 'flag-outline', + custom: 'emoticon-custom-outline', +}; + +const categoryToI18n: Record = {}; +const getItemLayout = sectionListGetItemLayout({ + getItemHeight: () => (EMOJI_SIZE + (EMOJI_GUTTER * 2)), + getSectionHeaderHeight: () => SECTION_HEADER_HEIGHT, +}); + +const styles = StyleSheet.create(({ + row: { + flexDirection: 'row', + marginBottom: EMOJI_GUTTER, + }, + emoji: { + height: EMOJI_SIZE + EMOJI_GUTTER, + marginHorizontal: 7, + width: EMOJI_SIZE + EMOJI_GUTTER, + }, +})); + +type Props = { + customEmojis: CustomEmojiModel[]; + customEmojisEnabled: boolean; + onEmojiPress: (emoji: string) => void; + recentEmojis: string[]; + skinTone: string; + width: number; +} + +CategoryNames.forEach((name: string) => { + if (CategoryTranslations.has(name) && CategoryMessage.has(name)) { + categoryToI18n[name] = { + id: CategoryTranslations.get(name)!, + defaultMessage: CategoryMessage.get(name)!, + icon: ICONS[name], + }; + } +}); + +const EmojiSections = ({customEmojis, customEmojisEnabled, onEmojiPress, recentEmojis, skinTone, width}: Props) => { + const serverUrl = useServerUrl(); + const list = useRef>(); + const [sectionIndex, setSectionIndex] = useState(0); + const [customEmojiPage, setCustomEmojiPage] = useState(0); + const [fetchingCustomEmojis, setFetchingCustomEmojis] = useState(false); + const [loadedAllCustomEmojis, setLoadedAllCustomEmojis] = useState(false); + + const sections: EmojiSection[] = useMemo(() => { + if (!width) { + return []; + } + const chunkSize = Math.floor(width / (EMOJI_SIZE + EMOJI_GUTTER)); + + return CategoryNames.map((category) => { + const emojiIndices = EmojiIndicesByCategory.get(skinTone)?.get(category); + + let data: EmojiAlias[][]; + switch (category) { + case 'custom': { + const builtInCustom = emojiIndices.map(fillEmoji); + + // eslint-disable-next-line max-nested-callbacks + const custom = customEmojisEnabled ? customEmojis.map((ce) => ({ + aliases: [], + name: ce.name, + short_name: '', + })) : []; + + data = chunk(builtInCustom.concat(custom), chunkSize); + break; + } + case 'recent': + // eslint-disable-next-line max-nested-callbacks + data = chunk(recentEmojis.map((emoji) => ({ + aliases: [], + name: emoji, + short_name: '', + })), chunkSize); + break; + default: + data = chunk(emojiIndices.map(fillEmoji), chunkSize); + break; + } + + return { + ...categoryToI18n[category], + data, + key: category, + }; + }).filter((s: EmojiSection) => s.data.length); + }, [skinTone, customEmojis, customEmojisEnabled, width]); + + const sectionIcons: SectionIconType[] = useMemo(() => { + return sections.map((s) => ({ + key: s.key, + icon: s.icon, + })); + }, [sections]); + + const emojiSectionsByOffset = useMemo(() => { + let lastOffset = 0; + return sections.map((s) => { + const start = lastOffset; + const nextOffset = s.data.length * (EMOJI_SIZE + (EMOJI_GUTTER * 2)); + lastOffset += nextOffset; + return start; + }); + }, [sections]); + + const onLoadMoreCustomEmojis = useCallback(async () => { + if (!customEmojisEnabled || fetchingCustomEmojis || loadedAllCustomEmojis) { + return; + } + setFetchingCustomEmojis(true); + const {data, error} = await fetchCustomEmojis(serverUrl, customEmojiPage, EMOJIS_PER_PAGE); + if (data?.length) { + setCustomEmojiPage(customEmojiPage + 1); + } else if (!error && (data && data.length < EMOJIS_PER_PAGE)) { + setLoadedAllCustomEmojis(true); + } + + setFetchingCustomEmojis(false); + }, [customEmojiPage, customEmojisEnabled, loadedAllCustomEmojis, fetchingCustomEmojis]); + + const onScroll = useCallback((e: NativeSyntheticEvent) => { + const {contentOffset} = e.nativeEvent; + let nextIndex = emojiSectionsByOffset.findIndex( + (offset) => contentOffset.y <= offset, + ); + + if (nextIndex === -1) { + nextIndex = emojiSectionsByOffset.length - 1; + } else if (nextIndex !== 0) { + nextIndex -= 1; + } + + if (nextIndex !== sectionIndex) { + setSectionIndex(nextIndex); + } + }, [emojiSectionsByOffset, sectionIndex]); + + const scrollToIndex = (index: number) => { + list.current?.scrollToLocation({sectionIndex: index, itemIndex: 0, animated: false, viewOffset: 0}); + setSectionIndex(index); + }; + + const renderSectionHeader = useCallback(({section}) => { + return ( + + ); + }, []); + + const renderFooter = useMemo(() => { + return fetchingCustomEmojis ? : null; + }, [fetchingCustomEmojis]); + + const renderItem = useCallback(({item}) => { + return ( + + {item.map((emoji: EmojiAlias) => { + return ( + + ); + })} + + ); + }, []); + + return ( + <> + + + + ); +}; + +export default EmojiSections; diff --git a/app/components/emoji_picker/sections/section_footer.tsx b/app/components/emoji_picker/sections/section_footer.tsx new file mode 100644 index 0000000000..779d179d0e --- /dev/null +++ b/app/components/emoji_picker/sections/section_footer.tsx @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {memo} from 'react'; +import {ActivityIndicator, View} from 'react-native'; + +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +const Footer = () => { + const theme = useTheme(); + const styles = getStyleSheetFromTheme(theme); + + return ( + + + + ); +}; + +const getStyleSheetFromTheme = makeStyleSheetFromTheme(() => { + return { + loading: { + flex: 1, + alignItems: 'center', + }, + }; +}); + +export default memo(Footer); diff --git a/app/components/emoji_picker/sections/section_header.tsx b/app/components/emoji_picker/sections/section_header.tsx new file mode 100644 index 0000000000..2239809585 --- /dev/null +++ b/app/components/emoji_picker/sections/section_header.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {memo} from 'react'; +import {View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + section: EmojiSection; +} + +export const SECTION_HEADER_HEIGHT = 28; + +const getStyleSheetFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + sectionTitleContainer: { + height: SECTION_HEADER_HEIGHT, + justifyContent: 'center', + backgroundColor: theme.centerChannelBg, + }, + sectionTitle: { + color: changeOpacity(theme.centerChannelColor, 0.2), + fontSize: 15, + fontWeight: '700', + }, + }; +}); + +const SectionHeader = ({section}: Props) => { + const theme = useTheme(); + const styles = getStyleSheetFromTheme(theme); + + return ( + + + + ); +}; + +export default memo(SectionHeader); diff --git a/app/components/emoji_picker/sections/touchable_emoji.tsx b/app/components/emoji_picker/sections/touchable_emoji.tsx new file mode 100644 index 0000000000..8be3899de2 --- /dev/null +++ b/app/components/emoji_picker/sections/touchable_emoji.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; + +import Emoji from '@components/emoji'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {preventDoubleTap} from '@utils/tap'; + +type Props = { + name: string; + onEmojiPress: (emoji: string) => void; + size?: number; + style: StyleProp; +} + +const TouchableEmoji = ({name, onEmojiPress, size = 30, style}: Props) => { + const onPress = useCallback(preventDoubleTap(() => onEmojiPress(name)), []); + + return ( + + + + ); +}; + +export default React.memo(TouchableEmoji); diff --git a/app/components/post_list/combined_user_activity/combined_user_activity.tsx b/app/components/post_list/combined_user_activity/combined_user_activity.tsx index 074307d1bb..5a1217f842 100644 --- a/app/components/post_list/combined_user_activity/combined_user_activity.tsx +++ b/app/components/post_list/combined_user_activity/combined_user_activity.tsx @@ -5,7 +5,7 @@ import React, {useEffect} from 'react'; import {useIntl} from 'react-intl'; import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'; -import {fetchMissinProfilesByIds, fetchMissinProfilesByUsernames} from '@actions/remote/user'; +import {fetchMissingProfilesByIds, fetchMissingProfilesByUsernames} from '@actions/remote/user'; import Markdown from '@components/markdown'; import SystemAvatar from '@components/system_avatar'; import SystemHeader from '@components/system_header'; @@ -70,11 +70,11 @@ const CombinedUserActivity = ({ const loadUserProfiles = () => { if (allUserIds.length) { - fetchMissinProfilesByIds(serverUrl, allUserIds); + fetchMissingProfilesByIds(serverUrl, allUserIds); } if (allUsernames.length) { - fetchMissinProfilesByUsernames(serverUrl, allUsernames); + fetchMissingProfilesByUsernames(serverUrl, allUsernames); } }; diff --git a/app/components/post_list/post/body/files/image_file_overlay.tsx b/app/components/post_list/post/body/files/image_file_overlay.tsx index 5a29e21cda..b4d4ebdf2b 100644 --- a/app/components/post_list/post/body/files/image_file_overlay.tsx +++ b/app/components/post_list/post/body/files/image_file_overlay.tsx @@ -24,7 +24,7 @@ const getStyleSheet = (scale: number, th: Theme) => { moreImagesText: { color: theme.sidebarHeaderTextColor, fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale)), - fontFamily: 'Open Sans', + fontFamily: 'OpenSans', textAlign: 'center', }, }; diff --git a/app/components/post_list/post/body/reactions/reaction.tsx b/app/components/post_list/post/body/reactions/reaction.tsx index 5893fe5cc5..ee13520e07 100644 --- a/app/components/post_list/post/body/reactions/reaction.tsx +++ b/app/components/post_list/post/body/reactions/reaction.tsx @@ -59,7 +59,7 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index 0298cd6c66..b504c746eb 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -103,7 +103,7 @@ const Header = (props: HeaderProps) => { theme={theme} userId={post.userId} /> - {showCustomStatusEmoji && customStatusExpired && Boolean(customStatus?.emoji) && ( + {showCustomStatusEmoji && !customStatusExpired && Boolean(customStatus?.emoji) && ( void; + placeholderTextColor: string; + searchClearButtonTestID: string; + tintColorDelete: string; + titleCancelColor: string; +} + +const ClearIcon = ({deleteIconSizeAndroid, onClear, placeholderTextColor, searchClearButtonTestID, tintColorDelete, titleCancelColor}: ClearIconProps) => { + const onPressClear = useCallback(() => onClear(), []); + + if (Platform.OS === 'ios') { + return ( + + ); + } + + return ( + + ); +}; + +export default memo(ClearIcon); diff --git a/app/components/search_bar/components/search_icon.tsx b/app/components/search_bar/components/search_icon.tsx new file mode 100644 index 0000000000..4edb9b2307 --- /dev/null +++ b/app/components/search_bar/components/search_icon.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Platform, TouchableWithoutFeedback, ViewStyle} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; + +type SearchIconProps = { + backArrowSize: number; + clearIconColorAndroid: string; + iOSStyle: ViewStyle; + onCancel: () => void; + searchCancelButtonTestID: string; + searchIconColor: string; + searchIconSize: number; + showArrow: boolean; +} + +const SearchIcon = ({backArrowSize, clearIconColorAndroid, iOSStyle, onCancel, searchCancelButtonTestID, searchIconColor, searchIconSize, showArrow}: SearchIconProps) => { + if (Platform.OS === 'ios') { + return ( + + ); + } + + if (showArrow) { + return ( + + + + ); + } + + return ( + + ); +}; + +export default SearchIcon; diff --git a/app/components/search_bar/index.tsx b/app/components/search_bar/index.tsx new file mode 100644 index 0000000000..1176583a6e --- /dev/null +++ b/app/components/search_bar/index.tsx @@ -0,0 +1,316 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import {IntlShape} from 'react-intl'; +import { + Animated, + InteractionManager, + Keyboard, + StyleSheet, + View, + Platform, + ViewStyle, + ReturnKeyTypeOptions, + KeyboardTypeOptions, + NativeSyntheticEvent, + TextInputSelectionChangeEventData, + TextStyle, +} from 'react-native'; +import {SearchBar} from 'react-native-elements'; + +import CompassIcon from '@components/compass_icon'; + +import ClearIcon from './components/clear_icon'; +import SearchIcon from './components/search_icon'; +import {getSearchStyles} from './styles'; + +const LEFT_COMPONENT_INITIAL_POSITION = Platform.OS === 'ios' ? 7 : 0; + +type SearchProps = { + autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters' | undefined; + autoFocus?: boolean; + backArrowSize?: number; + backgroundColor: string; + blurOnSubmit?: boolean; + cancelButtonStyle?: ViewStyle; + cancelTitle?: string; + containerHeight?: number; + containerStyle?: ViewStyle; + deleteIconSize?: number; + editable?: boolean; + inputHeight: number; + inputStyle?: TextStyle; + intl?: IntlShape; + keyboardAppearance?: 'default' | 'light' | 'dark' | undefined; + keyboardShouldPersist?: boolean; + keyboardType?: KeyboardTypeOptions | undefined; + leftComponent?: JSX.Element; + onBlur?: () => void; + onCancelButtonPress: (text?: string) => void; + onChangeText: (text: string) => void; + onFocus?: () => void; + onSearchButtonPress?: (value: string) => void; + onSelectionChange?: (e: NativeSyntheticEvent) => void; + placeholder?: string; + placeholderTextColor?: string; + returnKeyType?: ReturnKeyTypeOptions | undefined; + searchBarRightMargin?: number; + searchIconSize?: number; + selectionColor?: string; + showArrow?: boolean; + showCancel?: boolean; + testID: string; + tintColorDelete: string; + tintColorSearch: string; + titleCancelColor: string; + value?: string; +}; + +type SearchState = { + leftComponentWidth: number; +} + +export default class Search extends PureComponent { + static defaultProps = { + backArrowSize: 24, + blurOnSubmit: false, + containerHeight: 40, + deleteIconSize: 20, + editable: true, + keyboardShouldPersist: false, + keyboardType: 'default', + onBlur: () => true, + onSelectionChange: () => true, + placeholderTextColor: 'grey', + returnKeyType: 'search', + searchBarRightMargin: 0, + searchIconSize: 24, + showArrow: false, + showCancel: true, + value: '', + }; + + private readonly leftComponentAnimated: Animated.Value; + private readonly searchContainerAnimated: Animated.Value; + private searchContainerRef: any; + private inputKeywordRef: any; + private readonly searchStyle: any; + + constructor(props: SearchProps | Readonly) { + super(props); + this.state = { + leftComponentWidth: 0, + }; + + this.leftComponentAnimated = new Animated.Value(LEFT_COMPONENT_INITIAL_POSITION); + this.searchContainerAnimated = new Animated.Value(0); + + const {backgroundColor, cancelButtonStyle, containerHeight, inputHeight, inputStyle, placeholderTextColor, searchBarRightMargin, tintColorDelete, tintColorSearch, titleCancelColor} = props; + + this.searchStyle = getSearchStyles(backgroundColor, cancelButtonStyle, containerHeight!, inputHeight, inputStyle, placeholderTextColor!, searchBarRightMargin!, tintColorDelete, tintColorSearch, titleCancelColor); + } + + setSearchContainerRef = (ref: any) => { + this.searchContainerRef = ref; + }; + + setInputKeywordRef = (ref: any) => { + this.inputKeywordRef = ref; + }; + + blur = () => { + this.inputKeywordRef.blur(); + }; + + focus = () => { + this.inputKeywordRef.focus(); + }; + + onBlur = async () => { + this.props?.onBlur?.(); + + if (this.props.leftComponent) { + await this.collapseAnimation(); + } + }; + + onLeftComponentLayout = (event: { nativeEvent: { layout: { width: any } } }) => { + const leftComponentWidth = event.nativeEvent.layout.width; + this.setState({leftComponentWidth}); + }; + + onSearch = async () => { + const {keyboardShouldPersist, onSearchButtonPress, value} = this.props; + if (!keyboardShouldPersist) { + await Keyboard.dismiss(); + } + + if (value) { + onSearchButtonPress?.(value); + } + }; + + onChangeText = (text: string) => { + const {onChangeText} = this.props; + if (onChangeText) { + onChangeText(text); + } + }; + + onFocus = () => { + const {leftComponent, onFocus} = this.props; + InteractionManager.runAfterInteractions(async () => { + onFocus?.(); + if (leftComponent) { + await this.expandAnimation(); + } + }); + }; + + onClear = () => { + this.focus(); + this.props.onChangeText(''); + }; + + onCancel = () => { + const {onCancelButtonPress} = this.props; + + Keyboard.dismiss(); + InteractionManager.runAfterInteractions(() => { + return onCancelButtonPress?.(); + }); + }; + + onSelectionChange = (event: NativeSyntheticEvent) => { + const {onSelectionChange} = this.props; + onSelectionChange?.(event); + }; + + expandAnimation = () => { + return new Promise((resolve) => { + Animated.parallel([ + Animated.timing(this.leftComponentAnimated, {toValue: -115, duration: 200} as Animated.TimingAnimationConfig), + Animated.timing(this.searchContainerAnimated, {toValue: this.state.leftComponentWidth * -1, duration: 200} as Animated.TimingAnimationConfig), + ]).start(resolve); + }); + }; + + collapseAnimation = () => { + return new Promise((resolve) => { + Animated.parallel([ + Animated.timing(this.leftComponentAnimated, {toValue: LEFT_COMPONENT_INITIAL_POSITION, duration: 200} as Animated.TimingAnimationConfig), + Animated.timing(this.searchContainerAnimated, {toValue: 0, duration: 200} as Animated.TimingAnimationConfig), + ]).start(resolve); + }); + }; + + render() { + const {autoCapitalize, autoFocus, backArrowSize, blurOnSubmit, cancelTitle, deleteIconSize, editable, intl, keyboardAppearance, keyboardType, leftComponent, placeholder, placeholderTextColor, returnKeyType, searchIconSize, selectionColor, showArrow, showCancel, testID, tintColorDelete, tintColorSearch, titleCancelColor, value} = this.props; + + const searchClearButtonTestID = `${testID}.search.clear.button`; + const searchCancelButtonTestID = `${testID}.search.cancel.button`; + const searchInputTestID = `${testID}.search.input`; + + const {cancelButtonPropStyle, containerStyle, inputContainerStyle, inputTextStyle, searchBarStyle, styles} = this.searchStyle; + + return ( + + {leftComponent && ( + + {leftComponent} + + )} + + + ) + } + + // @ts-expect-error: The clearIcon can also accept a ReactElement + clearIcon={ + + } + containerStyle={containerStyle} + disableFullscreenUI={true} + editable={editable} + enablesReturnKeyAutomatically={true} + inputContainerStyle={inputContainerStyle} + inputStyle={inputTextStyle} + keyboardAppearance={keyboardAppearance} + keyboardType={keyboardType} + leftIconContainerStyle={styles.leftIcon} + placeholder={placeholder || intl?.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'}) || ''} + placeholderTextColor={placeholderTextColor} + platform={Platform.OS as 'ios' | 'android'} + onBlur={this.onBlur} + onCancel={this.onCancel} + + // @ts-expect-error: The TS definition for this SearchBar is messed up + onChangeText={this.onChangeText} + onClear={this.onClear} + onFocus={this.onFocus} + onSelectionChange={this.onSelectionChange} + onSubmitEditing={this.onSearch} + + // @ts-expect-error: The searchIcon can also accept a ReactElement + searchIcon={ + + } + selectionColor={selectionColor} + showCancel={showCancel!} + ref={this.setInputKeywordRef} + returnKeyType={returnKeyType} + underlineColorAndroid='transparent' + value={value!} + /> + + + ); + } +} diff --git a/app/components/search_bar/styles.ts b/app/components/search_bar/styles.ts new file mode 100644 index 0000000000..86f8ae3d8b --- /dev/null +++ b/app/components/search_bar/styles.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Platform, StyleSheet, TextStyle, ViewStyle} from 'react-native'; + +export const getSearchBarStyle = (backgroundColor: string, cancelButtonStyle: ViewStyle | undefined, containerHeight: number, inputHeight: number, inputStyle: TextStyle | undefined, placeholderTextColor: string, searchBarRightMargin: number, tintColorDelete: string, tintColorSearch: string, titleCancelColor: string) => ({ + cancelButtonText: { + ...cancelButtonStyle, + color: titleCancelColor, + }, + container: { + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + height: containerHeight, + overflow: 'hidden', + }, + clearIconColorIos: tintColorDelete || 'grey', + clearIconColorAndroid: titleCancelColor || placeholderTextColor, + inputStyle: { + ...inputStyle, + backgroundColor: 'transparent', + height: inputHeight, + }, + inputContainer: { + backgroundColor: inputStyle?.backgroundColor, + height: inputHeight, + }, + searchBarWrapper: { + marginRight: searchBarRightMargin, + height: Platform.select({ + ios: inputHeight || containerHeight - 10, + android: inputHeight, + }), + }, + searchBarContainer: { + backgroundColor, + }, + searchIcon: { + color: tintColorSearch || placeholderTextColor, + top: 8, + }, + searchIconColor: tintColorSearch || placeholderTextColor, +}); + +export const getStyles = () => StyleSheet.create({ + defaultColor: { + color: 'grey', + }, + fullWidth: { + flex: 1, + }, + inputContainer: { + borderRadius: Platform.select({ + ios: 2, + android: 0, + }), + }, + inputMargin: { + marginLeft: 4, + paddingTop: 0, + marginTop: Platform.select({ + ios: 0, + android: 8, + }), + }, + leftIcon: { + marginLeft: 4, + width: 30, + }, + searchContainer: { + paddingTop: 0, + paddingBottom: 0, + }, + text: { + fontSize: Platform.select({ + ios: 14, + android: 15, + }), + color: '#fff', + }, + leftComponent: { + position: 'relative', + marginLeft: 2, + }, +}); + +export const getSearchStyles = (backgroundColor: string, cancelButtonStyle: ViewStyle | undefined, containerHeight: number, inputHeight: number, inputStyle: TextStyle | undefined, placeholderTextColor: string, searchBarRightMargin: number, tintColorDelete: string, tintColorSearch: string, titleCancelColor: string) => { + const searchBarStyle = getSearchBarStyle(backgroundColor, cancelButtonStyle, containerHeight, inputHeight, inputStyle, placeholderTextColor, searchBarRightMargin, tintColorDelete, tintColorSearch, titleCancelColor); + + const styles = getStyles(); + + const inputTextStyle = { + ...styles.text, + ...styles.inputMargin, + ...searchBarStyle.inputStyle, + }; + + const inputContainerStyle = { + ...styles.inputContainer, + ...searchBarStyle.inputContainer, + }; + + const containerStyle = { + ...styles.searchContainer, + ...styles.fullWidth, + ...searchBarStyle.searchBarContainer, + }; + + const cancelButtonPropStyle = { + buttonTextStyle: { + ...styles.text, + ...searchBarStyle.cancelButtonText, + }, + }; + + return { + cancelButtonPropStyle, + containerStyle, + inputContainerStyle, + inputTextStyle, + searchBarStyle, + styles, + }; +}; diff --git a/app/components/slide_up_panel_item/index.tsx b/app/components/slide_up_panel_item/index.tsx index 7fe1c0be82..e8144d57c8 100644 --- a/app/components/slide_up_panel_item/index.tsx +++ b/app/components/slide_up_panel_item/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback} from 'react'; -import {StyleProp, Text, View, ViewStyle} from 'react-native'; +import {StyleProp, Text, TextStyle, View, ViewStyle} from 'react-native'; import FastImage, {ImageStyle, Source} from 'react-native-fast-image'; import CompassIcon from '@components/compass_icon'; @@ -15,7 +15,9 @@ import {isValidUrl} from '@utils/url'; type SlideUpPanelProps = { destructive?: boolean; icon?: string | Source; + imageStyles?: StyleProp; onPress: () => void; + textStyles?: TextStyle; testID?: string; text: string; } @@ -32,14 +34,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { color: '#D0021B', }, row: { - flex: 1, + width: '100%', flexDirection: 'row', }, iconContainer: { - alignItems: 'center', height: 50, justifyContent: 'center', - width: 60, + marginRight: 10, }, noIconContainer: { height: 50, @@ -61,15 +62,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { opacity: 0.9, letterSpacing: -0.45, }, - footer: { - marginHorizontal: 17, - borderBottomWidth: 0.5, - borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2), - }, }; }); -const SlideUpPanelItem = ({destructive, icon, onPress, testID, text}: SlideUpPanelProps) => { +const SlideUpPanelItem = ({destructive, icon, imageStyles, onPress, testID, text, textStyles}: SlideUpPanelProps) => { const theme = useTheme(); const handleOnPress = useCallback(preventDoubleTap(onPress, 500), []); const style = getStyleSheet(theme); @@ -77,7 +73,7 @@ const SlideUpPanelItem = ({destructive, icon, onPress, testID, text}: SlideUpPan let image; let iconStyle: StyleProp = [style.iconContainer]; if (icon) { - const imageStyle: StyleProp = [style.icon]; + const imageStyle: StyleProp = [style.icon, imageStyles]; if (destructive) { imageStyle.push(style.destructive); } @@ -105,27 +101,22 @@ const SlideUpPanelItem = ({destructive, icon, onPress, testID, text}: SlideUpPan } return ( - - - - {Boolean(image) && - {image} - } - - {text} - + + {Boolean(image) && + {image} + } + + {text} - - - + + ); }; diff --git a/app/components/status_label/index.tsx b/app/components/status_label/index.tsx new file mode 100644 index 0000000000..8cc7e0c300 --- /dev/null +++ b/app/components/status_label/index.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {TextStyle} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {General} from '@constants'; +import {useTheme} from '@context/theme'; +import {t} from '@i18n'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type StatusLabelProps = { + status?: string; + labelStyle?: TextStyle; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + label: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 17, + textAlignVertical: 'center', + includeFontPadding: false, + }, + }; +}); + +const StatusLabel = ({status = General.OFFLINE, labelStyle}: StatusLabelProps) => { + const theme = useTheme(); + const style = getStyleSheet(theme); + + let i18nId = t('status_dropdown.set_offline'); + let defaultMessage = 'Offline'; + + switch (status) { + case General.AWAY: + i18nId = t('status_dropdown.set_away'); + defaultMessage = 'Away'; + break; + case General.DND: + i18nId = t('status_dropdown.set_dnd'); + defaultMessage = 'Do Not Disturb'; + break; + case General.ONLINE: + i18nId = t('status_dropdown.set_online'); + defaultMessage = 'Online'; + break; + } + + if (status === General.OUT_OF_OFFICE) { + i18nId = t('status_dropdown.set_ooo'); + defaultMessage = 'Out Of Office'; + } + + return ( + + ); +}; + +export default StatusLabel; diff --git a/app/components/tablet_title/index.tsx b/app/components/tablet_title/index.tsx new file mode 100644 index 0000000000..32c59fb11b --- /dev/null +++ b/app/components/tablet_title/index.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Platform, Text, View} from 'react-native'; + +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + action?: string; + onPress: () => void; + title: string; + testID: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + actionContainer: { + alignItems: 'flex-end', + justifyContent: 'center', + marginRight: 20, + }, + action: { + color: theme.buttonBg, + fontFamily: 'OpenSans-Semibold', + fontSize: 16, + lineHeight: 24, + }, + container: { + backgroundColor: theme.centerChannelBg, + borderBottomWidth: 1, + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.08), + flexDirection: 'row', + height: 34, + width: '100%', + }, + titleContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + title: { + color: theme.centerChannelColor, + fontFamily: 'OpenSans-Semibold', + fontSize: 18, + lineHeight: 24, + }, +})); + +const TabletTitle = ({action, onPress, testID, title}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + return ( + <> + + + {title} + + {Boolean(action) && + + + {action} + + + } + + + ); +}; + +export default TabletTitle; diff --git a/app/components/touchable_with_feedback/touchable_with_feedback.android.tsx b/app/components/touchable_with_feedback/touchable_with_feedback.android.tsx index bebea779f7..53f2844272 100644 --- a/app/components/touchable_with_feedback/touchable_with_feedback.android.tsx +++ b/app/components/touchable_with_feedback/touchable_with_feedback.android.tsx @@ -4,16 +4,15 @@ /* eslint-disable new-cap */ import React, {memo} from 'react'; -import {TouchableOpacity, TouchableWithoutFeedback, View, StyleProp, ViewStyle} from 'react-native'; +import {Touchable, TouchableOpacity, TouchableWithoutFeedback, View, StyleProp, ViewStyle} from 'react-native'; import {TouchableNativeFeedback} from 'react-native-gesture-handler'; -type TouchableProps = { +type TouchableProps = Touchable & { testID: string; children: React.ReactNode | React.ReactNode[]; underlayColor: string; type: 'native' | 'opacity' | 'none'; style?: StyleProp; - [x: string]: any; } const TouchableWithFeedbackAndroid = ({testID, children, underlayColor, type = 'native', ...props}: TouchableProps) => { @@ -23,7 +22,7 @@ const TouchableWithFeedbackAndroid = ({testID, children, underlayColor, type = ' diff --git a/app/components/touchable_with_feedback/touchable_with_feedback.ios.tsx b/app/components/touchable_with_feedback/touchable_with_feedback.ios.tsx index 82c0973759..0199eafbf8 100644 --- a/app/components/touchable_with_feedback/touchable_with_feedback.ios.tsx +++ b/app/components/touchable_with_feedback/touchable_with_feedback.ios.tsx @@ -2,14 +2,13 @@ // See LICENSE.txt for license information. import React, {memo} from 'react'; -import {PanResponder, TouchableHighlight, TouchableOpacity, TouchableWithoutFeedback, View} from 'react-native'; +import {PanResponder, Touchable, TouchableHighlight, TouchableOpacity, TouchableWithoutFeedback, View} from 'react-native'; -type TouchableProps = { +type TouchableProps = Touchable & { cancelTouchOnPanning: boolean; children: React.ReactNode | React.ReactNode[]; testID: string; type: 'native' | 'opacity' | 'none'; - [x: string]: any; } const TouchableWithFeedbackIOS = ({testID, children, type = 'native', cancelTouchOnPanning, ...props}: TouchableProps) => { diff --git a/app/constants/custom_status.ts b/app/constants/custom_status.ts new file mode 100644 index 0000000000..ba9a5bf50c --- /dev/null +++ b/app/constants/custom_status.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {t} from '@i18n'; + +export enum CustomStatusDuration { + DONT_CLEAR = '', + THIRTY_MINUTES = 'thirty_minutes', + ONE_HOUR = 'one_hour', + FOUR_HOURS = 'four_hours', + TODAY = 'today', + THIS_WEEK = 'this_week', + DATE_AND_TIME = 'date_and_time', +} + +const { + DONT_CLEAR, + THIRTY_MINUTES, + ONE_HOUR, + FOUR_HOURS, + TODAY, + THIS_WEEK, + DATE_AND_TIME, +} = CustomStatusDuration; + +export const CST = { + [DONT_CLEAR]: { + id: t('custom_status.expiry_dropdown.dont_clear'), + defaultMessage: "Don't clear", + }, + [THIRTY_MINUTES]: { + id: t('custom_status.expiry_dropdown.thirty_minutes'), + defaultMessage: '30 minutes', + }, + [ONE_HOUR]: { + id: t('custom_status.expiry_dropdown.one_hour'), + defaultMessage: '1 hour', + }, + [FOUR_HOURS]: { + id: t('custom_status.expiry_dropdown.four_hours'), + defaultMessage: '4 hours', + }, + [TODAY]: { + id: t('custom_status.expiry_dropdown.today'), + defaultMessage: 'Today', + }, + [THIS_WEEK]: { + id: t('custom_status.expiry_dropdown.this_week'), + defaultMessage: 'This week', + }, + [DATE_AND_TIME]: { + id: t('custom_status.expiry_dropdown.date_and_time'), + defaultMessage: 'Date and Time', + }, +}; + +export const CUSTOM_STATUS_TEXT_CHARACTER_LIMIT = 100; + +export const SET_CUSTOM_STATUS_FAILURE = 'set_custom_status_failure'; + +export const CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES = 30; diff --git a/app/constants/database.ts b/app/constants/database.ts index 45e7b72969..b7f53dc619 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -57,6 +57,7 @@ export const SYSTEM_IDENTIFIERS = { INTEGRATION_TRIGGER_ID: 'IntegreationTriggerId', LICENSE: 'license', WEBSOCKET: 'WebSocket', + RECENT_CUSTOM_STATUS: 'recentCustomStatus', }; export const GLOBAL_IDENTIFIERS = { diff --git a/app/constants/emoji.ts b/app/constants/emoji.ts index 032a046f45..5c94f7ad2b 100644 --- a/app/constants/emoji.ts +++ b/app/constants/emoji.ts @@ -3,6 +3,8 @@ export const ALL_EMOJIS = 'all_emojis'; export const MAX_ALLOWED_REACTIONS = 40; +export const SORT_BY_NAME = 'name'; +export const EMOJIS_PER_PAGE = 200; // reEmoji matches an emoji (eg. :taco:) at the start of a string. export const reEmoji = /^:([a-z0-9_\-+]+):\B/i; @@ -13,8 +15,8 @@ export const reEmoticon = /^(?:(:-?\))|(;-?\))|(:o)|(:-o)|(:-?])|(:-?d)|(x-d)|(: // reMain matches some amount of plain text, starting at the beginning of the string and hopefully stopping right // before the next emoji by looking for any character that could start an emoji (:, ;, x, or <) export const reMain = /^[\s\S]+?(?=[:;x<]|$)/i; - export default { ALL_EMOJIS, MAX_ALLOWED_REACTIONS, + SORT_BY_NAME, }; diff --git a/app/constants/events.ts b/app/constants/events.ts new file mode 100644 index 0000000000..5ef48c9ed9 --- /dev/null +++ b/app/constants/events.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import keyMirror from '@utils/key_mirror'; + +export default keyMirror({ + ACCOUNT_SELECT_TABLET_VIEW: null, +}); diff --git a/app/constants/index.ts b/app/constants/index.ts index 8ba4d7b948..b1bcc2fbc7 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -4,9 +4,12 @@ import ActionType from './action_type'; import Apps from './apps'; import Attachment from './attachment'; +import {CustomStatusDuration} from './custom_status'; import Database from './database'; import DeepLink from './deep_linking'; import Device from './device'; +import Emoji from './emoji'; +import Events from './events'; import Files from './files'; import General from './general'; import List from './list'; @@ -22,11 +25,14 @@ import WebsocketEvents from './websocket'; export { ActionType, - Attachment, Apps, + Attachment, + CustomStatusDuration, Database, DeepLink, Device, + Emoji, + Events, Files, General, List, diff --git a/app/constants/preferences.ts b/app/constants/preferences.ts index 03f884c7ed..5334e5e857 100644 --- a/app/constants/preferences.ts +++ b/app/constants/preferences.ts @@ -6,6 +6,7 @@ const Preferences: Record = { CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME: 'channel_approximate_view_time', CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show', CATEGORY_GROUP_CHANNEL_SHOW: 'group_channel_show', + CATEGORY_EMOJI: 'emoji', CATEGORY_FLAGGED_POST: 'flagged_post', CATEGORY_FAVORITE_CHANNEL: 'favorite_channel', CATEGORY_AUTO_RESET_MANUAL_STATUS: 'auto_reset_manual_status', @@ -28,6 +29,7 @@ const Preferences: Record = { DISPLAY_PREFER_NICKNAME: 'nickname_full_name', DISPLAY_PREFER_FULL_NAME: 'full_name', DISPLAY_PREFER_USERNAME: 'username', + EMOJI_SKINTONE: 'emoji_skintone', MENTION_KEYS: 'mention_keys', USE_MILITARY_TIME: 'use_military_time', CATEGORY_SIDEBAR_SETTINGS: 'sidebar_settings', diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 026bda8423..085ab32d88 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -2,12 +2,15 @@ // See LICENSE.txt for license information. export const ABOUT = 'About'; +export const EMOJI_PICKER = 'AddReaction'; export const APP_FORM = 'AppForm'; export const BOTTOM_SHEET = 'BottomSheet'; export const CHANNEL = 'Channel'; +export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter'; +export const CUSTOM_STATUS = 'CustomStatus'; export const FORGOT_PASSWORD = 'ForgotPassword'; -export const INTEGRATION_SELECTOR = 'IntegrationSelector'; export const HOME = 'Home'; +export const INTEGRATION_SELECTOR = 'IntegrationSelector'; export const LOGIN = 'Login'; export const LOGIN_OPTIONS = 'LoginOptions'; export const MAIN_SIDEBAR = 'MainSidebar'; @@ -21,12 +24,15 @@ export const THREAD = 'Thread'; export default { ABOUT, + EMOJI_PICKER, APP_FORM, BOTTOM_SHEET, CHANNEL, + CUSTOM_STATUS_CLEAR_AFTER, + CUSTOM_STATUS, FORGOT_PASSWORD, - INTEGRATION_SELECTOR, HOME, + INTEGRATION_SELECTOR, LOGIN, LOGIN_OPTIONS, MAIN_SIDEBAR, diff --git a/app/context/theme/index.tsx b/app/context/theme/index.tsx index d133770047..da67ad930b 100644 --- a/app/context/theme/index.tsx +++ b/app/context/theme/index.tsx @@ -5,17 +5,19 @@ import {Q} from '@nozbe/watermelondb'; import withObservables from '@nozbe/with-observables'; import React, {ComponentType, createContext, useEffect} from 'react'; import {Appearance, EventSubscription} from 'react-native'; +import {of as of$} from 'rxjs'; +import {catchError, switchMap} from 'rxjs/operators'; import {Preferences} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import EphemeralStore from '@store/ephemeral_store'; -import {setNavigationStackStyles} from '@utils/theme'; +import {setNavigationStackStyles, setThemeDefaults} from '@utils/theme'; import type {PreferenceModel, SystemModel} from '@database/models/server'; import type Database from '@nozbe/watermelondb/Database'; type Props = { - currentTeamId: SystemModel[]; + currentTeamId?: string; children: React.ReactNode; themes: PreferenceModel[]; } @@ -38,12 +40,11 @@ const {Consumer, Provider} = ThemeContext; const ThemeProvider = ({currentTeamId, children, themes}: Props) => { const getTheme = (): Theme => { - if (currentTeamId.length) { - const teamId = currentTeamId[0]?.value; - const teamTheme = themes.find((t) => t.name === teamId) || themes[0]; + if (currentTeamId) { + const teamTheme = themes.find((t) => t.name === currentTeamId) || themes[0]; if (teamTheme?.value) { try { - const theme = JSON.parse(teamTheme.value) as Theme; + const theme = setThemeDefaults(JSON.parse(teamTheme.value) as Theme); EphemeralStore.theme = theme; requestAnimationFrame(() => { setNavigationStackStyles(theme); @@ -87,7 +88,10 @@ export function useTheme(): Theme { } const enhancedThemeProvider = withObservables([], ({database}: {database: Database}) => ({ - currentTeamId: database.get(SYSTEM).query(Q.where('id', SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID)).observe(), + currentTeamId: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe( + switchMap((row) => of$(row.value)), + catchError(() => of$(undefined)), + ), themes: database.get(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_THEME)).observeWithColumns(['value']), })); diff --git a/app/database/components/index.tsx b/app/database/components/index.tsx index a8a7bd283e..32b8617d61 100644 --- a/app/database/components/index.tsx +++ b/app/database/components/index.tsx @@ -15,21 +15,22 @@ import type ServersModel from '@typings/database/models/app/servers'; type State = { database: Database; serverUrl: string; -} +}; const {SERVERS} = MM_TABLES.APP; -export function withServerDatabase( - Component: ComponentType, -): ComponentType { +export function withServerDatabase(Component: ComponentType): ComponentType { return function ServerDatabaseComponent(props) { - const [state, setState] = useState(); + const [state, setState] = useState(); const db = DatabaseManager.appDatabase?.database; const observer = (servers: ServersModel[]) => { - const server = servers.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a)); + const server = servers.reduce((a, b) => + (b.lastActiveAt > a.lastActiveAt ? b : a), + ); - const serverDatabase = DatabaseManager.serverDatabases[server?.url]?.database; + const serverDatabase = + DatabaseManager.serverDatabases[server?.url]?.database; setState({ database: serverDatabase, @@ -54,9 +55,7 @@ export function withServerDatabase( } return ( - + diff --git a/app/database/models/server/user.ts b/app/database/models/server/user.ts index ba2ad6bcde..3c6b5c31aa 100644 --- a/app/database/models/server/user.ts +++ b/app/database/models/server/user.ts @@ -136,7 +136,7 @@ export default class UserModel extends Model { /** teams : All the team that this user is part of */ @children(TEAM_MEMBERSHIP) teams!: TeamMembershipModel[]; - prepareSatus = (status: string) => { + prepareStatus = (status: string) => { this.prepareUpdate((u) => { u.status = status; }); diff --git a/app/init/launch.ts b/app/init/launch.ts index f1b57319df..4b59fbb7cb 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -8,7 +8,7 @@ import {Notifications} from 'react-native-notifications'; import {appEntry, upgradeEntry} from '@actions/remote/entry'; import {Screens} from '@constants'; import DatabaseManager from '@database/manager'; -import {getActiveServerUrl, getServerCredentials} from '@init/credentials'; +import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials'; import {queryThemeForCurrentTeam} from '@queries/servers/preference'; import {queryCurrentUserId} from '@queries/servers/system'; import {goToScreen, resetToHome, resetToSelectServer} from '@screens/navigation'; @@ -43,7 +43,7 @@ const launchAppFromNotification = (notification: NotificationWithData) => { }; const launchApp = async (props: LaunchProps, resetNavigation = true) => { - let serverUrl; + let serverUrl: string | undefined; switch (props?.launchType) { case LaunchType.DeepLink: if (props.extra?.type !== DeepLinkType.Invalid) { @@ -85,7 +85,11 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => { `An error ocurred while upgrading the app to the new version.\n\nDetails: ${result.error}\n\nThe app will now quit.`, [{ text: 'OK', - onPress: () => Emm.exitApp(), + onPress: async () => { + await DatabaseManager.destroyServerDatabase(serverUrl!); + await removeServerCredentials(serverUrl!); + Emm.exitApp(); + }, }], ); return; diff --git a/app/queries/servers/custom_emoji.ts b/app/queries/servers/custom_emoji.ts new file mode 100644 index 0000000000..3710985400 --- /dev/null +++ b/app/queries/servers/custom_emoji.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database, Q} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; + +import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; + +export const queryAllCustomEmojis = async (database: Database): Promise => { + try { + return database.get(MM_TABLES.SERVER.CUSTOM_EMOJI).query().fetch(); + } catch { + return []; + } +}; + +export const queryCustomEmojisByName = async (database: Database, names: string[]): Promise => { + try { + return database.get(MM_TABLES.SERVER.CUSTOM_EMOJI).query(Q.where('name', Q.oneOf(names))).fetch(); + } catch { + return []; + } +}; diff --git a/app/queries/servers/preference.ts b/app/queries/servers/preference.ts index 961a46769e..23adefe668 100644 --- a/app/queries/servers/preference.ts +++ b/app/queries/servers/preference.ts @@ -23,10 +23,13 @@ export const prepareMyPreferences = (operator: ServerDataOperator, preferences: }; export const queryPreferencesByCategoryAndName = (database: Database, category: string, name: string) => { - return database.get(MM_TABLES.SERVER.PREFERENCE).query( - Q.where('category', category), - Q.where('name', name), - ).fetch() as Promise; + return database. + get(MM_TABLES.SERVER.PREFERENCE). + query( + Q.where('category', category), + Q.where('name', name), + ). + fetch(); }; export const queryThemeForCurrentTeam = async (database: Database) => { diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index fb123de508..398c2c4c72 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -90,6 +90,15 @@ export const queryConfig = async (serverDatabase: Database) => { } }; +export const queryRecentCustomStatuses = async (serverDatabase: Database) => { + try { + const recent = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.RECENT_CUSTOM_STATUS); + return recent; + } catch { + return undefined; + } +}; + export const queryExpandedLinks = async (serverDatabase: Database) => { try { const expandedLinks = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.EXPANDED_LINKS) as SystemModel; diff --git a/app/screens/bottom_sheet/index.tsx b/app/screens/bottom_sheet/index.tsx index 27d80c5980..8232442f5e 100644 --- a/app/screens/bottom_sheet/index.tsx +++ b/app/screens/bottom_sheet/index.tsx @@ -2,39 +2,55 @@ // See LICENSE.txt for license information. import React, {ReactNode, useEffect, useRef} from 'react'; -import {BackHandler, DeviceEventEmitter, StyleSheet, useWindowDimensions, View} from 'react-native'; +import {BackHandler, DeviceEventEmitter, Keyboard, StyleSheet, useWindowDimensions, View} from 'react-native'; import {State, TapGestureHandler} from 'react-native-gesture-handler'; +import {Navigation as RNN} from 'react-native-navigation'; import Animated from 'react-native-reanimated'; import RNBottomSheet from 'reanimated-bottom-sheet'; -import {Navigation} from '@constants'; +import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme'; +import {Device, Navigation} from '@constants'; import {useTheme} from '@context/theme'; +import {useSplitView} from '@hooks/device'; import {dismissModal} from '@screens/navigation'; import {hapticFeedback} from '@utils/general'; import Indicator from './indicator'; type SlideUpPanelProps = { + closeButtonId?: string; initialSnapIndex?: number; renderContent: () => ReactNode; snapPoints?: Array; } -const BottomSheet = ({initialSnapIndex = 0, renderContent, snapPoints = ['90%', '50%', 50]}: SlideUpPanelProps) => { +const BottomSheet = ({closeButtonId, initialSnapIndex = 0, renderContent, snapPoints = ['90%', '50%', 50]}: SlideUpPanelProps) => { const sheetRef = useRef(null); const dimensions = useWindowDimensions(); + const isSplitView = useSplitView(); + const isTablet = Device.IS_TABLET && !isSplitView; const theme = useTheme(); const lastSnap = snapPoints.length - 1; useEffect(() => { - const listener = DeviceEventEmitter.addListener(Navigation.NAVIGATION_CLOSE_MODAL, () => sheetRef.current?.snapTo(lastSnap)); + const listener = DeviceEventEmitter.addListener(Navigation.NAVIGATION_CLOSE_MODAL, () => { + if (sheetRef.current) { + sheetRef.current.snapTo(lastSnap); + } else { + dismissModal(); + } + }); return () => listener.remove(); }, []); useEffect(() => { const listener = BackHandler.addEventListener('hardwareBackPress', () => { - sheetRef.current?.snapTo(1); + if (sheetRef.current) { + sheetRef.current.snapTo(1); + } else { + dismissModal(); + } return true; }); @@ -43,9 +59,20 @@ const BottomSheet = ({initialSnapIndex = 0, renderContent, snapPoints = ['90%', useEffect(() => { hapticFeedback(); + Keyboard.dismiss(); sheetRef.current?.snapTo(initialSnapIndex); }, []); + useEffect(() => { + const navigationEvents = RNN.events().registerNavigationButtonPressedListener(({buttonId}) => { + if (closeButtonId && buttonId === closeButtonId) { + dismissModal(); + } + }); + + return () => navigationEvents.remove(); + }, []); + const renderBackdrop = () => { return ( @@ -79,6 +108,16 @@ const BottomSheet = ({initialSnapIndex = 0, renderContent, snapPoints = ['90%', ); + if (isTablet) { + const styles = getStyleSheet(theme); + return ( + <> + + {renderContainer()} + + ); + } + return ( <> { + return { + separator: { + height: 1, + borderTopWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.08), + }, + }; +}); + export default BottomSheet; diff --git a/app/screens/custom_status/components/clear_after.tsx b/app/screens/custom_status/components/clear_after.tsx new file mode 100644 index 0000000000..be4dfa0ba9 --- /dev/null +++ b/app/screens/custom_status/components/clear_after.tsx @@ -0,0 +1,102 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {Text, TouchableOpacity, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import CustomStatusExpiry from '@components/custom_status/custom_status_expiry'; +import FormattedText from '@components/formatted_text'; +import {CustomStatusDuration, CST} from '@constants/custom_status'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import type UserModel from '@typings/database/models/servers/user'; +import type {Moment} from 'moment-timezone'; + +type Props = { + currentUser: UserModel; + duration: CustomStatusDuration; + onOpenClearAfterModal: () => void; + theme: Theme; + expiresAt: Moment; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + rightIcon: { + position: 'absolute', + right: 18, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + expiryTimeLabel: { + fontSize: 17, + paddingLeft: 16, + textAlignVertical: 'center', + color: theme.centerChannelColor, + }, + inputContainer: { + justifyContent: 'center', + height: 48, + backgroundColor: theme.centerChannelBg, + }, + expiryTime: { + position: 'absolute', + right: 42, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + customStatusExpiry: { + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + }; +}); + +const ClearAfter = ({currentUser, duration, expiresAt, onOpenClearAfterModal, theme}: Props) => { + const intl = useIntl(); + const style = getStyleSheet(theme); + + const renderClearAfterTime = () => { + if (duration && duration === CustomStatusDuration.DATE_AND_TIME) { + return ( + + + + ); + } + + return ( + + ); + }; + + return ( + + + {intl.formatMessage({id: 'mobile.custom_status.clear_after', defaultMessage: 'Clear After'})} + {renderClearAfterTime()} + + + + ); +}; + +export default ClearAfter; diff --git a/app/screens/custom_status/components/custom_status_emoji.tsx b/app/screens/custom_status/components/custom_status_emoji.tsx new file mode 100644 index 0000000000..5e63ee5fdb --- /dev/null +++ b/app/screens/custom_status/components/custom_status_emoji.tsx @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, TouchableOpacity} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import Emoji from '@components/emoji'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + onPress: () => void; + isStatusSet: boolean; + emoji?: string; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + iconContainer: { + position: 'absolute', + left: 14, + top: 10, + }, + icon: { + color: changeOpacity(theme.centerChannelColor, 0.64), + }, + emoji: { + color: theme.centerChannelColor, + }, + }; +}); + +const CustomStatusEmoji = ({emoji, isStatusSet, onPress, theme}: Props) => { + const style = getStyleSheet(theme); + return ( + + {isStatusSet ? ( + + + + ) : ( + + )} + + ); +}; + +export default CustomStatusEmoji; diff --git a/app/screens/custom_status/components/custom_status_input.tsx b/app/screens/custom_status/components/custom_status_input.tsx new file mode 100644 index 0000000000..c25d7b2528 --- /dev/null +++ b/app/screens/custom_status/components/custom_status_input.tsx @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {TextInput, View} from 'react-native'; + +import ClearButton from '@components/custom_status/clear_button'; +import {CUSTOM_STATUS_TEXT_CHARACTER_LIMIT} from '@constants/custom_status'; +import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; + +import CustomStatusEmoji from './custom_status_emoji'; + +type Props = { + emoji?: string; + isStatusSet: boolean; + onChangeText: (value: string) => void; + onClearHandle: () => void; + onOpenEmojiPicker: () => void; + text?: string; + theme: Theme; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1, + marginRight: 16, + marginLeft: 52, + }, + clearButton: { + position: 'absolute', + top: 3, + right: 14, + }, + input: { + alignSelf: 'stretch', + color: theme.centerChannelColor, + width: '100%', + fontSize: 17, + paddingHorizontal: 52, + textAlignVertical: 'center', + height: '100%', + }, + inputContainer: { + justifyContent: 'center', + height: 48, + backgroundColor: theme.centerChannelBg, + }, + }; +}); + +const CustomStatusInput = ({emoji, isStatusSet, onChangeText, onClearHandle, onOpenEmojiPicker, text, theme}: Props) => { + const style = getStyleSheet(theme); + const intl = useIntl(); + const placeholder = intl.formatMessage({id: 'custom_status.set_status', defaultMessage: 'Set a Status'}); + + return ( + + + {isStatusSet && ( + + )} + + {isStatusSet ? ( + + + + ) : null} + + ); +}; + +export default CustomStatusInput; diff --git a/app/screens/custom_status/components/custom_status_suggestion.tsx b/app/screens/custom_status/components/custom_status_suggestion.tsx new file mode 100644 index 0000000000..48942c4252 --- /dev/null +++ b/app/screens/custom_status/components/custom_status_suggestion.tsx @@ -0,0 +1,144 @@ +// 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 {Text, TouchableOpacity, View} from 'react-native'; + +import ClearButton from '@components/custom_status/clear_button'; +import CustomStatusText from '@components/custom_status/custom_status_text'; +import Emoji from '@components/emoji'; +import {CST, CustomStatusDuration} from '@constants/custom_status'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + duration: CustomStatusDuration; + emoji?: string; + expires_at?: string; + handleClear?: (status: UserCustomStatus) => void; + handleSuggestionClick: (status: UserCustomStatus) => void; + isExpirySupported: boolean; + separator: boolean; + text?: string; + theme: Theme; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + backgroundColor: theme.centerChannelBg, + flexDirection: 'row', + minHeight: 50, + }, + iconContainer: { + width: 45, + height: 46, + left: 14, + top: 12, + marginRight: 6, + color: theme.centerChannelColor, + }, + wrapper: { + flex: 1, + }, + textContainer: { + paddingTop: 14, + paddingBottom: 14, + justifyContent: 'center', + width: '70%', + flex: 1, + }, + clearButtonContainer: { + position: 'absolute', + top: 4, + right: 13, + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1, + marginRight: 16, + }, + customStatusDuration: { + color: changeOpacity(theme.centerChannelColor, 0.6), + fontSize: 15, + }, + customStatusText: { + color: theme.centerChannelColor, + }, + }; +}); + +const CustomStatusSuggestion = ({duration, emoji, expires_at, handleClear, handleSuggestionClick, isExpirySupported, separator, text, theme}: Props) => { + const style = getStyleSheet(theme); + const intl = useIntl(); + + const handleClick = useCallback(preventDoubleTap(() => { + handleSuggestionClick({emoji, text, duration}); + }), []); + + const handleSuggestionClear = useCallback(() => { + if (handleClear) { + handleClear({emoji, text, duration, expires_at}); + } + }, []); + + const showCustomStatus = Boolean(duration && duration !== CustomStatusDuration.DATE_AND_TIME && isExpirySupported); + + const clearButton = + handleClear && expires_at ? ( + + + + ) : null; + + return ( + + + {emoji && ( + + + + )} + + + {Boolean(text) && ( + + + + )} + {showCustomStatus && ( + + + + )} + + {clearButton} + {separator && } + + + + ); +}; + +export default CustomStatusSuggestion; diff --git a/app/screens/custom_status/components/custom_status_suggestions.tsx b/app/screens/custom_status/components/custom_status_suggestions.tsx new file mode 100644 index 0000000000..2e507e05d2 --- /dev/null +++ b/app/screens/custom_status/components/custom_status_suggestions.tsx @@ -0,0 +1,107 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {IntlShape} from 'react-intl'; +import {View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {CustomStatusDuration} from '@constants/custom_status'; +import {t} from '@i18n'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import CustomStatusSuggestion from './custom_status_suggestion'; + +type Props = { + intl: IntlShape; + isExpirySupported: boolean; + onHandleCustomStatusSuggestionClick: (status: UserCustomStatus) => void; + recentCustomStatuses: UserCustomStatus[]; + theme: Theme; +}; + +type DefaultUserCustomStatus = { + emoji: string; + message: string; + messageDefault: string; + durationDefault: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + separator: { + marginTop: 32, + }, + title: { + fontSize: 17, + marginBottom: 12, + color: changeOpacity(theme.centerChannelColor, 0.5), + marginLeft: 16, + }, + block: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), + borderBottomWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), + borderTopWidth: 1, + }, + }; +}); + +const defaultCustomStatusSuggestions: DefaultUserCustomStatus[] = [ + {emoji: 'calendar', message: t('custom_status.suggestions.in_a_meeting'), messageDefault: 'In a meeting', durationDefault: CustomStatusDuration.ONE_HOUR}, + {emoji: 'hamburger', message: t('custom_status.suggestions.out_for_lunch'), messageDefault: 'Out for lunch', durationDefault: CustomStatusDuration.THIRTY_MINUTES}, + {emoji: 'sneezing_face', message: t('custom_status.suggestions.out_sick'), messageDefault: 'Out sick', durationDefault: CustomStatusDuration.TODAY}, + {emoji: 'house', message: t('custom_status.suggestions.working_from_home'), messageDefault: 'Working from home', durationDefault: CustomStatusDuration.TODAY}, + {emoji: 'palm_tree', message: t('custom_status.suggestions.on_a_vacation'), messageDefault: 'On a vacation', durationDefault: CustomStatusDuration.THIS_WEEK}, +]; + +const CustomStatusSuggestions = ({ + intl, + isExpirySupported, + onHandleCustomStatusSuggestionClick, + recentCustomStatuses, + theme, +}: Props) => { + const style = getStyleSheet(theme); + const recentCustomStatusTexts = recentCustomStatuses.map((status: UserCustomStatus) => status.text); + + const customStatusSuggestions = defaultCustomStatusSuggestions. + map((status) => ({ + emoji: status.emoji, + text: intl.formatMessage({id: status.message, defaultMessage: status.messageDefault}), + duration: status.durationDefault, + })). + filter((status: UserCustomStatus) => !recentCustomStatusTexts.includes(status.text)). + map((status: UserCustomStatus, index: number, arr: UserCustomStatus[]) => ( + + )); + + if (customStatusSuggestions.length === 0) { + return null; + } + + return ( + <> + + + + {customStatusSuggestions} + + + ); +}; + +export default CustomStatusSuggestions; diff --git a/app/screens/custom_status/components/recent_custom_statuses.tsx b/app/screens/custom_status/components/recent_custom_statuses.tsx new file mode 100644 index 0000000000..24999952f5 --- /dev/null +++ b/app/screens/custom_status/components/recent_custom_statuses.tsx @@ -0,0 +1,77 @@ +// 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 FormattedText from '@components/formatted_text'; +import {t} from '@i18n'; +import CustomStatusSuggestion from '@screens/custom_status/components/custom_status_suggestion'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + isExpirySupported: boolean; + onHandleClear: (status: UserCustomStatus) => void; + onHandleSuggestionClick: (status: UserCustomStatus) => void; + recentCustomStatuses: UserCustomStatus[]; + theme: Theme; +} + +const RecentCustomStatuses = ({isExpirySupported, onHandleClear, onHandleSuggestionClick, recentCustomStatuses, theme}: Props) => { + const style = getStyleSheet(theme); + + if (recentCustomStatuses.length === 0) { + return null; + } + + return ( + <> + + + + + {recentCustomStatuses.map((status: UserCustomStatus, index: number) => ( + + ))} + + + + ); +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + separator: { + marginTop: 32, + }, + title: { + fontSize: 17, + marginBottom: 12, + color: changeOpacity(theme.centerChannelColor, 0.5), + marginLeft: 16, + }, + block: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), + borderBottomWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), + borderTopWidth: 1, + }, + }; +}); + +export default RecentCustomStatuses; diff --git a/app/screens/custom_status/index.tsx b/app/screens/custom_status/index.tsx new file mode 100644 index 0000000000..cd89eae606 --- /dev/null +++ b/app/screens/custom_status/index.tsx @@ -0,0 +1,425 @@ +// 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 moment, {Moment} from 'moment-timezone'; +import React from 'react'; +import {injectIntl, IntlShape} from 'react-intl'; +import {BackHandler, DeviceEventEmitter, Keyboard, KeyboardAvoidingView, Platform, ScrollView, View} from 'react-native'; +import {EventSubscription, Navigation, NavigationButtonPressedEvent, NavigationComponent, NavigationComponentProps} from 'react-native-navigation'; +import {Edge, SafeAreaView} from 'react-native-safe-area-context'; +import {of as of$} from 'rxjs'; +import {switchMap, catchError} from 'rxjs/operators'; + +import {updateLocalCustomStatus} from '@actions/local/user'; +import {removeRecentCustomStatus, updateCustomStatus, unsetCustomStatus} from '@actions/remote/user'; +import CompassIcon from '@components/compass_icon'; +import StatusBar from '@components/status_bar'; +import TabletTitle from '@components/tablet_title'; +import {CustomStatusDuration, Events, Screens} from '@constants'; +import {SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {withServerUrl} from '@context/server_url'; +import {withTheme} from '@context/theme'; +import {dismissModal, goToScreen, mergeNavigationOptions, showModal} from '@screens/navigation'; +import {getCurrentMomentForTimezone, getRoundedTime, isCustomStatusExpirySupported, safeParseJSON} from '@utils/helpers'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import { + getTimezone, + getUserCustomStatus, + isCustomStatusExpired as verifyExpiredStatus, +} from '@utils/user'; + +import ClearAfter from './components/clear_after'; +import CustomStatusInput from './components/custom_status_input'; +import CustomStatusSuggestions from './components/custom_status_suggestions'; +import RecentCustomStatuses from './components/recent_custom_statuses'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +interface Props extends NavigationComponentProps { + customStatusExpirySupported: boolean; + currentUser: UserModel; + intl: IntlShape; + isModal?: boolean; + isTablet?: boolean; + recentCustomStatuses: UserCustomStatus[]; + serverUrl: string; + theme: Theme; +} + +type State = { + emoji?: string; + text?: string; + duration: CustomStatusDuration; + expires_at: Moment; +}; + +const {SERVER: {SYSTEM, USER}} = MM_TABLES; +const {DONT_CLEAR, THIRTY_MINUTES, ONE_HOUR, FOUR_HOURS, TODAY, THIS_WEEK, DATE_AND_TIME} = CustomStatusDuration; +const DEFAULT_DURATION: CustomStatusDuration = TODAY; +const BTN_UPDATE_STATUS = 'update-custom-status'; +const edges: Edge[] = ['bottom', 'left', 'right']; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), + }, + contentContainerStyle: { + height: '99%', + }, + scrollView: { + flex: 1, + paddingTop: 32, + }, + separator: { + marginTop: 32, + }, + block: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), + borderBottomWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), + borderTopWidth: 1, + }, + }; +}); + +class CustomStatusModal extends NavigationComponent { + private navigationEventListener: EventSubscription | undefined; + private isCustomStatusExpired: boolean | undefined; + private backListener: EventSubscription | undefined; + + constructor(props: Props) { + super(props); + const {intl, theme, componentId} = props; + + mergeNavigationOptions(componentId, { + topBar: { + rightButtons: [ + { + enabled: true, + id: BTN_UPDATE_STATUS, + showAsAction: 'always', + testID: 'custom_status.done.button', + text: intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'}), + color: theme.sidebarHeaderTextColor, + }, + ], + }, + }); + + this.setUp(); + } + + setUp = () => { + const {currentUser} = this.props; + const userTimezone = getTimezone(currentUser.timezone); + + const customStatus = this.getCustomStatus(); + + this.isCustomStatusExpired = verifyExpiredStatus(currentUser); + + const currentTime = getCurrentMomentForTimezone(userTimezone ?? ''); + + let initialCustomExpiryTime: Moment = getRoundedTime(currentTime); + const isCurrentCustomStatusSet = !this.isCustomStatusExpired && (customStatus?.text || customStatus?.emoji); + if (isCurrentCustomStatusSet && customStatus?.duration === DATE_AND_TIME && customStatus?.expires_at) { + initialCustomExpiryTime = moment(customStatus?.expires_at); + } + + this.state = { + duration: isCurrentCustomStatusSet ? customStatus?.duration ?? DONT_CLEAR : DEFAULT_DURATION, + emoji: isCurrentCustomStatusSet ? customStatus?.emoji : '', + expires_at: initialCustomExpiryTime, + text: isCurrentCustomStatusSet ? customStatus?.text : '', + }; + }; + + getCustomStatus = () => { + const {currentUser} = this.props; + return getUserCustomStatus(currentUser); + }; + + componentDidMount() { + this.navigationEventListener = Navigation.events().bindComponent(this); + this.backListener = BackHandler.addEventListener('hardwareBackPress', this.onBackPress); + } + + componentWillUnmount() { + this.navigationEventListener?.remove(); + this.backListener?.remove(); + } + + navigationButtonPressed({buttonId}: NavigationButtonPressedEvent) { + switch (buttonId) { + case BTN_UPDATE_STATUS: + this.handleSetStatus(); + break; + } + } + + onBackPress = () => { + if (this.props.isTablet) { + DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, ''); + } else { + dismissModal(); + } + return true; + }; + + handleSetStatus = async () => { + const {customStatusExpirySupported, currentUser, serverUrl} = this.props; + const {emoji, text, duration} = this.state; + const customStatus = this.getCustomStatus(); + + const isStatusSet = emoji || text; + if (isStatusSet) { + let isStatusSame = customStatus?.emoji === emoji && customStatus?.text === text && customStatus?.duration === duration; + const expiresAt = this.calculateExpiryTime(duration); + if (isStatusSame && duration === DATE_AND_TIME) { + isStatusSame = customStatus?.expires_at === expiresAt; + } + + if (!isStatusSame) { + const status: UserCustomStatus = { + emoji: emoji || 'speech_balloon', + text: text?.trim(), + duration: DONT_CLEAR, + }; + + if (customStatusExpirySupported) { + status.duration = duration; + status.expires_at = expiresAt; + } + const {error} = await updateCustomStatus(serverUrl, currentUser, status); + if (error) { + DeviceEventEmitter.emit(SET_CUSTOM_STATUS_FAILURE); + return; + } + + updateLocalCustomStatus(serverUrl, currentUser, status); + + this.setState({ + duration: status.duration, + emoji: status.emoji, + expires_at: moment(status.expires_at), + text: status.text, + }); + } + } else if (customStatus?.emoji) { + const unsetResponse = await unsetCustomStatus(serverUrl); + + if (unsetResponse?.data) { + updateLocalCustomStatus(serverUrl, currentUser, undefined); + } + } + Keyboard.dismiss(); + if (this.props.isTablet) { + DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, ''); + } else { + dismissModal(); + } + }; + + calculateExpiryTime = (duration: CustomStatusDuration): string => { + const {currentUser} = this.props; + const userTimezone = getTimezone(currentUser.timezone); + const currentTime = getCurrentMomentForTimezone(userTimezone); + + const {expires_at} = this.state; + switch (duration) { + case THIRTY_MINUTES: + return currentTime.add(30, 'minutes').seconds(0).milliseconds(0).toISOString(); + case ONE_HOUR: + return currentTime.add(1, 'hour').seconds(0).milliseconds(0).toISOString(); + case FOUR_HOURS: + return currentTime.add(4, 'hours').seconds(0).milliseconds(0).toISOString(); + case TODAY: + return currentTime.endOf('day').toISOString(); + case THIS_WEEK: + return currentTime.endOf('week').toISOString(); + case DATE_AND_TIME: + return expires_at.toISOString(); + case DONT_CLEAR: + default: + return ''; + } + }; + + handleTextChange = (text: string) => { + this.setState({text}); + }; + + handleRecentCustomStatusClear = (status: UserCustomStatus) => removeRecentCustomStatus(this.props.serverUrl, status); + + clearHandle = () => this.setState({emoji: '', text: '', duration: DEFAULT_DURATION}); + + handleCustomStatusSuggestionClick = (status: UserCustomStatus) => { + const {emoji, text, duration} = status; + this.setState({emoji, text, duration}); + }; + + handleRecentCustomStatusSuggestionClick = (status: UserCustomStatus) => { + const {emoji, text, duration} = status; + this.setState({emoji, text, duration: duration || DONT_CLEAR}); + if (duration === DATE_AND_TIME) { + this.openClearAfterModal(); + } + }; + + openEmojiPicker = preventDoubleTap(() => { + const {theme, intl} = this.props; + CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor).then((source) => { + const screen = Screens.EMOJI_PICKER; + const title = intl.formatMessage({id: 'mobile.custom_status.choose_emoji', defaultMessage: 'Choose an emoji'}); + const passProps = {closeButton: source, onEmojiPress: this.handleEmojiClick}; + + showModal(screen, title, passProps); + }); + }); + + handleEmojiClick = (emoji: string) => { + this.setState({emoji}); + }; + + handleClearAfterClick = (duration: CustomStatusDuration, expires_at: string) => + this.setState({ + duration, + expires_at: duration === DATE_AND_TIME && expires_at ? moment(expires_at) : this.state.expires_at, + }); + + openClearAfterModal = async () => { + const {intl, theme} = this.props; + const screen = Screens.CUSTOM_STATUS_CLEAR_AFTER; + const title = intl.formatMessage({id: 'mobile.custom_status.clear_after.title', defaultMessage: 'Clear Custom Status After'}); + const passProps = { + handleClearAfterClick: this.handleClearAfterClick, + initialDuration: this.state.duration, + intl, + theme, + }; + + if (this.props.isTablet) { + showModal(screen, title, passProps); + } else { + goToScreen(screen, title, passProps); + } + }; + + render() { + const {duration, emoji, expires_at, text} = this.state; + const {customStatusExpirySupported, currentUser, intl, recentCustomStatuses, theme} = this.props; + const isStatusSet = Boolean(emoji || text); + const style = getStyleSheet(theme); + + return ( + <> + {this.props.isTablet && + + } + + + + + + + + {isStatusSet && customStatusExpirySupported && ( + + )} + + {recentCustomStatuses.length > 0 && ( + + ) + } + + + + + + + + ); + } +} + +const augmentCSM = injectIntl(withTheme(withServerUrl(CustomStatusModal))); + +const enhancedCSM = withObservables([], ({database}: WithDatabaseArgs) => { + const config = database. + get(SYSTEM). + findAndObserve(SYSTEM_IDENTIFIERS.CONFIG); + return { + currentUser: database. + get(SYSTEM). + findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID). + pipe( + switchMap((id) => database.get(USER).findAndObserve(id.value)), + ), + customStatusExpirySupported: config.pipe( + switchMap((cfg) => of$(isCustomStatusExpirySupported((cfg.value as ClientConfig).Version))), + ), + recentCustomStatuses: database. + get(SYSTEM). + findAndObserve(SYSTEM_IDENTIFIERS.RECENT_CUSTOM_STATUS).pipe( + switchMap( + (recentStatuses) => of$( + safeParseJSON(recentStatuses.value) as unknown as UserCustomStatus[], + ), + ), + catchError(() => of$([])), + ), + }; +}); + +export default withDatabase(enhancedCSM(augmentCSM)); diff --git a/app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx b/app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx new file mode 100644 index 0000000000..e1e6f65fe2 --- /dev/null +++ b/app/screens/custom_status_clear_after/components/clear_after_menu_item.tsx @@ -0,0 +1,142 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment, {Moment} from 'moment'; +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {View, TouchableOpacity} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import CustomStatusExpiry from '@components/custom_status/custom_status_expiry'; +import CustomStatusText from '@components/custom_status/custom_status_text'; +import {CustomStatusDuration, CST} from '@constants/custom_status'; +import {useTheme} from '@context/theme'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import DateTimePicker from './date_time_selector'; + +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + currentUser: UserModel; + duration: CustomStatusDuration; + expiryTime?: string; + handleItemClick: (duration: CustomStatusDuration, expiresAt: string) => void; + + isSelected: boolean; + separator: boolean; + showDateTimePicker?: boolean; + showExpiryTime?: boolean; + +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + backgroundColor: theme.centerChannelBg, + display: 'flex', + flexDirection: 'row', + padding: 10, + }, + textContainer: { + marginLeft: 5, + marginBottom: 2, + alignItems: 'center', + width: '70%', + flex: 1, + flexDirection: 'row', + position: 'relative', + }, + rightPosition: { + position: 'absolute', + right: 14, + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1, + marginHorizontal: 16, + }, + button: { + borderRadius: 1000, + color: theme.buttonBg, + }, + customStatusExpiry: { + color: theme.linkColor, + }, + }; +}); + +const ClearAfterMenuItem = ({currentUser, duration, expiryTime = '', handleItemClick, isSelected, separator, showDateTimePicker = false, showExpiryTime = false}: Props) => { + const theme = useTheme(); + const intl = useIntl(); + const style = getStyleSheet(theme); + + const expiryMenuItems: { [key in CustomStatusDuration]: string } = { + [CustomStatusDuration.DONT_CLEAR]: intl.formatMessage(CST[CustomStatusDuration.DONT_CLEAR]), + [CustomStatusDuration.THIRTY_MINUTES]: intl.formatMessage(CST[CustomStatusDuration.THIRTY_MINUTES]), + [CustomStatusDuration.ONE_HOUR]: intl.formatMessage(CST[CustomStatusDuration.ONE_HOUR]), + [CustomStatusDuration.FOUR_HOURS]: intl.formatMessage(CST[CustomStatusDuration.FOUR_HOURS]), + [CustomStatusDuration.TODAY]: intl.formatMessage(CST[CustomStatusDuration.TODAY]), + [CustomStatusDuration.THIS_WEEK]: intl.formatMessage(CST[CustomStatusDuration.THIS_WEEK]), + [CustomStatusDuration.DATE_AND_TIME]: intl.formatMessage({id: 'custom_status.expiry_dropdown.custom', defaultMessage: 'Custom'}), + }; + + const handleClick = preventDoubleTap(() => { + handleItemClick(duration, expiryTime); + }); + + const handleCustomExpiresAtChange = useCallback((expiresAt: Moment) => { + handleItemClick(duration, expiresAt.toISOString()); + }, [handleItemClick, duration]); + + return ( + + + + + + {isSelected && ( + + + + )} + {showExpiryTime && expiryTime !== '' && ( + + + + )} + + + {separator && } + + {showDateTimePicker && ( + + )} + + ); +}; + +export default ClearAfterMenuItem; diff --git a/app/screens/custom_status_clear_after/components/date_time_selector.tsx b/app/screens/custom_status_clear_after/components/date_time_selector.tsx new file mode 100644 index 0000000000..c6c165ee7b --- /dev/null +++ b/app/screens/custom_status_clear_after/components/date_time_selector.tsx @@ -0,0 +1,131 @@ +// 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 DateTimePicker from '@react-native-community/datetimepicker'; +import moment, {Moment} from 'moment-timezone'; +import React, {useState} from 'react'; +import {View, Button, Platform} from 'react-native'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Preferences} from '@constants'; +import {CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES} from '@constants/custom_status'; +import {MM_TABLES} from '@constants/database'; +import {getPreferenceAsBool} from '@helpers/api/preference'; +import {getCurrentMomentForTimezone, getRoundedTime, getUtcOffsetForTimeZone} from '@utils/helpers'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {getTimezone} from '@utils/user'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PreferenceModel from '@typings/database/models/servers/preference'; +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + currentUser: UserModel; + isMilitaryTime: boolean; + theme: Theme; + handleChange: (currentDate: Moment) => void; +} + +type AndroidMode = 'date' | 'time'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + paddingTop: 10, + backgroundColor: theme.centerChannelBg, + }, + buttonContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-evenly', + marginBottom: 10, + }, + }; +}); + +const DateTimeSelector = ({currentUser, handleChange, isMilitaryTime, theme}: Props) => { + const styles = getStyleSheet(theme); + const timezone = getTimezone(currentUser.timezone); + const currentTime = getCurrentMomentForTimezone(timezone); + const timezoneOffSetInMinutes = timezone ? getUtcOffsetForTimeZone(timezone) : undefined; + const minimumDate = getRoundedTime(currentTime); + const [date, setDate] = useState(minimumDate); + const [mode, setMode] = useState('date'); + const [show, setShow] = useState(false); + + const onChange = (_: React.ChangeEvent, selectedDate: Date) => { + const currentDate = selectedDate || date; + setShow(Platform.OS === 'ios'); + if (moment(currentDate).isAfter(minimumDate)) { + setDate(moment(currentDate)); + handleChange(moment(currentDate)); + } + }; + + const showMode = (currentMode: AndroidMode) => { + setShow(true); + setMode(currentMode); + }; + + const showDatepicker = () => { + showMode('date'); + handleChange(moment(date)); + }; + + const showTimepicker = () => { + showMode('time'); + handleChange(moment(date)); + }; + + return ( + + +