diff --git a/.gitignore b/.gitignore index 89f914522b..a597d9eb59 100644 --- a/.gitignore +++ b/.gitignore @@ -102,7 +102,7 @@ coverage mattermost-license.txt *.mattermost-license detox/artifacts -detox/detox_pixel_4_xl_api_30 +detox/detox_pixel_* # Bundle artifact *.jsbundle diff --git a/.solidarity b/.solidarity index 561f1505cf..a315055013 100644 --- a/.solidarity +++ b/.solidarity @@ -54,13 +54,6 @@ "error": "visit rvm install https://rvm.io/rvm/install", "platform": "darwin" }, - { - "rule": "cli", - "binary": "bundler", - "semver": "2.1.4", - "error": "install watchman `gem install bundler --version 2.1.4`", - "platform": "darwin" - }, { "rule": "cli", "binary": "pod", diff --git a/NOTICE.txt b/NOTICE.txt index 336ae5e2a0..8453b77d56 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -200,6 +200,41 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- + +## @mattermost/react-native-turbo-mailer + +This product contains '@mattermost/react-native-turbo-mailer' by Avinash Lingaloo. + +An adaptation of react-native-mail that supports Turbo Module + +* HOMEPAGE: + * https://github.com/mattermost/react-native-turbo-mailer#readme + +* LICENSE: MIT + +MIT License + +Copyright (c) 2022 Mattermost +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + --- ## @msgpack/msgpack @@ -493,6 +528,21 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +--- + +## @react-navigation/stack + +This product contains '@react-navigation/stack'. + +Stack navigator component for iOS and Android with animated transitions and gestures + +* HOMEPAGE: + * https://reactnavigation.org/docs/stack-navigator/ + +* LICENSE: MIT + + + --- ## @rudderstack/rudder-sdk-react-native @@ -1109,6 +1159,40 @@ Lightweight fuzzy-search limitations under the License. +--- + +## html-entities + +This product contains 'html-entities' by Marat Dulin. + +Fastest HTML entities encode/decode library. + +* HOMEPAGE: + * https://github.com/mdevils/html-entities#readme + +* LICENSE: MIT + +Copyright (c) 2021 Dulin Marat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + --- ## jail-monkey diff --git a/android/app/build.gradle b/android/app/build.gradle index 9a8df50dea..d9e1d91f61 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -145,7 +145,7 @@ android { applicationId "com.mattermost.rnbeta" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 443 + versionCode 446 versionName "2.0.0" testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' diff --git a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java index 6a106568d2..a9289cd90b 100644 --- a/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java +++ b/android/app/src/main/java/com/mattermost/helpers/CustomPushNotificationHelper.java @@ -74,7 +74,10 @@ public class CustomPushNotificationHelper { if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) { try { - sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, senderId)))); + Bitmap avatar = userAvatar(context, serverUrl, senderId, null); + if (avatar != null) { + sender.setIcon(IconCompat.createWithBitmap(avatar)); + } } catch (IOException e) { e.printStackTrace(); } @@ -270,7 +273,10 @@ public class CustomPushNotificationHelper { if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) { try { - sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, "me")))); + Bitmap avatar = userAvatar(context, serverUrl, "me", null); + if (avatar != null) { + sender.setIcon(IconCompat.createWithBitmap(avatar)); + } } catch (IOException e) { e.printStackTrace(); } @@ -397,6 +403,7 @@ public class CustomPushNotificationHelper { String channelName = getConversationTitle(bundle); String senderName = bundle.getString("sender_name"); String serverUrl = bundle.getString("server_url"); + String urlOverride = bundle.getString("override_icon_url"); int smallIconResId = getSmallIconResourceId(context, smallIcon); notification.setSmallIcon(smallIconResId); @@ -404,33 +411,46 @@ public class CustomPushNotificationHelper { if (serverUrl != null && channelName.equals(senderName)) { try { String senderId = bundle.getString("sender_id"); - notification.setLargeIcon(userAvatar(context, serverUrl, senderId)); + Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride); + if (avatar != null) { + notification.setLargeIcon(avatar); + } } catch (IOException e) { e.printStackTrace(); } } } - private static Bitmap userAvatar(Context context, final String serverUrl, final String userId) throws IOException { - final ReactApplicationContext reactApplicationContext = new ReactApplicationContext(context); - final String token = Credentials.getCredentialsForServerSync(reactApplicationContext, serverUrl); + private static Bitmap userAvatar(Context context, final String serverUrl, final String userId, final String urlOverride) throws IOException { + try { + final OkHttpClient client = new OkHttpClient(); + Request request; + String url; + if (urlOverride != null) { + request = new Request.Builder().url(urlOverride).build(); + Log.i("ReactNative", String.format("Fetch override profile image %s", urlOverride)); + } else { + final ReactApplicationContext reactApplicationContext = new ReactApplicationContext(context); + final String token = Credentials.getCredentialsForServerSync(reactApplicationContext, serverUrl); + url = String.format("%s/api/v4/users/%s/image", serverUrl, userId); + Log.i("ReactNative", String.format("Fetch profile image %s", url)); + request = new Request.Builder() + .header("Authorization", String.format("Bearer %s", token)) + .url(url) + .build(); + } + Response response = client.newCall(request).execute(); + if (response.code() == 200) { + assert response.body() != null; + byte[] bytes = Objects.requireNonNull(response.body()).bytes(); + Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + return getCircleBitmap(bitmap); + } - final OkHttpClient client = new OkHttpClient(); - final String url = String.format("%s/api/v4/users/%s/image", serverUrl, userId); - Request request = new Request.Builder() - .header("Authorization", String.format("Bearer %s", token)) - .url(url) - .build(); - Response response = client.newCall(request).execute(); - if (response.code() == 200) { - assert response.body() != null; - byte[] bytes = Objects.requireNonNull(response.body()).bytes(); - Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); - - Log.i("ReactNative", String.format("Fetch profile %s", url)); - return getCircleBitmap(bitmap); + return null; + } catch (Exception e) { + e.printStackTrace(); + return null; } - - return null; } } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java index e9222b4bdf..a8f89866f8 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.java @@ -41,7 +41,7 @@ public class MainActivity extends NavigationActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + super.onCreate(null); setContentView(R.layout.launch_screen); setHWKeyboardConnected(); } diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 8d8085da07..893b33e6e5 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -10,7 +10,7 @@ import {addChannelToDefaultCategory, storeCategories} from '@actions/local/categ import {removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; import {switchToGlobalThreads} from '@actions/local/thread'; import {loadCallForChannel} from '@calls/actions/calls'; -import {Events, General, Preferences, Screens} from '@constants'; +import {DeepLink, Events, General, Preferences, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {privateChannelJoinPrompt} from '@helpers/api/channel'; import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; @@ -24,11 +24,11 @@ import {getNthLastChannelFromTeam, getMyTeamById, getTeamByName, queryMyTeams, r import {getCurrentUser} from '@queries/servers/user'; import {dismissAllModals, popToRoot} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; +import {setTeamLoading} from '@store/team_load_store'; import {generateChannelNameFromDisplayName, getDirectChannelName, isDMorGM} from '@utils/channel'; import {isTablet} from '@utils/helpers'; import {logDebug, logError, logInfo} from '@utils/log'; import {showMuteChannelSnackbar} from '@utils/snack_bar'; -import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import {displayGroupMessageName, displayUsername} from '@utils/user'; import {fetchGroupsForChannelIfConstrained} from './groups'; @@ -360,6 +360,9 @@ export async function fetchMyChannelsForTeam(serverUrl: string, teamId: string, } try { + if (!fetchOnly) { + setTeamLoading(serverUrl, true); + } const [allChannels, channelMemberships, categoriesWithOrder] = await Promise.all([ client.getMyChannels(teamId, includeDeleted, since), client.getMyChannelMembers(teamId), @@ -388,10 +391,14 @@ export async function fetchMyChannelsForTeam(serverUrl: string, teamId: string, if (models.length) { await operator.batchRecords(models); } + setTeamLoading(serverUrl, false); } return {channels, memberships, categories}; } catch (error) { + if (!fetchOnly) { + setTeamLoading(serverUrl, false); + } forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } @@ -655,7 +662,7 @@ export async function switchToChannelByName(serverUrl: string, channelName: stri let joinedTeam = false; let teamId = ''; try { - if (teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) { + if (teamName === DeepLink.Redirect) { teamId = await getCurrentTeamId(database); } else { const team = await getTeamByName(database, teamName); @@ -1257,7 +1264,7 @@ export const unarchiveChannel = async (serverUrl: string, channelId: string) => try { EphemeralStore.addArchivingChannel(channelId); await client.unarchiveChannel(channelId); - await setChannelDeleteAt(serverUrl, channelId, Date.now()); + await setChannelDeleteAt(serverUrl, channelId, 0); return {error: undefined}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); diff --git a/app/actions/remote/command.ts b/app/actions/remote/command.ts index 4cb475925f..bcc7c25c19 100644 --- a/app/actions/remote/command.ts +++ b/app/actions/remote/command.ts @@ -5,26 +5,18 @@ import {IntlShape} from 'react-intl'; import {Alert} from 'react-native'; import {doAppSubmit, postEphemeralCallResponseForCommandArgs} from '@actions/remote/apps'; -import {showPermalink} from '@actions/remote/permalink'; import {Client} from '@client/rest'; import {AppCommandParser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser'; import {AppCallResponseTypes} from '@constants/apps'; -import DeepLinkType from '@constants/deep_linking'; import DatabaseManager from '@database/manager'; import AppsManager from '@managers/apps_manager'; import IntegrationsManager from '@managers/integrations_manager'; import NetworkManager from '@managers/network_manager'; import {getChannelById} from '@queries/servers/channel'; import {getConfig, getCurrentTeamId} from '@queries/servers/system'; -import {getTeammateNameDisplay, queryUsersByUsername} from '@queries/servers/user'; -import {showAppForm, showModal} from '@screens/navigation'; -import * as DraftUtils from '@utils/draft'; -import {matchDeepLink, tryOpenURL} from '@utils/url'; -import {displayUsername} from '@utils/user'; - -import {makeDirectChannel, switchToChannelById, switchToChannelByName} from './channel'; - -import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkDM, DeepLinkGM, DeepLinkPlugin} from '@typings/launch'; +import {showAppForm} from '@screens/navigation'; +import {handleDeepLink, matchDeepLink} from '@utils/deep_link'; +import {tryOpenURL} from '@utils/url'; export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string): Promise<{data?: CommandResponse; error?: string | {message: string}}> => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -144,60 +136,9 @@ export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, loc const config = await getConfig(database); const match = matchDeepLink(location, serverUrl, config?.SiteURL); - let linkServerUrl: string | undefined; - if (match?.data?.serverUrl) { - linkServerUrl = DatabaseManager.searchUrl(match.data.serverUrl); - } - if (match && linkServerUrl) { - switch (match.type) { - case DeepLinkType.Channel: { - const data = match.data as DeepLinkChannel; - switchToChannelByName(linkServerUrl, data.channelName, data.teamName, DraftUtils.errorBadChannel, intl); - break; - } - case DeepLinkType.Permalink: { - const data = match.data as DeepLinkPermalink; - showPermalink(linkServerUrl, data.teamName, data.postId, intl); - break; - } - case DeepLinkType.DirectMessage: { - const data = match.data as DeepLinkDM; - if (!data.userName) { - DraftUtils.errorUnkownUser(intl); - return {data: false}; - } - - if (data.serverUrl !== serverUrl) { - if (!database) { - return {error: `${serverUrl} database not found`}; - } - } - const user = (await queryUsersByUsername(database, [data.userName]).fetch())[0]; - if (!user) { - DraftUtils.errorUnkownUser(intl); - return {data: false}; - } - - makeDirectChannel(linkServerUrl, user.id, displayUsername(user, intl.locale, await getTeammateNameDisplay(database)), true); - break; - } - case DeepLinkType.GroupMessage: { - const data = match.data as DeepLinkGM; - if (!data.channelId) { - DraftUtils.errorBadChannel(intl); - return {data: false}; - } - - switchToChannelById(linkServerUrl, data.channelId); - break; - } - case DeepLinkType.Plugin: { - const data = match.data as DeepLinkPlugin; - showModal('PluginInternal', data.id, {link: location}); - break; - } - } + if (match) { + handleDeepLink(match, intl, location); } else { const {formatMessage} = intl; const onError = () => Alert.alert( diff --git a/app/actions/remote/entry/app.ts b/app/actions/remote/entry/app.ts index 1339bdaaf2..a68e9dafeb 100644 --- a/app/actions/remote/entry/app.ts +++ b/app/actions/remote/entry/app.ts @@ -6,6 +6,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems'; import DatabaseManager from '@database/manager'; import {prepareCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, getCurrentChannelId, getConfig, getLicense} from '@queries/servers/system'; import {getCurrentUser} from '@queries/servers/user'; +import {setTeamLoading} from '@store/team_load_store'; import {deleteV1Data} from '@utils/file'; import {logInfo} from '@utils/log'; @@ -37,8 +38,10 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false) const currentChannelId = await getCurrentChannelId(database); const lastDisconnectedAt = (await getWebSocketLastDisconnected(database)) || since; + setTeamLoading(serverUrl, true); const entryData = await entry(serverUrl, currentTeamId, currentChannelId, since); if ('error' in entryData) { + setTeamLoading(serverUrl, false); return {error: entryData.error}; } @@ -55,6 +58,7 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false) const dt = Date.now(); await operator.batchRecords(models); logInfo('ENTRY MODELS BATCHING TOOK', `${Date.now() - dt}ms`); + setTeamLoading(serverUrl, false); const {id: currentUserId, locale: currentUserLocale} = meData?.user || (await getCurrentUser(database))!; const config = await getConfig(database); diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts index a6dc2ff587..35ea06cd2f 100644 --- a/app/actions/remote/entry/common.ts +++ b/app/actions/remote/entry/common.ts @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. import {Database, Model} from '@nozbe/watermelondb'; -import {DeviceEventEmitter} from 'react-native'; import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, MyChannelsRequest} from '@actions/remote/channel'; import {fetchGroupsForMember} from '@actions/remote/groups'; @@ -10,11 +9,11 @@ import {fetchPostsForUnreadChannels} from '@actions/remote/post'; import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference'; import {fetchRoles} from '@actions/remote/role'; import {fetchConfigAndLicense} from '@actions/remote/systems'; -import {fetchAllTeams, fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, MyTeamsRequest} from '@actions/remote/team'; +import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team'; import {syncTeamThreads} from '@actions/remote/thread'; import {autoUpdateTimezone, fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user'; import {gqlAllChannels} from '@client/graphQL/entry'; -import {Events, General, Preferences, Screens} from '@constants'; +import {General, Preferences, Screens} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import {PUSH_PROXY_RESPONSE_NOT_AVAILABLE, PUSH_PROXY_RESPONSE_UNKNOWN, PUSH_PROXY_STATUS_NOT_AVAILABLE, PUSH_PROXY_STATUS_UNKNOWN, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy'; import DatabaseManager from '@database/manager'; @@ -320,9 +319,7 @@ export async function restDeferredAppEntryActions( channelsToFetchProfiles = new Set(directChannels); // defer fetching posts for unread channels on initial team - fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId, true); - } else { - DeviceEventEmitter.emit(Events.FETCHING_POSTS, false); + fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId); } }, FETCH_UNREADS_TIMEOUT); @@ -346,7 +343,7 @@ export async function restDeferredAppEntryActions( } } - await fetchAllTeams(serverUrl); + updateCanJoinTeams(serverUrl); await updateAllUsersSince(serverUrl, since); // Fetch groups for current user diff --git a/app/actions/remote/entry/gql_common.ts b/app/actions/remote/entry/gql_common.ts index 8d11a2012d..3006f2816c 100644 --- a/app/actions/remote/entry/gql_common.ts +++ b/app/actions/remote/entry/gql_common.ts @@ -2,17 +2,16 @@ // See LICENSE.txt for license information. import {Database} from '@nozbe/watermelondb'; -import {DeviceEventEmitter} from 'react-native'; import {storeConfigAndLicense} from '@actions/local/systems'; import {MyChannelsRequest} from '@actions/remote/channel'; import {fetchGroupsForMember} from '@actions/remote/groups'; import {fetchPostsForUnreadChannels} from '@actions/remote/post'; -import {MyTeamsRequest} from '@actions/remote/team'; +import {MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team'; import {syncTeamThreads} from '@actions/remote/thread'; import {autoUpdateTimezone, updateAllUsersSince} from '@actions/remote/user'; import {gqlEntry, gqlEntryChannels, gqlOtherChannels} from '@client/graphQL/entry'; -import {Events, Preferences} from '@constants'; +import {Preferences} from '@constants'; import DatabaseManager from '@database/manager'; import {getPreferenceValue} from '@helpers/api/preference'; import {selectDefaultTeam} from '@helpers/api/team'; @@ -49,9 +48,7 @@ export async function deferredAppEntryGraphQLActions( setTimeout(() => { if (chData?.channels?.length && chData.memberships?.length) { // defer fetching posts for unread channels on initial team - fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId, true); - } else { - DeviceEventEmitter.emit(Events.FETCHING_POSTS, false); + fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId); } }, FETCH_UNREADS_TIMEOUT); @@ -98,6 +95,7 @@ export async function deferredAppEntryGraphQLActions( // Fetch groups for current user fetchGroupsForMember(serverUrl, currentUserId); + updateCanJoinTeams(serverUrl); updateAllUsersSince(serverUrl, since); return {error: undefined}; @@ -183,9 +181,22 @@ export const entryGQL = async (serverUrl: string, currentTeamId?: string, curren user: gqlToClientUser(fetchedData.user!), }; + const allTeams = getMemberTeamsFromGQLQuery(fetchedData); + const allTeamMemberships = fetchedData.teamMembers.map((m) => gqlToClientTeamMembership(m, meData.user.id)); + + const [nonArchivedTeams, archivedTeamIds] = allTeams.reduce((acc, t) => { + if (t.delete_at) { + acc[1].add(t.id); + return acc; + } + return [[...acc[0], t], acc[1]]; + }, [[], new Set()]); + + const nonArchivedTeamMemberships = allTeamMemberships.filter((m) => !archivedTeamIds.has(m.team_id)); + const teamData = { - teams: getMemberTeamsFromGQLQuery(fetchedData), - memberships: fetchedData.teamMembers.map((m) => gqlToClientTeamMembership(m, meData.user.id)), + teams: nonArchivedTeams, + memberships: nonArchivedTeamMemberships, }; const prefData = { @@ -205,7 +216,7 @@ export const entryGQL = async (serverUrl: string, currentTeamId?: string, curren let initialTeamId = currentTeamId; if (!teamData.teams.length) { initialTeamId = ''; - } else if (!initialTeamId || !teamData.teams.find((t) => t.id === currentTeamId)) { + } else if (!initialTeamId || !teamData.teams.find((t) => t.id === currentTeamId && t.delete_at === 0)) { const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.TEAMS_ORDER, '', '') as string; initialTeamId = selectDefaultTeam(teamData.teams, meData.user.locale, teamOrderPreference, config.ExperimentalPrimaryTeam)?.id || ''; } diff --git a/app/actions/remote/entry/login.ts b/app/actions/remote/entry/login.ts index dd141f0d36..751bafe0c6 100644 --- a/app/actions/remote/entry/login.ts +++ b/app/actions/remote/entry/login.ts @@ -6,6 +6,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; import {setCurrentTeamAndChannelId} from '@queries/servers/system'; +import {setTeamLoading} from '@store/team_load_store'; import {isTablet} from '@utils/helpers'; import {deferredAppEntryActions, entry} from './gql_common'; @@ -47,9 +48,11 @@ export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs) return {error: clData.error}; } + setTeamLoading(serverUrl, true); const entryData = await entry(serverUrl, '', ''); if ('error' in entryData) { + setTeamLoading(serverUrl, false); return {error: entryData.error}; } @@ -66,6 +69,7 @@ export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs) } await operator.batchRecords(models); + setTeamLoading(serverUrl, false); const config = clData.config || {} as ClientConfig; const license = clData.license || {} as ClientLicense; diff --git a/app/actions/remote/entry/notification.ts b/app/actions/remote/entry/notification.ts index 25eb4bb731..76ef6ee381 100644 --- a/app/actions/remote/entry/notification.ts +++ b/app/actions/remote/entry/notification.ts @@ -14,6 +14,7 @@ import {getIsCRTEnabled} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; +import {setTeamLoading} from '@store/team_load_store'; import {isTablet} from '@utils/helpers'; import {emitNotificationError} from '@utils/notification'; import {setThemeDefaults, updateThemeIfNeeded} from '@utils/theme'; @@ -81,8 +82,10 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not switchedToScreen = true; } + setTeamLoading(serverUrl, true); const entryData = await entry(serverUrl, teamId, channelId); if ('error' in entryData) { + setTeamLoading(serverUrl, false); return {error: entryData.error}; } const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData; @@ -134,6 +137,7 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not } await operator.batchRecords(models); + setTeamLoading(serverUrl, false); const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(operator.database))!; const config = await getConfig(database); diff --git a/app/actions/remote/notifications.ts b/app/actions/remote/notifications.ts index 018c838eaf..231121770c 100644 --- a/app/actions/remote/notifications.ts +++ b/app/actions/remote/notifications.ts @@ -14,6 +14,7 @@ import {getMyChannel, getChannelById} from '@queries/servers/channel'; import {getCurrentTeamId, getWebSocketLastDisconnected} from '@queries/servers/system'; import {getMyTeamById} from '@queries/servers/team'; import {getIsCRTEnabled} from '@queries/servers/thread'; +import EphemeralStore from '@store/ephemeral_store'; import {emitNotificationError} from '@utils/notification'; const fetchNotificationData = async (serverUrl: string, notification: NotificationWithData, skipEvents = false) => { @@ -122,6 +123,7 @@ export const openNotification = async (serverUrl: string, notification: Notifica } try { + EphemeralStore.setNotificationTapped(true); const {database} = operator; const channelId = notification.payload!.channel_id!; const rootId = notification.payload!.root_id!; diff --git a/app/actions/remote/permalink.ts b/app/actions/remote/permalink.ts index 19aa9b3b42..19ef622ccb 100644 --- a/app/actions/remote/permalink.ts +++ b/app/actions/remote/permalink.ts @@ -1,15 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {DeepLink} from '@constants'; import DatabaseManager from '@database/manager'; import {getCurrentTeam} from '@queries/servers/team'; import {displayPermalink} from '@utils/permalink'; -import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import type TeamModel from '@typings/database/models/servers/team'; -import type {IntlShape} from 'react-intl'; -export const showPermalink = async (serverUrl: string, teamName: string, postId: string, intl: IntlShape, openAsPermalink = true) => { +export const showPermalink = async (serverUrl: string, teamName: string, postId: string, openAsPermalink = true) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; @@ -18,7 +17,7 @@ export const showPermalink = async (serverUrl: string, teamName: string, postId: try { let name = teamName; let team: TeamModel | undefined; - if (!name || name === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) { + if (!name || name === DeepLink.Redirect) { team = await getCurrentTeam(database); if (team) { name = team.name; diff --git a/app/actions/remote/post.ts b/app/actions/remote/post.ts index ac971708fc..61968e3b10 100644 --- a/app/actions/remote/post.ts +++ b/app/actions/remote/post.ts @@ -23,7 +23,9 @@ import {getPostById, getRecentPostsInChannel} from '@queries/servers/post'; import {getCurrentUserId, getCurrentChannelId} from '@queries/servers/system'; import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread'; import {queryAllUsers} from '@queries/servers/user'; +import {setFetchingThreadState} from '@store/fetching_thread_store'; import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers'; +import {isServerError} from '@utils/errors'; import {logError} from '@utils/log'; import {processPostsFetched} from '@utils/post'; import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list'; @@ -133,7 +135,7 @@ export async function createPost(serverUrl: string, post: Partial, files: let created; try { created = await client.createPost(newPost); - } catch (error: any) { + } catch (error) { const errorPost = { ...newPost, id: pendingPostId, @@ -146,10 +148,11 @@ export async function createPost(serverUrl: string, post: Partial, files: // If the failure was because: the root post was deleted or // TownSquareIsReadOnly=true then remove the post - if (error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || + if (isServerError(error) && ( + error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || error.server_error_id === ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR || error.server_error_id === ServerErrors.PLUGIN_DISMISSED_POST_ERROR - ) { + )) { await removePost(serverUrl, databasePost); } else { const models = await operator.handlePosts({ @@ -251,11 +254,12 @@ export const retryFailedPost = async (serverUrl: string, post: PostModel) => { } } await operator.batchRecords(models); - } catch (error: any) { - if (error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || + } catch (error) { + if (isServerError(error) && ( + error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR || error.server_error_id === ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR || error.server_error_id === ServerErrors.PLUGIN_DISMISSED_POST_ERROR - ) { + )) { await removePost(serverUrl, post); } else { post.prepareUpdate((p) => { @@ -325,12 +329,9 @@ export async function fetchPostsForChannel(serverUrl: string, channelId: string, } } -export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: Channel[], memberships: ChannelMembership[], excludeChannelId?: string, emitEvent = false) => { +export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: Channel[], memberships: ChannelMembership[], excludeChannelId?: string) => { try { const promises = []; - if (emitEvent) { - DeviceEventEmitter.emit(Events.FETCHING_POSTS, true); - } for (const member of memberships) { const channel = channels.find((c) => c.id === member.channel_id); if (channel && (channel.total_msg_count - member.msg_count) > 0 && channel.id !== excludeChannelId) { @@ -338,13 +339,7 @@ export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: C } } await Promise.all(promises); - if (emitEvent) { - DeviceEventEmitter.emit(Events.FETCHING_POSTS, false); - } } catch (error) { - if (emitEvent) { - DeviceEventEmitter.emit(Events.FETCHING_POSTS, false); - } return {error}; } @@ -583,6 +578,8 @@ export async function fetchPostThread(serverUrl: string, postId: string, options return {error}; } + setFetchingThreadState(postId, true); + try { const isCRTEnabled = await getIsCRTEnabled(operator.database); @@ -620,9 +617,11 @@ export async function fetchPostThread(serverUrl: string, postId: string, options } await operator.batchRecords(models); } + setFetchingThreadState(postId, false); return {posts: extractRecordsForTable(posts, MM_TABLES.SERVER.POST)}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + setFetchingThreadState(postId, false); return {error}; } } diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 1ecc9778b6..9a6e91dd34 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -5,6 +5,8 @@ import {Model} from '@nozbe/watermelondb'; import {DeviceEventEmitter} from 'react-native'; import {removeUserFromTeam as localRemoveUserFromTeam} from '@actions/local/team'; +import {Client} from '@client/rest'; +import {PER_PAGE_DEFAULT} from '@client/rest/constants'; import {Events} from '@constants'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; @@ -12,9 +14,10 @@ import {getActiveServerUrl} from '@queries/app/servers'; import {prepareCategoriesAndCategoriesChannels} from '@queries/servers/categories'; import {prepareMyChannelsForTeam, getDefaultChannelForTeam} from '@queries/servers/channel'; import {prepareCommonSystemValues, getCurrentTeamId, getCurrentUserId} from '@queries/servers/system'; -import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, getNthLastChannelFromTeam, queryTeamsById, syncTeamTable, getLastTeam, getTeamById, removeTeamFromTeamHistory} from '@queries/servers/team'; -import {dismissAllModals, popToRoot, resetToTeams} from '@screens/navigation'; +import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, getNthLastChannelFromTeam, queryTeamsById, getLastTeam, getTeamById, removeTeamFromTeamHistory, queryMyTeams} from '@queries/servers/team'; +import {dismissAllModals, popToRoot} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; +import {setTeamLoading} from '@store/team_load_store'; import {isTablet} from '@utils/helpers'; import {logDebug} from '@utils/log'; @@ -56,12 +59,16 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s return {error}; } + let loadEventSent = false; try { EphemeralStore.startAddingToTeam(teamId); const team = await client.getTeam(teamId); const member = await client.addToTeam(teamId, userId); if (!fetchOnly) { + setTeamLoading(serverUrl, true); + loadEventSent = true; + fetchRolesIfNeeded(serverUrl, member.roles.split(' ')); const {channels, memberships: channelMembers, categories} = await fetchMyChannelsForTeam(serverUrl, teamId, false, 0, true); const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -80,6 +87,8 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s ])).flat(); await operator.batchRecords(models); + setTeamLoading(serverUrl, false); + loadEventSent = false; if (await isTablet()) { const channel = await getDefaultChannelForTeam(operator.database, teamId); @@ -87,11 +96,18 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s fetchPostsForChannel(serverUrl, channel.id); } } + } else { + setTeamLoading(serverUrl, false); + loadEventSent = false; } } EphemeralStore.finishAddingToTeam(teamId); + updateCanJoinTeams(serverUrl); return {member}; } catch (error) { + if (loadEventSent) { + setTeamLoading(serverUrl, false); + } EphemeralStore.finishAddingToTeam(teamId); forceLogoutIfNecessary(serverUrl, error as ClientError); return {error}; @@ -222,23 +238,10 @@ export async function fetchMyTeam(serverUrl: string, teamId: string, fetchOnly = } } -export const fetchAllTeams = async (serverUrl: string, fetchOnly = false): Promise => { - let client; +export const fetchAllTeams = async (serverUrl: string, page = 0, perPage = PER_PAGE_DEFAULT): Promise<{teams?: Team[]; error?: any}> => { try { - client = NetworkManager.getClient(serverUrl); - } catch (error) { - return {error}; - } - - try { - const teams = await client.getTeams(); - if (!fetchOnly) { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (operator) { - syncTeamTable(operator, teams); - } - } - + const client = NetworkManager.getClient(serverUrl); + const teams = await client.getTeams(page, perPage); return {teams}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientError); @@ -246,6 +249,76 @@ export const fetchAllTeams = async (serverUrl: string, fetchOnly = false): Promi } }; +const recCanJoinTeams = async (client: Client, myTeamsIds: Set, page: number): Promise => { + const fetchedTeams = await client.getTeams(page, PER_PAGE_DEFAULT); + if (fetchedTeams.find((t) => !myTeamsIds.has(t.id) && t.delete_at === 0)) { + return true; + } + + if (fetchedTeams.length === PER_PAGE_DEFAULT) { + return recCanJoinTeams(client, myTeamsIds, page + 1); + } + + return false; +}; + +const LOAD_MORE_THRESHOLD = 10; +export async function fetchTeamsForComponent( + serverUrl: string, + page: number, + joinedIds?: Set, + alreadyLoaded: Team[] = [], +): Promise<{teams: Team[]; hasMore: boolean; page: number}> { + let hasMore = true; + const {teams, error} = await fetchAllTeams(serverUrl, page, PER_PAGE_DEFAULT); + if (error || !teams || teams.length < PER_PAGE_DEFAULT) { + hasMore = false; + } + + if (error) { + return {teams: alreadyLoaded, hasMore, page}; + } + + if (teams?.length) { + const notJoinedTeams = joinedIds ? teams.filter((t) => !joinedIds.has(t.id)) : teams; + alreadyLoaded.push(...notJoinedTeams); + + if (teams.length < PER_PAGE_DEFAULT) { + hasMore = false; + } + + if ( + hasMore && + (alreadyLoaded.length > LOAD_MORE_THRESHOLD) + ) { + return fetchTeamsForComponent(serverUrl, page + 1, joinedIds, alreadyLoaded); + } + + return {teams: alreadyLoaded, hasMore, page: page + 1}; + } + + return {teams: alreadyLoaded, hasMore: false, page}; +} + +export const updateCanJoinTeams = async (serverUrl: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const myTeams = await queryMyTeams(database).fetch(); + const myTeamsIds = new Set(myTeams.map((m) => m.id)); + + const canJoin = await recCanJoinTeams(client, myTeamsIds, 0); + + EphemeralStore.setCanJoinOtherTeams(serverUrl, canJoin); + return {}; + } catch (error) { + EphemeralStore.setCanJoinOtherTeams(serverUrl, false); + forceLogoutIfNecessary(serverUrl, error as ClientError); + return {error}; + } +}; + export const fetchTeamsChannelsAndUnreadPosts = async (serverUrl: string, since: number, teams: Team[], memberships: TeamMembership[], excludeTeamId?: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { @@ -315,7 +388,7 @@ export const removeUserFromTeam = async (serverUrl: string, teamId: string, user if (!fetchOnly) { localRemoveUserFromTeam(serverUrl, teamId); - fetchAllTeams(serverUrl); + updateCanJoinTeams(serverUrl); } return {error: undefined}; @@ -388,9 +461,9 @@ export async function handleKickFromTeam(serverUrl: string, teamId: string) { const teamToJumpTo = await getLastTeam(database, teamId); if (teamToJumpTo) { await handleTeamChange(serverUrl, teamToJumpTo); - } else if (currentServer === serverUrl) { - await resetToTeams(); } + + // Resetting to team select handled by the home screen } catch (error) { logDebug('Failed to kick user from team', error); } diff --git a/app/actions/remote/terms_of_service.ts b/app/actions/remote/terms_of_service.ts index ff40727067..64cb865a0d 100644 --- a/app/actions/remote/terms_of_service.ts +++ b/app/actions/remote/terms_of_service.ts @@ -1,7 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; +import {getCurrentUser} from '@queries/servers/user'; import {forceLogoutIfNecessary} from './session'; @@ -25,15 +27,24 @@ export async function fetchTermsOfService(serverUrl: string): Promise<{terms?: T } export async function updateTermsOfServiceStatus(serverUrl: string, id: string, status: boolean): Promise<{resp?: {status: string}; error?: any}> { - let client; - try { - client = NetworkManager.getClient(serverUrl); - } catch (error) { - return {error}; - } - try { + const client = NetworkManager.getClient(serverUrl); const resp = await client.updateMyTermsOfServiceStatus(id, status); + + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const currentUser = await getCurrentUser(database); + if (currentUser) { + currentUser.prepareUpdate((u) => { + if (status) { + u.termsOfServiceCreateAt = Date.now(); + u.termsOfServiceId = id; + } else { + u.termsOfServiceCreateAt = 0; + u.termsOfServiceId = ''; + } + }); + operator.batchRecords([currentUser]); + } return {resp}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientError); diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 23e6f0d746..88d3145f15 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -1,8 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {DeviceEventEmitter} from 'react-native'; - import {markChannelAsViewed} from '@actions/local/channel'; import {markChannelAsRead} from '@actions/remote/channel'; import {handleEntryAfterLoadNavigation} from '@actions/remote/entry/common'; @@ -30,11 +28,10 @@ import { handleCallUserVoiceOn, } from '@calls/connection/websocket_event_handlers'; import {isSupportedServerCalls} from '@calls/utils'; -import {Events, Screens, WebsocketEvents} from '@constants'; +import {Screens, WebsocketEvents} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import AppsManager from '@managers/apps_manager'; -import {getActiveServerUrl} from '@queries/app/servers'; import {getCurrentChannel} from '@queries/servers/channel'; import {getLastPostInThread} from '@queries/servers/post'; import { @@ -50,6 +47,7 @@ import {getIsCRTEnabled} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; +import {setTeamLoading} from '@store/team_load_store'; import {isTablet} from '@utils/helpers'; import {logDebug, logInfo} from '@utils/log'; @@ -70,7 +68,7 @@ import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePrefe import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions'; import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles'; import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system'; -import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent} from './teams'; +import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent, handleTeamArchived, handleTeamRestored} from './teams'; import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads'; import {handleUserUpdatedEvent, handleUserTypingEvent} from './users'; @@ -88,7 +86,7 @@ export async function handleFirstConnect(serverUrl: string) { // ESR: 5.37 if (lastDisconnect && config?.EnableReliableWebSockets !== 'true' && alreadyConnected.has(serverUrl)) { - handleReconnect(serverUrl); + await handleReconnect(serverUrl); return; } @@ -102,8 +100,8 @@ export async function handleFirstConnect(serverUrl: string) { } } -export function handleReconnect(serverUrl: string) { - doReconnect(serverUrl); +export async function handleReconnect(serverUrl: string) { + await doReconnect(serverUrl); } export async function handleClose(serverUrl: string, lastDisconnect: number) { @@ -141,15 +139,10 @@ async function doReconnect(serverUrl: string) { const currentTeam = await getCurrentTeam(database); const currentChannel = await getCurrentChannel(database); - const currentActiveServerUrl = await getActiveServerUrl(); - if (serverUrl === currentActiveServerUrl) { - DeviceEventEmitter.emit(Events.FETCHING_POSTS, true); - } + setTeamLoading(serverUrl, true); const entryData = await entry(serverUrl, currentTeam?.id, currentChannel?.id, lastDisconnectedAt); if ('error' in entryData) { - if (serverUrl === currentActiveServerUrl) { - DeviceEventEmitter.emit(Events.FETCHING_POSTS, false); - } + setTeamLoading(serverUrl, false); return; } const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData; @@ -159,6 +152,7 @@ async function doReconnect(serverUrl: string) { const dt = Date.now(); await operator.batchRecords(models); logInfo('WEBSOCKET RECONNECT MODELS BATCHING TOOK', `${Date.now() - dt}ms`); + setTeamLoading(serverUrl, false); await fetchPostDataIfNeeded(serverUrl); @@ -318,6 +312,14 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { handleOpenDialogEvent(serverUrl, msg); break; + case WebsocketEvents.DELETE_TEAM: + handleTeamArchived(serverUrl, msg); + break; + + case WebsocketEvents.RESTORE_TEAM: + handleTeamRestored(serverUrl, msg); + break; + case WebsocketEvents.THREAD_UPDATED: handleThreadUpdatedEvent(serverUrl, msg); break; @@ -441,7 +443,10 @@ async function fetchPostDataIfNeeded(serverUrl: string) { if (currentChannelId && (isChannelScreenMounted || tabletDevice)) { await fetchPostsForChannel(serverUrl, currentChannelId); markChannelAsRead(serverUrl, currentChannelId); - markChannelAsViewed(serverUrl, currentChannelId); + if (!EphemeralStore.wasNotificationTapped()) { + markChannelAsViewed(serverUrl, currentChannelId); + } + EphemeralStore.setNotificationTapped(false); } } catch (error) { logDebug('could not fetch needed post after WS reconnect', error); diff --git a/app/actions/websocket/teams.ts b/app/actions/websocket/teams.ts index cf5b501839..c59d62d385 100644 --- a/app/actions/websocket/teams.ts +++ b/app/actions/websocket/teams.ts @@ -6,16 +6,77 @@ import {Model} from '@nozbe/watermelondb'; import {removeUserFromTeam} from '@actions/local/team'; import {fetchMyChannelsForTeam} from '@actions/remote/channel'; import {fetchRoles} from '@actions/remote/role'; -import {fetchAllTeams, fetchMyTeam, handleKickFromTeam} from '@actions/remote/team'; +import {fetchMyTeam, handleKickFromTeam, updateCanJoinTeams} from '@actions/remote/team'; import {updateUsersNoLongerVisible} from '@actions/remote/user'; import DatabaseManager from '@database/manager'; +import ServerDataOperator from '@database/operator/server_data_operator'; +import NetworkManager from '@managers/network_manager'; import {prepareCategoriesAndCategoriesChannels} from '@queries/servers/categories'; import {prepareMyChannelsForTeam} from '@queries/servers/channel'; -import {getCurrentTeam, prepareMyTeams} from '@queries/servers/team'; +import {getCurrentTeam, prepareMyTeams, queryMyTeamsByIds} from '@queries/servers/team'; import {getCurrentUser} from '@queries/servers/user'; import EphemeralStore from '@store/ephemeral_store'; +import {setTeamLoading} from '@store/team_load_store'; import {logDebug} from '@utils/log'; +export async function handleTeamArchived(serverUrl: string, msg: WebSocketMessage) { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const team: Team = JSON.parse(msg.data.team); + + const membership = (await queryMyTeamsByIds(database, [team.id]).fetch())[0]; + if (membership) { + const currentTeam = await getCurrentTeam(database); + if (currentTeam?.id === team.id) { + await handleKickFromTeam(serverUrl, team.id); + } + + await removeUserFromTeam(serverUrl, team.id); + + const user = await getCurrentUser(database); + if (user?.isGuest) { + updateUsersNoLongerVisible(serverUrl); + } + } + updateCanJoinTeams(serverUrl); + } catch (error) { + logDebug('cannot handle archive team websocket event', error); + } +} + +export async function handleTeamRestored(serverUrl: string, msg: WebSocketMessage) { + let markedAsLoading = false; + try { + const client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const team: Team = JSON.parse(msg.data.team); + + const teamMembership = await client.getTeamMember(team.id, 'me'); + if (teamMembership && teamMembership.delete_at === 0) { + // Ignore duplicated team join events sent by the server + if (EphemeralStore.isAddingToTeam(team.id)) { + return; + } + EphemeralStore.startAddingToTeam(team.id); + + setTeamLoading(serverUrl, true); + markedAsLoading = true; + await fetchAndStoreJoinedTeamInfo(serverUrl, operator, team.id, [team], [teamMembership]); + setTeamLoading(serverUrl, false); + markedAsLoading = false; + + EphemeralStore.finishAddingToTeam(team.id); + } + + updateCanJoinTeams(serverUrl); + } catch (error) { + if (markedAsLoading) { + setTeamLoading(serverUrl, false); + } + logDebug('cannot handle restore team websocket event', error); + } +} + export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMessage) { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -33,7 +94,7 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess } await removeUserFromTeam(serverUrl, teamId); - fetchAllTeams(serverUrl); + updateCanJoinTeams(serverUrl); if (user.isGuest) { updateUsersNoLongerVisible(serverUrl); @@ -62,10 +123,6 @@ export async function handleUpdateTeamEvent(serverUrl: string, msg: WebSocketMes } export async function handleUserAddedToTeamEvent(serverUrl: string, msg: WebSocketMessage) { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - return; - } const {team_id: teamId} = msg.data; // Ignore duplicated team join events sent by the server @@ -74,8 +131,20 @@ export async function handleUserAddedToTeamEvent(serverUrl: string, msg: WebSock } EphemeralStore.startAddingToTeam(teamId); - const {teams, memberships: teamMemberships} = await fetchMyTeam(serverUrl, teamId, true); + try { + setTeamLoading(serverUrl, true); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const {teams, memberships: teamMemberships} = await fetchMyTeam(serverUrl, teamId, true); + await fetchAndStoreJoinedTeamInfo(serverUrl, operator, teamId, teams, teamMemberships); + } catch (error) { + logDebug('could not handle user added to team websocket event'); + } + setTeamLoading(serverUrl, false); + EphemeralStore.finishAddingToTeam(teamId); +} + +const fetchAndStoreJoinedTeamInfo = async (serverUrl: string, operator: ServerDataOperator, teamId: string, teams?: Team[], teamMemberships?: TeamMembership[]) => { const modelPromises: Array> = []; if (teams?.length && teamMemberships?.length) { const {channels, memberships, categories} = await fetchMyChannelsForTeam(serverUrl, teamId, false, 0, true); @@ -83,7 +152,9 @@ export async function handleUserAddedToTeamEvent(serverUrl: string, msg: WebSock modelPromises.push(...await prepareMyChannelsForTeam(operator, teamId, channels || [], memberships || [])); const {roles} = await fetchRoles(serverUrl, teamMemberships, memberships, undefined, true); - modelPromises.push(operator.handleRole({roles, prepareRecordsOnly: true})); + if (roles?.length) { + modelPromises.push(operator.handleRole({roles, prepareRecordsOnly: true})); + } } if (teams && teamMemberships) { @@ -92,6 +163,4 @@ export async function handleUserAddedToTeamEvent(serverUrl: string, msg: WebSock const models = await Promise.all(modelPromises); await operator.batchRecords(models.flat()); - - EphemeralStore.finishAddingToTeam(teamId); -} +}; diff --git a/app/components/channel_actions/channel_actions.tsx b/app/components/channel_actions/channel_actions.tsx index 16c31f576a..33af421f79 100644 --- a/app/components/channel_actions/channel_actions.tsx +++ b/app/components/channel_actions/channel_actions.tsx @@ -5,7 +5,6 @@ import React, {useCallback} from 'react'; import {StyleSheet, View} from 'react-native'; import ChannelInfoStartButton from '@calls/components/channel_info_start'; -import AddPeopleBox from '@components/channel_actions/add_people_box'; import CopyChannelLinkBox from '@components/channel_actions/copy_channel_link_box'; import FavoriteBox from '@components/channel_actions/favorite_box'; import MutedBox from '@components/channel_actions/mute_box'; @@ -14,6 +13,8 @@ import {useServerUrl} from '@context/server'; import {dismissBottomSheet} from '@screens/navigation'; import {isTypeDMorGM} from '@utils/channel'; +// import AddPeopleBox from '@components/channel_actions/add_people_box'; + type Props = { channelId: string; channelType?: ChannelType; @@ -69,6 +70,7 @@ const ChannelActions = ({channelId, channelType, inModal = false, dismissChannel testID={`${testID}.set_header.action`} /> } + {/* Add back in after MM-47655 is resolved. https://mattermost.atlassian.net/browse/MM-47655 {!isDM && } + */} {!isDM && !callsEnabled && <> diff --git a/app/components/common_post_options/copy_permalink_option/copy_permalink_option.tsx b/app/components/common_post_options/copy_permalink_option/copy_permalink_option.tsx index 49afc06697..8ba0ee61d0 100644 --- a/app/components/common_post_options/copy_permalink_option/copy_permalink_option.tsx +++ b/app/components/common_post_options/copy_permalink_option/copy_permalink_option.tsx @@ -15,19 +15,20 @@ import {showSnackBar} from '@utils/snack_bar'; import type PostModel from '@typings/database/models/servers/post'; type Props = { + bottomSheetId: typeof Screens[keyof typeof Screens]; sourceScreen: typeof Screens[keyof typeof Screens]; post: PostModel; teamName: string; } -const CopyPermalinkOption = ({teamName, post, sourceScreen}: Props) => { +const CopyPermalinkOption = ({bottomSheetId, teamName, post, sourceScreen}: Props) => { const serverUrl = useServerUrl(); const handleCopyLink = useCallback(async () => { const permalink = `${serverUrl}/${teamName}/pl/${post.id}`; Clipboard.setString(permalink); - await dismissBottomSheet(Screens.POST_OPTIONS); + await dismissBottomSheet(bottomSheetId); showSnackBar({barType: SNACK_BAR_TYPE.LINK_COPIED, sourceScreen}); - }, [teamName, post.id]); + }, [teamName, post.id, bottomSheetId]); return ( { +const FollowThreadOption = ({bottomSheetId, thread, teamId}: FollowThreadOptionProps) => { let id: string; let defaultMessage: string; let icon: string; @@ -44,13 +45,13 @@ const FollowThreadOption = ({thread, teamId}: FollowThreadOptionProps) => { const serverUrl = useServerUrl(); - const handleToggleFollow = async () => { + const handleToggleFollow = useCallback(async () => { if (teamId == null) { return; } - await dismissBottomSheet(Screens.POST_OPTIONS); + await dismissBottomSheet(bottomSheetId); updateThreadFollowing(serverUrl, teamId, thread.id, !thread.isFollowing); - }; + }, [bottomSheetId, teamId, thread]); const followThreadOptionTestId = thread.isFollowing ? 'post_options.following_thread.option' : 'post_options.follow_thread.option'; diff --git a/app/components/common_post_options/reply_option.tsx b/app/components/common_post_options/reply_option.tsx index fc912f733b..6afa518792 100644 --- a/app/components/common_post_options/reply_option.tsx +++ b/app/components/common_post_options/reply_option.tsx @@ -14,16 +14,16 @@ import type PostModel from '@typings/database/models/servers/post'; type Props = { post: PostModel; - location?: typeof Screens[keyof typeof Screens]; + bottomSheetId: typeof Screens[keyof typeof Screens]; } -const ReplyOption = ({post, location}: Props) => { +const ReplyOption = ({post, bottomSheetId}: Props) => { const serverUrl = useServerUrl(); const handleReply = useCallback(async () => { const rootId = post.rootId || post.id; - await dismissBottomSheet(location || Screens.POST_OPTIONS); + await dismissBottomSheet(bottomSheetId); fetchAndSwitchToThread(serverUrl, rootId); - }, [post, serverUrl]); + }, [bottomSheetId, post, serverUrl]); return ( { +const SaveOption = ({bottomSheetId, isSaved, postId}: CopyTextProps) => { const serverUrl = useServerUrl(); const onHandlePress = useCallback(async () => { const remoteAction = isSaved ? deleteSavedPost : savePostPreference; - await dismissBottomSheet(Screens.POST_OPTIONS); + await dismissBottomSheet(bottomSheetId); remoteAction(serverUrl, postId); - }, [postId, serverUrl]); + }, [bottomSheetId, postId, serverUrl]); const id = isSaved ? t('mobile.post_info.unsave') : t('mobile.post_info.save'); const defaultMessage = isSaved ? 'Unsave' : 'Save'; diff --git a/app/components/connection_banner/connection_banner.tsx b/app/components/connection_banner/connection_banner.tsx index ea1340a720..44604ff32d 100644 --- a/app/components/connection_banner/connection_banner.tsx +++ b/app/components/connection_banner/connection_banner.tsx @@ -20,7 +20,7 @@ import {makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; type Props = { - isConnected: boolean; + websocketState: WebsocketConnectedState; } const getStyle = makeStyleSheetFromTheme((theme: Theme) => { @@ -74,7 +74,7 @@ const TIME_TO_OPEN = toMilliseconds({seconds: 3}); const TIME_TO_CLOSE = toMilliseconds({seconds: 1}); const ConnectionBanner = ({ - isConnected, + websocketState, }: Props) => { const intl = useIntl(); const closeTimeout = useRef(); @@ -86,6 +86,8 @@ const ConnectionBanner = ({ const appState = useAppState(); const netInfo = useNetInfo(); + const isConnected = websocketState === 'connected'; + const openCallback = useCallback(() => { setVisible(true); clearTimeoutRef(openTimeout); @@ -97,7 +99,9 @@ const ConnectionBanner = ({ }, []); useEffect(() => { - if (!isConnected) { + if (websocketState === 'connecting') { + openCallback(); + } else if (!isConnected) { openTimeout.current = setTimeout(openCallback, TIME_TO_OPEN); } return () => { @@ -158,6 +162,8 @@ const ConnectionBanner = ({ let text; if (isConnected) { text = intl.formatMessage({id: 'connection_banner.connected', defaultMessage: 'Connection restored'}); + } else if (websocketState === 'connecting') { + text = intl.formatMessage({id: 'connection_banner.connecting', defaultMessage: 'Connecting...'}); } else if (netInfo.isInternetReachable) { text = intl.formatMessage({id: 'connection_banner.not_reachable', defaultMessage: 'The server is not reachable'}); } else { diff --git a/app/components/connection_banner/index.ts b/app/components/connection_banner/index.ts index 0b5547837b..5de70a4285 100644 --- a/app/components/connection_banner/index.ts +++ b/app/components/connection_banner/index.ts @@ -9,7 +9,7 @@ import websocket_manager from '@managers/websocket_manager'; import ConnectionBanner from './connection_banner'; const enhanced = withObservables(['serverUrl'], ({serverUrl}: {serverUrl: string}) => ({ - isConnected: websocket_manager.observeConnected(serverUrl), + websocketState: websocket_manager.observeWebsocketState(serverUrl), })); export default withServerUrl(enhanced(ConnectionBanner)); diff --git a/app/components/files/files.tsx b/app/components/files/files.tsx index 5f45f878ec..b4a404b92f 100644 --- a/app/components/files/files.tsx +++ b/app/components/files/files.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; import Animated, {useDerivedValue} from 'react-native-reanimated'; @@ -69,10 +69,9 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l filesForGallery.value[idx] = file; }; - const isSingleImage = () => (filesInfo.length === 1 && (isImage(filesInfo[0]) || isVideo(filesInfo[0]))); + const isSingleImage = useMemo(() => filesInfo.filter((f) => isImage(f) || isVideo(f)).length === 1, [filesInfo]); const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false) => { - const singleImage = isSingleImage(); let nonVisibleImagesCount: number; let container: StyleProp = items.length > 1 ? styles.container : undefined; const containerWithGutter = [container, styles.gutter]; @@ -97,7 +96,7 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l file={file} index={attachmentIndex(file.id!)} onPress={handlePreviewPress} - isSingleImage={singleImage} + isSingleImage={isSingleImage} nonVisibleImagesCount={nonVisibleImagesCount} publicLinkEnabled={publicLinkEnabled} updateFileForGallery={updateFileForGallery} diff --git a/app/components/files/video_file.tsx b/app/components/files/video_file.tsx index 7b7ef0f863..c5b7d919ec 100644 --- a/app/components/files/video_file.tsx +++ b/app/components/files/video_file.tsx @@ -76,9 +76,8 @@ const VideoFile = ({ const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45; return calculateDimensions(video.height || wrapperWidth, video.width || wrapperWidth, wrapperWidth, viewPortHeight); } - return undefined; - }, [dimensions.height, dimensions.width, video.height, video.width, wrapperWidth]); + }, [dimensions.height, dimensions.width, video.height, video.width, wrapperWidth, isSingleImage]); const getThumbnail = async () => { const data = {...file}; @@ -161,7 +160,12 @@ const VideoFile = ({ if (failed) { thumbnail = ( - + - {!isSingleImage && } + {!isSingleImage && !failed && } {thumbnail} diff --git a/app/components/floating_text_input_label/index.tsx b/app/components/floating_text_input_label/index.tsx index 2657136867..3c5e7ade9c 100644 --- a/app/components/floating_text_input_label/index.tsx +++ b/app/components/floating_text_input_label/index.tsx @@ -5,12 +5,14 @@ import {debounce} from 'lodash'; import React, {useState, useEffect, useRef, useImperativeHandle, forwardRef, useMemo, useCallback} from 'react'; -import {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, Platform, StyleProp, TargetedEvent, Text, TextInput, TextInputFocusEventData, TextInputProps, TextStyle, TouchableWithoutFeedback, View, ViewStyle} from 'react-native'; +import {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TargetedEvent, Text, TextInput, TextInputFocusEventData, TextInputProps, TextStyle, TouchableWithoutFeedback, View, ViewStyle} from 'react-native'; import Animated, {useAnimatedStyle, withTiming, Easing} from 'react-native-reanimated'; import CompassIcon from '@components/compass_icon'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {getLabelPositions, onExecution} from './utils'; + const DEFAULT_INPUT_HEIGHT = 48; const BORDER_DEFAULT_WIDTH = 1; const BORDER_FOCUSED_WIDTH = 2; @@ -36,6 +38,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ lineHeight: 16, paddingVertical: 5, }, + input: { + backgroundColor: 'transparent', + borderWidth: 0, + flex: 1, + paddingHorizontal: 0, + paddingTop: 0, + paddingBottom: 0, + }, label: { position: 'absolute', color: changeOpacity(theme.centerChannelColor, 0.64), @@ -52,6 +62,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ fontSize: 10, }, textInput: { + flexDirection: 'row', fontFamily: 'OpenSans', fontSize: 16, paddingTop: 12, @@ -65,29 +76,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const onExecution = ( - e: NativeSyntheticEvent, - innerFunc?: () => void, - outerFunc?: ((event: NativeSyntheticEvent) => void), -) => { - innerFunc?.(); - outerFunc?.(e); -}; - -const getLabelPositions = (style: TextStyle, labelStyle: TextStyle, smallLabelStyle: TextStyle) => { - const top: number = style.paddingTop as number || 0; - const bottom: number = style.paddingBottom as number || 0; - - const height: number = (style.height as number || (top + bottom) || style.padding as number) || 0; - const textInputFontSize = style.fontSize || 13; - const labelFontSize = labelStyle.fontSize || 16; - const smallLabelFontSize = smallLabelStyle.fontSize || 10; - const fontSizeDiff = textInputFontSize - labelFontSize; - const unfocused = (height * 0.5) + (fontSizeDiff * (Platform.OS === 'android' ? 0.5 : 0.6)); - const focused = -(labelFontSize + smallLabelFontSize) * 0.25; - return [unfocused, focused]; -}; - export type FloatingTextInputRef = { blur: () => void; focus: () => void; @@ -97,10 +85,11 @@ export type FloatingTextInputRef = { type FloatingTextInputProps = TextInputProps & { containerStyle?: ViewStyle; editable?: boolean; + endAdornment?: React.ReactNode; error?: string; errorIcon?: string; isKeyboardInput?: boolean; - label?: string; + label: string; labelTextStyle?: TextStyle; multiline?: boolean; onBlur?: (event: NativeSyntheticEvent) => void; @@ -120,6 +109,7 @@ const FloatingTextInput = forwardRef { + const combinedTextInputContainerStyle = useMemo(() => { const res: StyleProp = [styles.textInput]; if (!editable) { res.push(styles.readOnly); @@ -217,6 +207,16 @@ const FloatingTextInput = forwardRef { + const res: StyleProp = [styles.textInput, styles.input, textInputStyle]; + + if (multiline) { + res.push({height: 80, textAlignVertical: 'top'}); + } + + return res; + }, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, editable]); + const textAnimatedTextStyle = useAnimatedStyle(() => { const inputText = placeholder || value || props.defaultValue; const index = inputText || focusedLabel ? 1 : 0; @@ -245,31 +245,32 @@ const FloatingTextInput = forwardRef - {label && ( - - {label} - - )} - + + {label} + + + + {endAdornment} + {Boolean(error) && ( {showErrorIcon && errorIcon && diff --git a/app/components/floating_text_input_label/utils.ts b/app/components/floating_text_input_label/utils.ts new file mode 100644 index 0000000000..eb6973014b --- /dev/null +++ b/app/components/floating_text_input_label/utils.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {NativeSyntheticEvent, Platform, TargetedEvent, TextInputFocusEventData, TextStyle} from 'react-native'; + +export const onExecution = ( + e: NativeSyntheticEvent, + innerFunc?: () => void, + outerFunc?: ((event: NativeSyntheticEvent) => void), +) => { + innerFunc?.(); + outerFunc?.(e); +}; + +export const getLabelPositions = (style: TextStyle, labelStyle: TextStyle, smallLabelStyle: TextStyle) => { + const top: number = style.paddingTop as number || 0; + const bottom: number = style.paddingBottom as number || 0; + + const height: number = (style.height as number || (top + bottom) || style.padding as number) || 0; + const textInputFontSize = style.fontSize || 13; + const labelFontSize = labelStyle.fontSize || 16; + const smallLabelFontSize = smallLabelStyle.fontSize || 10; + const fontSizeDiff = textInputFontSize - labelFontSize; + const unfocused = (height * 0.5) + (fontSizeDiff * (Platform.OS === 'android' ? 0.5 : 0.6)); + const focused = -(labelFontSize + smallLabelFontSize) * 0.25; + return [unfocused, focused]; +}; + diff --git a/app/components/markdown/hashtag/index.tsx b/app/components/markdown/hashtag/index.tsx index 2090e80737..17b041c54c 100644 --- a/app/components/markdown/hashtag/index.tsx +++ b/app/components/markdown/hashtag/index.tsx @@ -2,8 +2,9 @@ // See LICENSE.txt for license information. import React from 'react'; -import {StyleProp, Text, TextStyle} from 'react-native'; +import {DeviceEventEmitter, StyleProp, Text, TextStyle} from 'react-native'; +import {Navigation, Screens} from '@constants'; import {popToRoot, dismissAllModals} from '@screens/navigation'; type HashtagProps = { @@ -17,7 +18,12 @@ const Hashtag = ({hashtag, linkStyle}: HashtagProps) => { await dismissAllModals(); await popToRoot(); - // showSearchModal('#' + hashtag); + DeviceEventEmitter.emit(Navigation.NAVIGATE_TO_TAB, { + screen: Screens.SEARCH, + params: { + searchTerm: hashtag, + }, + }); }; return ( diff --git a/app/components/markdown/markdown.tsx b/app/components/markdown/markdown.tsx index a7efb3a11b..0437097154 100644 --- a/app/components/markdown/markdown.tsx +++ b/app/components/markdown/markdown.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {useManagedConfig} from '@mattermost/react-native-emm'; import {Parser, Node} from 'commonmark'; import Renderer from 'commonmark-react-renderer'; import React, {ReactElement, useMemo, useRef} from 'react'; @@ -131,6 +132,7 @@ const Markdown = ({ textStyles = {}, theme, value = '', baseParagraphStyle, }: MarkdownProps) => { const style = getStyleSheet(theme); + const managedConfig = useManagedConfig(); const urlFilter = (url: string) => { const scheme = getScheme(url); @@ -471,10 +473,14 @@ const Markdown = ({ }; const renderText = ({context, literal}: MarkdownBaseRenderer) => { + const selectable = (managedConfig.copyAndPasteProtection !== 'true') && context.includes('table_cell'); if (context.indexOf('image') !== -1) { // If this text is displayed, it will be styled by the image component return ( - + {literal} ); @@ -496,6 +502,7 @@ const Markdown = ({ {literal} diff --git a/app/components/markdown/markdown_image/index.tsx b/app/components/markdown/markdown_image/index.tsx index 3f734ee2e6..b266162590 100644 --- a/app/components/markdown/markdown_image/index.tsx +++ b/app/components/markdown/markdown_image/index.tsx @@ -59,7 +59,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ height: 24, }, container: { - marginBottom: 5, + marginVertical: 5, + top: 5, }, svg: { backgroundColor: changeOpacity(theme.centerChannelColor, 0.06), diff --git a/app/components/markdown/markdown_link/markdown_link.tsx b/app/components/markdown/markdown_link/markdown_link.tsx index 1189b4a92f..82fa6d93a7 100644 --- a/app/components/markdown/markdown_link/markdown_link.tsx +++ b/app/components/markdown/markdown_link/markdown_link.tsx @@ -9,19 +9,14 @@ import {Alert, StyleSheet, Text, View} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import urlParse from 'url-parse'; -import {switchToChannelByName} from '@actions/remote/channel'; -import {showPermalink} from '@actions/remote/permalink'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; -import DeepLinkType from '@constants/deep_linking'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {bottomSheet, dismissBottomSheet} from '@screens/navigation'; -import {errorBadChannel} from '@utils/draft'; +import {handleDeepLink, matchDeepLink} from '@utils/deep_link'; import {bottomSheetSnapPoint} from '@utils/helpers'; import {preventDoubleTap} from '@utils/tap'; -import {matchDeepLink, normalizeProtocol, tryOpenURL} from '@utils/url'; - -import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkWithData} from '@typings/launch'; +import {normalizeProtocol, tryOpenURL} from '@utils/url'; type MarkdownLinkProps = { children: ReactElement; @@ -65,28 +60,27 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU return; } - const match: DeepLinkWithData | null = matchDeepLink(url, serverUrl, siteURL); + const onError = () => { + Alert.alert( + formatMessage({ + id: 'mobile.link.error.title', + defaultMessage: 'Error', + }), + formatMessage({ + id: 'mobile.link.error.text', + defaultMessage: 'Unable to open the link.', + }), + ); + }; - if (match && match.data?.teamName) { - if (match.type === DeepLinkType.Channel) { - await switchToChannelByName(serverUrl, (match?.data as DeepLinkChannel).channelName, match.data?.teamName, errorBadChannel, intl); - } else if (match.type === DeepLinkType.Permalink) { - showPermalink(serverUrl, match.data.teamName, (match.data as DeepLinkPermalink).postId, intl); + const match = matchDeepLink(url, serverUrl, siteURL); + + if (match) { + const {error} = await handleDeepLink(match, intl); + if (error) { + tryOpenURL(match, onError); } } else { - const onError = () => { - Alert.alert( - formatMessage({ - id: 'mobile.link.error.title', - defaultMessage: 'Error', - }), - formatMessage({ - id: 'mobile.link.error.text', - defaultMessage: 'Unable to open the link.', - }), - ); - }; - tryOpenURL(url, onError); } }), [href, intl.locale, serverUrl, siteURL]); diff --git a/app/components/markdown/markdown_table_cell/index.tsx b/app/components/markdown/markdown_table_cell/index.tsx index 2da5759471..3fb8eb7173 100644 --- a/app/components/markdown/markdown_table_cell/index.tsx +++ b/app/components/markdown/markdown_table_cell/index.tsx @@ -24,6 +24,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { justifyContent: 'flex-start', padding: 8, }, + textContainer: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + }, cellRightBorder: { borderRightWidth: 1, }, @@ -57,7 +62,9 @@ const MarkdownTableCell = ({isLastCell, align, children}: MarkdownTableCellProps style={[cellStyle, textStyle]} testID='markdown_table_cell' > - {children} + + {children} + ); }; diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx index dfd8ea3154..96a5a881b9 100644 --- a/app/components/option_item/index.tsx +++ b/app/components/option_item/index.tsx @@ -283,7 +283,6 @@ const OptionItem = ({ } ); - if (Object.values(TouchableOptionTypes).includes(type)) { return ( diff --git a/app/components/post_draft/draft_input/index.tsx b/app/components/post_draft/draft_input/index.tsx index cfb4a276a4..7ded6863bd 100644 --- a/app/components/post_draft/draft_input/index.tsx +++ b/app/components/post_draft/draft_input/index.tsx @@ -24,8 +24,8 @@ type Props = { canShowPostPriority?: boolean; // Post Props - postProps: Post['props']; - updatePostProps: (postProps: Post['props']) => void; + postPriority: PostPriorityData; + updatePostPriority: (postPriority: PostPriorityData) => void; // Cursor Position Handler updateCursorPosition: React.Dispatch>; @@ -110,8 +110,8 @@ export default function DraftInput({ updateCursorPosition, cursorPosition, updatePostInputTop, - postProps, - updatePostProps, + postPriority, + updatePostPriority, setIsFocused, }: Props) { const theme = useTheme(); @@ -155,9 +155,9 @@ export default function DraftInput({ overScrollMode={'never'} disableScrollViewPanResponder={true} > - {Boolean(postProps.priority) && ( + {Boolean(postPriority?.priority) && ( - + )} diff --git a/app/components/post_draft/quick_actions/camera_quick_action/camera_type.tsx b/app/components/post_draft/quick_actions/camera_quick_action/camera_type.tsx index 411c4d5945..c42eccb1d4 100644 --- a/app/components/post_draft/quick_actions/camera_quick_action/camera_type.tsx +++ b/app/components/post_draft/quick_actions/camera_quick_action/camera_type.tsx @@ -2,60 +2,36 @@ // See LICENSE.txt for license information. import React from 'react'; +import {useIntl} from 'react-intl'; import {View} from 'react-native'; import {CameraOptions} from 'react-native-image-picker'; -import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; +import SlideUpPanelItem from '@components/slide_up_panel_item'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {dismissBottomSheet} from '@screens/navigation'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; type Props = { onPress: (options: CameraOptions) => void; } const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({ - center: { - alignItems: 'center', - }, - container: { - alignItems: 'center', - backgroundColor: theme.centerChannelBg, - height: 200, - paddingVertical: 10, - }, - flex: { - flex: 1, - }, - options: { - alignItems: 'center', - flex: 1, - flexDirection: 'row', - justifyContent: 'space-evenly', - width: '100%', - marginBottom: 50, - }, - optionContainer: { - alignItems: 'flex-start', - }, title: { color: theme.centerChannelColor, - fontSize: 18, - fontWeight: 'bold', - }, - text: { - color: theme.centerChannelColor, - fontSize: 15, + ...typography('Heading', 600, 'SemiBold'), + marginBottom: 8, }, + })); const CameraType = ({onPress}: Props) => { const theme = useTheme(); const isTablet = useIsTablet(); const style = getStyle(theme); + const intl = useIntl(); const onPhoto = async () => { const options: CameraOptions = { @@ -80,54 +56,26 @@ const CameraType = ({onPress}: Props) => { }; return ( - + {!isTablet && } - - - - - - - - - - - - - - - - - - + + ); }; diff --git a/app/components/post_draft/quick_actions/camera_quick_action/index.tsx b/app/components/post_draft/quick_actions/camera_quick_action/index.tsx index 2579bb6ade..35d24aeaa5 100644 --- a/app/components/post_draft/quick_actions/camera_quick_action/index.tsx +++ b/app/components/post_draft/quick_actions/camera_quick_action/index.tsx @@ -5,14 +5,18 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; import {Alert, StyleSheet} from 'react-native'; import {CameraOptions} from 'react-native-image-picker'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; import CompassIcon from '@components/compass_icon'; +import {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {ICON_SIZE} from '@constants/post_draft'; import {useTheme} from '@context/theme'; +import {TITLE_HEIGHT} from '@screens/bottom_sheet/content'; import {bottomSheet} from '@screens/navigation'; import {fileMaxWarning} from '@utils/file'; import PickerUtil from '@utils/file/file_picker'; +import {bottomSheetSnapPoint} from '@utils/helpers'; import {changeOpacity} from '@utils/theme'; import CameraType from './camera_type'; @@ -36,6 +40,7 @@ export default function CameraQuickAction({ }: QuickActionAttachmentProps) { const intl = useIntl(); const theme = useTheme(); + const {bottom} = useSafeAreaInsets(); const handleButtonPress = useCallback((options: CameraOptions) => { const picker = new PickerUtil(intl, @@ -64,14 +69,15 @@ export default function CameraQuickAction({ return; } + const snap = bottomSheetSnapPoint(2, ITEM_HEIGHT, bottom); bottomSheet({ - title: intl.formatMessage({id: 'camera_type.title', defaultMessage: 'Choose an action'}), + title: intl.formatMessage({id: 'mobile.camera_type.title', defaultMessage: 'Camera options'}), renderContent, - snapPoints: [200, 10], + snapPoints: [TITLE_HEIGHT + snap, 10], theme, closeButtonId: 'camera-close-id', }); - }, [intl, theme, renderContent, maxFilesReached, maxFileCount]); + }, [intl, theme, renderContent, maxFilesReached, maxFileCount, bottom]); const actionTestID = disabled ? `${testID}.disabled` : testID; const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64); diff --git a/app/components/post_draft/quick_actions/post_priority_action/index.tsx b/app/components/post_draft/quick_actions/post_priority_action/index.tsx index cc6452f239..0e0ddf2958 100644 --- a/app/components/post_draft/quick_actions/post_priority_action/index.tsx +++ b/app/components/post_draft/quick_actions/post_priority_action/index.tsx @@ -6,7 +6,7 @@ import {useIntl} from 'react-intl'; import {StyleSheet} from 'react-native'; import CompassIcon from '@components/compass_icon'; -import PostPriorityPicker, {PostPriorityData} from '@components/post_priority/post_priority_picker'; +import PostPriorityPicker from '@components/post_priority/post_priority_picker'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {ICON_SIZE} from '@constants/post_draft'; import {useTheme} from '@context/theme'; @@ -15,8 +15,8 @@ import {changeOpacity} from '@utils/theme'; type Props = { testID?: string; - postProps: Post['props']; - updatePostProps: (postProps: Post['props']) => void; + postPriority: PostPriorityData; + updatePostPriority: (postPriority: PostPriorityData) => void; } const style = StyleSheet.create({ @@ -29,30 +29,27 @@ const style = StyleSheet.create({ export default function PostPriorityAction({ testID, - postProps, - updatePostProps, + postPriority, + updatePostPriority, }: Props) { const intl = useIntl(); const theme = useTheme(); const handlePostPriorityPicker = useCallback((postPriorityData: PostPriorityData) => { - updatePostProps((oldPostProps: Post['props']) => ({ - ...oldPostProps, - ...postPriorityData, - })); + updatePostPriority(postPriorityData); dismissBottomSheet(); - }, [updatePostProps]); + }, [updatePostPriority]); const renderContent = useCallback(() => { return ( ); - }, [handlePostPriorityPicker, postProps]); + }, [handlePostPriorityPicker, postPriority]); const onPress = useCallback(() => { bottomSheet({ diff --git a/app/components/post_draft/quick_actions/quick_actions.tsx b/app/components/post_draft/quick_actions/quick_actions.tsx index 10d4592321..944a166d0a 100644 --- a/app/components/post_draft/quick_actions/quick_actions.tsx +++ b/app/components/post_draft/quick_actions/quick_actions.tsx @@ -22,8 +22,8 @@ type Props = { value: string; updateValue: (value: string) => void; addFiles: (file: FileInfo[]) => void; - postProps: Post['props']; - updatePostProps: (postProps: Post['props']) => void; + postPriority: PostPriorityData; + updatePostPriority: (postPriority: PostPriorityData) => void; focus: () => void; } @@ -45,8 +45,8 @@ export default function QuickActions({ maxFileCount, updateValue, addFiles, - postProps, - updatePostProps, + postPriority, + updatePostPriority, focus, }: Props) { const atDisabled = value[value.length - 1] === '@'; @@ -101,8 +101,8 @@ export default function QuickActions({ {isPostPriorityEnabled && canShowPostPriority && ( )} diff --git a/app/components/post_draft/send_handler/send_handler.tsx b/app/components/post_draft/send_handler/send_handler.tsx index 6ffb5fb51d..97c7e7e696 100644 --- a/app/components/post_draft/send_handler/send_handler.tsx +++ b/app/components/post_draft/send_handler/send_handler.tsx @@ -13,6 +13,7 @@ import {setStatus} from '@actions/remote/user'; import {canEndCall, endCall, getEndCallMessage} from '@calls/actions/calls'; import ClientError from '@client/rest/error'; import {Events, Screens} from '@constants'; +import {PostPriorityType} from '@constants/post'; import {NOTIFY_ALL_MEMBERS} from '@constants/post_draft'; import {useServerUrl} from '@context/server'; import DraftUploadManager from '@managers/draft_upload_manager'; @@ -54,6 +55,10 @@ type Props = { uploadFileError: React.ReactNode; } +const INITIAL_PRIORITY = { + priority: PostPriorityType.STANDARD, +}; + export default function SendHandler({ testID, channelId, @@ -83,8 +88,7 @@ export default function SendHandler({ const [channelTimezoneCount, setChannelTimezoneCount] = useState(0); const [sendingMessage, setSendingMessage] = useState(false); - - const [postProps, setPostProps] = useState({}); + const [postPriority, setPostPriority] = useState(INITIAL_PRIORITY); const canSend = useCallback(() => { if (sendingMessage) { @@ -120,17 +124,19 @@ export default function SendHandler({ message: value, } as Post; - if (Object.keys(postProps).length) { - post.props = postProps; + if (Object.keys(postPriority).length) { + post.metadata = { + priority: postPriority, + }; } createPost(serverUrl, post, postFiles); clearDraft(); setSendingMessage(false); - setPostProps({}); + setPostPriority(INITIAL_PRIORITY); DeviceEventEmitter.emit(Events.POST_LIST_SCROLL_TO_BOTTOM, rootId ? Screens.THREAD : Screens.CHANNEL); - }, [files, currentUserId, channelId, rootId, value, clearDraft, postProps]); + }, [files, currentUserId, channelId, rootId, value, clearDraft, postPriority]); const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean) => { const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, Boolean(isTimezoneEnabled), channelTimezoneCount, atHere); @@ -297,8 +303,8 @@ export default function SendHandler({ canSend={canSend()} maxMessageLength={maxMessageLength} updatePostInputTop={updatePostInputTop} - postProps={postProps} - updatePostProps={setPostProps} + postPriority={postPriority} + updatePostPriority={setPostPriority} setIsFocused={setIsFocused} /> ); diff --git a/app/components/post_list/post/body/message/message.tsx b/app/components/post_list/post/body/message/message.tsx index 74df04462f..001bbff239 100644 --- a/app/components/post_list/post/body/message/message.tsx +++ b/app/components/post_list/post/body/message/message.tsx @@ -44,6 +44,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { message: { color: theme.centerChannelColor, ...typography('Body', 200), + lineHeight: undefined, // remove line height, not needed and causes problems with md images }, pendingPost: { opacity: 0.5, diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index fafe6035bc..7b9eaab895 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -132,10 +132,10 @@ const Header = (props: HeaderProps) => { style={style.time} testID='post_header.date_time' /> - {showPostPriority && ( + {showPostPriority && post.metadata?.priority?.priority && ( )} diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index a3a812483f..a177816680 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -139,7 +139,7 @@ const Post = ({ const handlePostPress = () => { if ([Screens.SAVED_MESSAGES, Screens.MENTIONS, Screens.SEARCH, Screens.PINNED_MESSAGES].includes(location)) { - showPermalink(serverUrl, '', post.id, intl); + showPermalink(serverUrl, '', post.id); return; } @@ -226,7 +226,7 @@ const Post = ({ // If the post is a priority post: // 1. Show the priority label in channel screen // 2. Show the priority label in thread screen for the root post - const showPostPriority = Boolean(isPostPriorityEnabled && post.props?.priority) && (location !== Screens.THREAD || !post.rootId); + const showPostPriority = Boolean(isPostPriorityEnabled && post.metadata?.priority?.priority) && (location !== Screens.THREAD || !post.rootId); const sameSequence = hasReplies ? (hasReplies && post.rootId) : !post.rootId; if (!showPostPriority && hasSameRoot && isConsecutivePost && sameSequence) { diff --git a/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap b/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap index 04c7ab01b1..8d44565568 100644 --- a/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap +++ b/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap @@ -44,6 +44,7 @@ exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] = { const props = { isSaved: true, repliesCount: 0, + rootId: '', rootPost: {} as PostModel, testID: 'thread-overview', }; @@ -26,6 +27,7 @@ describe('ThreadOverview', () => { const props = { isSaved: false, repliesCount: 2, + rootId: '', rootPost: {} as PostModel, testID: 'thread-overview', }; diff --git a/app/components/post_list/thread_overview/thread_overview.tsx b/app/components/post_list/thread_overview/thread_overview.tsx index 2a80341e06..f9f532e9ca 100644 --- a/app/components/post_list/thread_overview/thread_overview.tsx +++ b/app/components/post_list/thread_overview/thread_overview.tsx @@ -13,6 +13,7 @@ import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; +import {useFetchingThreadState} from '@hooks/fetching_thread'; import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -23,6 +24,7 @@ import type PostModel from '@typings/database/models/servers/post'; type Props = { isSaved: boolean; repliesCount: number; + rootId: string; rootPost?: PostModel; testID: string; style?: StyleProp; @@ -56,13 +58,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -const ThreadOverview = ({isSaved, repliesCount, rootPost, style, testID}: Props) => { +const ThreadOverview = ({isSaved, repliesCount, rootId, rootPost, style, testID}: Props) => { const theme = useTheme(); const styles = getStyleSheet(theme); const intl = useIntl(); const isTablet = useIsTablet(); const serverUrl = useServerUrl(); + const isFetchingThread = useFetchingThreadState(rootId); const onHandleSavePress = useCallback(preventDoubleTap(() => { if (rootPost?.id) { @@ -98,30 +101,44 @@ const ThreadOverview = ({isSaved, repliesCount, rootPost, style, testID}: Props) const saveButtonTestId = isSaved ? `${testID}.unsave.button` : `${testID}.save.button`; + let repliesCountElement; + if (repliesCount > 0) { + repliesCountElement = ( + + ); + } else if (isFetchingThread) { + repliesCountElement = ( + + ); + } else { + repliesCountElement = ( + + ); + } + return ( - { - repliesCount > 0 ? ( - - ) : ( - - ) - } + {repliesCountElement} { @@ -48,7 +48,7 @@ const PostPriorityLabel = ({label}: Props) => { containerStyle.push(style.urgent); iconName = 'alert-outline'; labelText = intl.formatMessage({id: 'post_priority.label.urgent', defaultMessage: 'URGENT'}); - } else { + } else if (label === PostPriorityType.IMPORTANT) { containerStyle.push(style.important); iconName = 'alert-circle-outline'; labelText = intl.formatMessage({id: 'post_priority.label.important', defaultMessage: 'IMPORTANT'}); diff --git a/app/components/post_priority/post_priority_picker/index.tsx b/app/components/post_priority/post_priority_picker/index.tsx index 9de0b58d0c..5b70622b8f 100644 --- a/app/components/post_priority/post_priority_picker/index.tsx +++ b/app/components/post_priority/post_priority_picker/index.tsx @@ -14,10 +14,6 @@ import {typography} from '@utils/typography'; import PostPriorityPickerItem from './post_priority_picker_item'; -export type PostPriorityData = { - priority: PostPriorityType; -}; - type Props = { data: PostPriorityData; onSubmit: (data: PostPriorityData) => void; @@ -34,7 +30,7 @@ const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({ }, title: { color: theme.centerChannelColor, - ...typography('Body', 600, 'SemiBold'), + ...typography('Heading', 600, 'SemiBold'), }, betaContainer: { backgroundColor: PostPriorityColors.IMPORTANT, @@ -61,8 +57,8 @@ const PostPriorityPicker = ({data, onSubmit}: Props) => { // For now, we just have one option but the spec suggest we have more in the next phase // const [data, setData] = React.useState(defaultData); - const handleUpdatePriority = React.useCallback((priority: PostPriorityType) => { - onSubmit({priority}); + const handleUpdatePriority = React.useCallback((priority: PostPriorityData['priority']) => { + onSubmit({priority: priority || ''}); }, [onSubmit]); return ( diff --git a/app/components/selected_users/index.tsx b/app/components/selected_users/index.tsx index 72a639aee6..e2ffad6f76 100644 --- a/app/components/selected_users/index.tsx +++ b/app/components/selected_users/index.tsx @@ -67,6 +67,11 @@ type Props = { */ teammateNameDisplay: string; + /** + * test ID + */ + testID?: string; + /** * toast Icon */ @@ -130,7 +135,7 @@ export default function SelectedUsers({ buttonIcon, buttonText, containerHeight = 0, modalPosition = 0, onPress, onRemove, selectedIds, setShowToast, showToast = false, - teammateNameDisplay, toastIcon, toastMessage, + teammateNameDisplay, testID, toastIcon, toastMessage, }: Props) { const theme = useTheme(); const style = getStyleFromTheme(theme); @@ -157,7 +162,7 @@ export default function SelectedUsers({ user={selectedIds[id]} teammateNameDisplay={teammateNameDisplay} onRemove={onRemove} - testID='create_direct_message.selected_user' + testID={`${testID}.selected_user`} />, ); } @@ -276,6 +281,7 @@ export default function SelectedUsers({ icon={buttonIcon} text={buttonText} disabled={numberSelectedIds > General.MAX_USERS_IN_GM} + testID={`${testID}.start.button`} /> diff --git a/app/components/syntax_highlight/index.tsx b/app/components/syntax_highlight/index.tsx index a83b2e3e01..8ddcdd63b0 100644 --- a/app/components/syntax_highlight/index.tsx +++ b/app/components/syntax_highlight/index.tsx @@ -4,7 +4,7 @@ import React, {useCallback, useMemo} from 'react'; import {StyleSheet, View} from 'react-native'; import SyntaxHighlighter from 'react-syntax-highlighter'; -import {github, monokai, solarizedDark, solarizedLight} from 'react-syntax-highlighter/dist/cjs/styles/hljs'; +import {githubGist as github, monokai, solarizedDark, solarizedLight} from 'react-syntax-highlighter/dist/cjs/styles/hljs'; import {useTheme} from '@context/theme'; diff --git a/app/components/syntax_highlight/renderer.tsx b/app/components/syntax_highlight/renderer.tsx index c98cfd3f43..71c45d3109 100644 --- a/app/components/syntax_highlight/renderer.tsx +++ b/app/components/syntax_highlight/renderer.tsx @@ -74,7 +74,7 @@ function createNativeElement({node, stylesheet, key, defaultColor, fontFamily, f {value} diff --git a/app/components/team_sidebar/add_team/team_list.tsx b/app/components/team_list/index.tsx similarity index 83% rename from app/components/team_sidebar/add_team/team_list.tsx rename to app/components/team_list/index.tsx index 28da68a3c3..08c4738adf 100644 --- a/app/components/team_sidebar/add_team/team_list.tsx +++ b/app/components/team_list/index.tsx @@ -5,6 +5,8 @@ import React, {useCallback} from 'react'; import {ListRenderItemInfo, StyleSheet, View} from 'react-native'; import {FlatList} from 'react-native-gesture-handler'; // Keep the FlatList from gesture handler so it works well with bottom sheet +import Loading from '@components/loading'; + import TeamListItem from './team_list_item'; import type TeamModel from '@typings/database/models/servers/team'; @@ -17,6 +19,8 @@ type Props = { onPress: (id: string) => void; testID?: string; selectedTeamId?: string; + onEndReached?: () => void; + loading?: boolean; } const styles = StyleSheet.create({ @@ -30,7 +34,7 @@ const styles = StyleSheet.create({ const keyExtractor = (item: TeamModel) => item.id; -export default function TeamList({teams, textColor, iconTextColor, iconBackgroundColor, onPress, testID, selectedTeamId}: Props) { +export default function TeamList({teams, textColor, iconTextColor, iconBackgroundColor, onPress, testID, selectedTeamId, onEndReached, loading = false}: Props) { const renderTeam = useCallback(({item: t}: ListRenderItemInfo) => { return ( ); + } + return ( ); diff --git a/app/components/team_sidebar/add_team/team_list_item/index.ts b/app/components/team_list/team_list_item/index.ts similarity index 100% rename from app/components/team_sidebar/add_team/team_list_item/index.ts rename to app/components/team_list/team_list_item/index.ts diff --git a/app/components/team_sidebar/add_team/team_list_item/team_list_item.tsx b/app/components/team_list/team_list_item/team_list_item.tsx similarity index 100% rename from app/components/team_sidebar/add_team/team_list_item/team_list_item.tsx rename to app/components/team_list/team_list_item/team_list_item.tsx diff --git a/app/components/team_sidebar/add_team/add_team_slide_up.tsx b/app/components/team_sidebar/add_team/add_team_slide_up.tsx deleted file mode 100644 index 2e41bee57b..0000000000 --- a/app/components/team_sidebar/add_team/add_team_slide_up.tsx +++ /dev/null @@ -1,105 +0,0 @@ -// 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 {View} from 'react-native'; - -import {addCurrentUserToTeam, handleTeamChange} from '@actions/remote/team'; -import FormattedText from '@components/formatted_text'; -import Empty from '@components/illustrations/no_team'; -import {useServerUrl} from '@context/server'; -import {useTheme} from '@context/theme'; -import BottomSheetContent from '@screens/bottom_sheet/content'; -import {dismissBottomSheet} from '@screens/navigation'; -import {makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; - -import TeamList from './team_list'; - -import type TeamModel from '@typings/database/models/servers/team'; - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ - empty: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - title: { - color: theme.centerChannelColor, - lineHeight: 28, - marginTop: 16, - ...typography('Heading', 400, 'Regular'), - }, - description: { - color: theme.centerChannelColor, - marginTop: 8, - maxWidth: 334, - ...typography('Body', 200, 'Regular'), - }, -})); - -type Props = { - otherTeams: TeamModel[]; - title: string; - showTitle?: boolean; -} - -export default function AddTeamSlideUp({otherTeams, title, showTitle = true}: Props) { - const intl = useIntl(); - const serverUrl = useServerUrl(); - const theme = useTheme(); - const styles = getStyleSheet(theme); - - const onPressCreate = useCallback(() => { - //TODO Create team screen https://mattermost.atlassian.net/browse/MM-43622 - dismissBottomSheet(); - }, []); - - const onPress = useCallback(async (teamId: string) => { - const {error} = await addCurrentUserToTeam(serverUrl, teamId); - if (!error) { - await dismissBottomSheet(); - handleTeamChange(serverUrl, teamId); - } - }, [serverUrl]); - - const hasOtherTeams = Boolean(otherTeams.length); - - return ( - - {hasOtherTeams && - - } - {!hasOtherTeams && - - - - - - } - - ); -} diff --git a/app/components/team_sidebar/add_team/index.tsx b/app/components/team_sidebar/add_team/index.tsx index e361ef5ddb..9fd187239f 100644 --- a/app/components/team_sidebar/add_team/index.tsx +++ b/app/components/team_sidebar/add_team/index.tsx @@ -3,26 +3,16 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {useWindowDimensions, View} from 'react-native'; -import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {View} from 'react-native'; import CompassIcon from '@components/compass_icon'; import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Screens} from '@constants'; import {useTheme} from '@context/theme'; -import {useIsTablet} from '@hooks/device'; -import {bottomSheet} from '@screens/navigation'; +import {showModal} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; -import {getTeamsSnapHeight} from '@utils/team_list'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import AddTeamSlideUp from './add_team_slide_up'; - -import type TeamModel from '@typings/database/models/servers/team'; - -type Props = { - otherTeams: TeamModel[]; -} - const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { container: { @@ -45,35 +35,26 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -export default function AddTeam({otherTeams}: Props) { +export default function AddTeam() { const theme = useTheme(); const styles = getStyleSheet(theme); - const dimensions = useWindowDimensions(); const intl = useIntl(); - const insets = useSafeAreaInsets(); - const isTablet = useIsTablet(); const onPress = useCallback(preventDoubleTap(() => { const title = intl.formatMessage({id: 'mobile.add_team.join_team', defaultMessage: 'Join Another Team'}); - const renderContent = () => { - return ( - - ); + const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + const closeButtonId = 'close-join-team'; + const options = { + topBar: { + leftButtons: [{ + id: closeButtonId, + icon: closeButton, + testID: 'close.join_team.button', + }], + }, }; - - const height = getTeamsSnapHeight({dimensions, teams: otherTeams, insets}); - bottomSheet({ - closeButtonId: 'close-team_list', - renderContent, - snapPoints: [height, 10], - theme, - title, - }); - }), [otherTeams, intl, isTablet, dimensions, theme]); + showModal(Screens.JOIN_TEAM, title, {closeButtonId}, options); + }), [intl]); return ( diff --git a/app/components/team_sidebar/index.ts b/app/components/team_sidebar/index.ts index 55561115ef..8c4a8417d1 100644 --- a/app/components/team_sidebar/index.ts +++ b/app/components/team_sidebar/index.ts @@ -1,17 +1,14 @@ // 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 {switchMap} from 'rxjs/operators'; -import {queryMyTeams, queryOtherTeams} from '@queries/servers/team'; +import {withServerUrl} from '@context/server'; +import EphemeralStore from '@store/ephemeral_store'; import TeamSidebar from './team_sidebar'; -import type {WithDatabaseArgs} from '@typings/database/database'; - -const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { +const enhanced = withObservables([], ({serverUrl}: {serverUrl: string}) => { // TODO https://mattermost.atlassian.net/browse/MM-43622 // const canCreateTeams = observeCurrentUser(database).pipe( // switchMap((u) => (u ? of$(u.roles.split(' ')) : of$([]))), @@ -19,17 +16,9 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { // switchMap((r) => of$(hasPermission(r, Permissions.CREATE_TEAM, false))), // ); - const otherTeams = queryMyTeams(database).observe().pipe( - switchMap((mm) => { - // eslint-disable-next-line max-nested-callbacks - const ids = mm.map((m) => m.id); - return queryOtherTeams(database, ids).observe(); - }), - ); - return { - otherTeams, + canJoinOtherTeams: EphemeralStore.observeCanJoinOtherTeams(serverUrl), }; }); -export default withDatabase(enhanced(TeamSidebar)); +export default withServerUrl(enhanced(TeamSidebar)); diff --git a/app/components/team_sidebar/team_sidebar.tsx b/app/components/team_sidebar/team_sidebar.tsx index 50a84181d8..bab5239ee8 100644 --- a/app/components/team_sidebar/team_sidebar.tsx +++ b/app/components/team_sidebar/team_sidebar.tsx @@ -11,11 +11,9 @@ import {makeStyleSheetFromTheme} from '@utils/theme'; import AddTeam from './add_team'; import TeamList from './team_list'; -import type TeamModel from '@typings/database/models/servers/team'; - type Props = { iconPad?: boolean; - otherTeams: TeamModel[]; + canJoinOtherTeams: boolean; teamsCount: number; } @@ -38,8 +36,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -export default function TeamSidebar({iconPad, otherTeams, teamsCount}: Props) { - const showAddTeam = otherTeams.length > 0; +export default function TeamSidebar({iconPad, canJoinOtherTeams, teamsCount}: Props) { const initialWidth = teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0; const width = useSharedValue(initialWidth); const marginTop = useSharedValue(iconPad ? 44 : 0); @@ -68,10 +65,8 @@ export default function TeamSidebar({iconPad, otherTeams, teamsCount}: Props) { - {showAddTeam && ( - + {canJoinOtherTeams && ( + )} diff --git a/app/components/tutorial_highlight/long_press.tsx b/app/components/tutorial_highlight/long_press.tsx index 561af0d256..04bae5a81d 100644 --- a/app/components/tutorial_highlight/long_press.tsx +++ b/app/components/tutorial_highlight/long_press.tsx @@ -48,6 +48,7 @@ const TutorialSwipeLeft = ({containerStyle, message, style, textStyles}: Props) diff --git a/app/components/tutorial_highlight/swipe_left.tsx b/app/components/tutorial_highlight/swipe_left.tsx index 505116f6a1..4669a6fbaf 100644 --- a/app/components/tutorial_highlight/swipe_left.tsx +++ b/app/components/tutorial_highlight/swipe_left.tsx @@ -48,6 +48,7 @@ const TutorialSwipeLeft = ({containerStyle, message, style, textStyles}: Props) diff --git a/app/components/user_avatars_stack/index.tsx b/app/components/user_avatars_stack/index.tsx index d2a43bc323..57199ba305 100644 --- a/app/components/user_avatars_stack/index.tsx +++ b/app/components/user_avatars_stack/index.tsx @@ -4,11 +4,14 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; import {StyleProp, Text, TouchableOpacity, View, ViewStyle} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; import FormattedText from '@components/formatted_text'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; +import {TITLE_HEIGHT} from '@screens/bottom_sheet/content'; import {bottomSheet} from '@screens/navigation'; +import {bottomSheetSnapPoint} from '@utils/helpers'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -19,6 +22,7 @@ import UsersList from './users_list'; import type UserModel from '@typings/database/models/servers/user'; const OVERFLOW_DISPLAY_LIMIT = 99; +const USER_ROW_HEIGHT = 40; type Props = { channelId: string; @@ -88,9 +92,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { marginBottom: 12, }, listHeaderText: { - color: changeOpacity(theme.centerChannelColor, 0.56), - ...typography('Body', 75, 'SemiBold'), - textTransform: 'uppercase', + color: theme.centerChannelColor, + ...typography('Heading', 600, 'SemiBold'), }, }; }); @@ -99,6 +102,7 @@ const UserAvatarsStack = ({breakAt = 3, channelId, location, style: baseContaine const theme = useTheme(); const intl = useIntl(); const isTablet = useIsTablet(); + const {bottom} = useSafeAreaInsets(); const showParticipantsList = useCallback(preventDoubleTap(() => { const renderContent = () => ( @@ -119,15 +123,16 @@ const UserAvatarsStack = ({breakAt = 3, channelId, location, style: baseContaine /> ); + const snap = bottomSheetSnapPoint(Math.min(users.length, 5), USER_ROW_HEIGHT, bottom); bottomSheet({ closeButtonId: 'close-set-user-status', renderContent, initialSnapIndex: 1, - snapPoints: ['90%', '50%', 10], + snapPoints: ['90%', TITLE_HEIGHT + snap, 10], title: intl.formatMessage({id: 'mobile.participants.header', defaultMessage: 'Thread Participants'}), theme, }); - }), [isTablet, theme, users, channelId, location]); + }), [isTablet, theme, users, channelId, location, bottom]); const displayUsers = users.slice(0, breakAt); const overflowUsersCount = Math.min(users.length - displayUsers.length, OVERFLOW_DISPLAY_LIMIT); diff --git a/app/components/user_item/user_item.tsx b/app/components/user_item/user_item.tsx index 3b27bfe420..44dc3d5651 100644 --- a/app/components/user_item/user_item.tsx +++ b/app/components/user_item/user_item.tsx @@ -25,6 +25,7 @@ type AtMentionItemProps = { showFullName: boolean; testID?: string; isCustomStatusEnabled: boolean; + pictureContainerStyle?: StyleProp; } const getName = (user: UserProfile | UserModel | undefined, showFullName: boolean, isCurrentUser: boolean, intl: IntlShape) => { @@ -94,6 +95,7 @@ const UserItem = ({ showFullName, testID, isCustomStatusEnabled, + pictureContainerStyle, }: AtMentionItemProps) => { const theme = useTheme(); const style = getStyleFromTheme(theme); @@ -124,7 +126,7 @@ const UserItem = ({ style={[style.row, containerStyle]} testID={userItemTestId} > - + c > 0), - distinctUntilChanged(), - ); + observeHasChannels = (canViewArchived: boolean, channelId: string) => { + return this.channels.observeWithColumns(['delete_at']).pipe( + map((channels) => { + if (canViewArchived) { + return channels.filter((c) => c.deleteAt === 0 || c.id === channelId).length > 0; + } + + return channels.filter((c) => c.deleteAt === 0).length > 0; + }), + distinctUntilChanged(), + ); + }; toCategoryWithChannels = async (): Promise => { const categoryChannels = await this.categoryChannels.fetch(); diff --git a/app/database/operator/server_data_operator/transformers/user.ts b/app/database/operator/server_data_operator/transformers/user.ts index 420abc34f2..c98afae1cd 100644 --- a/app/database/operator/server_data_operator/transformers/user.ts +++ b/app/database/operator/server_data_operator/transformers/user.ts @@ -42,8 +42,8 @@ export const transformUserRecord = ({action, database, value}: TransformerArgs): user.timezone = raw.timezone || null; user.isBot = raw.is_bot ?? false; user.remoteId = raw?.remote_id ?? null; - user.termsOfServiceId = raw?.terms_of_service_id ?? ''; - user.termsOfServiceCreateAt = raw?.terms_of_service_create_at ?? 0; + user.termsOfServiceId = raw?.terms_of_service_id ?? (record?.termsOfServiceId || ''); + user.termsOfServiceCreateAt = raw?.terms_of_service_create_at ?? (record?.termsOfServiceCreateAt || 0); if (raw.status) { user.status = raw.status; } diff --git a/app/hooks/fetching_thread.ts b/app/hooks/fetching_thread.ts new file mode 100644 index 0000000000..60fa44bd19 --- /dev/null +++ b/app/hooks/fetching_thread.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useState} from 'react'; +import {of as of$} from 'rxjs'; +import {distinctUntilChanged, switchMap} from 'rxjs/operators'; + +import {subject} from '@store/fetching_thread_store'; + +export const useFetchingThreadState = (rootId: string) => { + const [isFetching, setIsFetching] = useState(false); + useEffect(() => { + const sub = subject.pipe( + switchMap((s) => of$(s[rootId] || false)), + distinctUntilChanged(), + ).subscribe(setIsFetching); + + return () => sub.unsubscribe(); + }, []); + + return isFetching; +}; diff --git a/app/hooks/keyboard_tracking.ts b/app/hooks/keyboard_tracking.ts index ad1b8670c1..95da896846 100644 --- a/app/hooks/keyboard_tracking.ts +++ b/app/hooks/keyboard_tracking.ts @@ -24,10 +24,13 @@ export const useKeyboardTrackingPaused = (keyboardTrackingRef: RefObject { - if (!isPostDraftPaused.current) { - isPostDraftPaused.current = true; - keyboardTrackingRef.current?.pauseTracking(trackerId); - } + setTimeout(() => { + const visibleScreen = NavigationStore.getVisibleScreen(); + if (!isPostDraftPaused.current && !screens.includes(visibleScreen)) { + isPostDraftPaused.current = true; + keyboardTrackingRef.current?.pauseTracking(trackerId); + } + }); }); const commandCompletedListener = Navigation.events().registerCommandCompletedListener(() => { diff --git a/app/hooks/teams_loading.ts b/app/hooks/teams_loading.ts new file mode 100644 index 0000000000..661282bd8a --- /dev/null +++ b/app/hooks/teams_loading.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useState} from 'react'; +import {of as of$} from 'rxjs'; +import {switchMap, distinctUntilChanged} from 'rxjs/operators'; + +import {getLoadingTeamChannelsSubject} from '@store/team_load_store'; + +export const useTeamsLoading = (serverUrl: string) => { + // const subject = getLoadingTeamChannelsSubject(serverUrl); + // const [loading, setLoading] = useState(subject.getValue() !== 0); + const [loading, setLoading] = useState(false); + useEffect(() => { + const sub = getLoadingTeamChannelsSubject(serverUrl).pipe( + switchMap((v) => of$(v !== 0)), + distinctUntilChanged(), + ).subscribe(setLoading); + + return () => sub.unsubscribe(); + }, []); + + return loading; +}; diff --git a/app/init/launch.ts b/app/init/launch.ts index 885705374c..fe8484b990 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -7,20 +7,20 @@ import {Notifications} from 'react-native-notifications'; import {appEntry, pushNotificationEntry, upgradeEntry} from '@actions/remote/entry'; import LocalConfig from '@assets/config.json'; -import {Screens, DeepLink, Events, Launch, PushNotification} from '@constants'; +import {DeepLink, Events, Launch, PushNotification} from '@constants'; import DatabaseManager from '@database/manager'; import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials'; import {getOnboardingViewed} from '@queries/app/global'; import {getThemeForCurrentTeam} from '@queries/servers/preference'; import {getCurrentUserId} from '@queries/servers/system'; import {queryMyTeams} from '@queries/servers/team'; -import {goToScreen, resetToHome, resetToSelectServer, resetToTeams, resetToOnboarding} from '@screens/navigation'; +import {resetToHome, resetToSelectServer, resetToTeams, resetToOnboarding} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; +import {getLaunchPropsFromDeepLink} from '@utils/deep_link'; import {logInfo} from '@utils/log'; import {convertToNotificationData} from '@utils/notification'; -import {parseDeepLink} from '@utils/url'; -import type {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkWithData, LaunchProps} from '@typings/launch'; +import type {DeepLinkWithData, LaunchProps} from '@typings/launch'; const initialNotificationTypes = [PushNotification.NOTIFICATION_TYPE.MESSAGE, PushNotification.NOTIFICATION_TYPE.SESSION]; @@ -67,13 +67,18 @@ const launchAppFromNotification = async (notification: NotificationWithData, col * @returns a redirection to a screen, either onboarding, add_server, login or home depending on the scenario */ -const launchApp = async (props: LaunchProps, resetNavigation = true) => { +const launchApp = async (props: LaunchProps) => { let serverUrl: string | undefined; switch (props?.launchType) { case Launch.DeepLink: if (props.extra?.type !== DeepLink.Invalid) { const extra = props.extra as DeepLinkWithData; - serverUrl = extra.data?.serverUrl; + const existingServer = DatabaseManager.searchUrl(extra.data!.serverUrl); + serverUrl = existingServer; + props.serverUrl = serverUrl || extra.data?.serverUrl; + if (!serverUrl) { + props.launchError = true; + } } break; case Launch.Notification: { @@ -142,17 +147,17 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => { return resetToOnboarding(props); } - return launchToServer(props, resetNavigation); + return resetToSelectServer(props); }; const launchToHome = async (props: LaunchProps) => { let openPushNotification = false; switch (props.launchType) { - case Launch.DeepLink: - // TODO: - // deepLinkEntry({props.serverUrl, props.extra}); + case Launch.DeepLink: { + appEntry(props.serverUrl!); break; + } case Launch.Notification: { const extra = props.extra as NotificationWithData; openPushNotification = Boolean(props.serverUrl && !props.launchError && extra.userInteraction && extra.payload?.channel_id && !extra.payload?.userInfo?.local); @@ -185,55 +190,8 @@ const launchToHome = async (props: LaunchProps) => { return resetToTeams(); }; -const launchToServer = (props: LaunchProps, resetNavigation: Boolean) => { - if (resetNavigation) { - return resetToSelectServer(props); - } - - // This is being called for Deeplinks, but needs to be revisited when - // the implementation of deep links is complete - const title = ''; - return goToScreen(Screens.SERVER, title, {...props}); -}; - -export const relaunchApp = (props: LaunchProps, resetNavigation = false) => { - return launchApp(props, resetNavigation); -}; - -export const getLaunchPropsFromDeepLink = (deepLinkUrl: string, coldStart = false): LaunchProps => { - const parsed = parseDeepLink(deepLinkUrl); - const launchProps: LaunchProps = { - launchType: Launch.DeepLink, - coldStart, - }; - - switch (parsed.type) { - case DeepLink.Invalid: - launchProps.launchError = true; - break; - case DeepLink.Channel: { - const parsedData = parsed.data as DeepLinkChannel; - (launchProps.extra as DeepLinkWithData).data = parsedData; - break; - } - case DeepLink.DirectMessage: { - const parsedData = parsed.data as DeepLinkDM; - (launchProps.extra as DeepLinkWithData).data = parsedData; - break; - } - case DeepLink.GroupMessage: { - const parsedData = parsed.data as DeepLinkGM; - (launchProps.extra as DeepLinkWithData).data = parsedData; - break; - } - case DeepLink.Permalink: { - const parsedData = parsed.data as DeepLinkPermalink; - (launchProps.extra as DeepLinkWithData).data = parsedData; - break; - } - } - - return launchProps; +export const relaunchApp = (props: LaunchProps) => { + return launchApp(props); }; export const getLaunchPropsFromNotification = async (notification: NotificationWithData, coldStart = false): Promise => { diff --git a/app/init/push_notifications.ts b/app/init/push_notifications.ts index ec78be8f7e..14aac23155 100644 --- a/app/init/push_notifications.ts +++ b/app/init/push_notifications.ts @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. import {AppState, DeviceEventEmitter, Platform} from 'react-native'; -import DeviceInfo from 'react-native-device-info'; import { Notification, NotificationAction, @@ -29,6 +28,7 @@ import {getIsCRTEnabled, getThreadById} from '@queries/servers/thread'; import {dismissOverlay, showOverlay} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; +import {isBetaApp} from '@utils/general'; import {isMainActivity, isTablet} from '@utils/helpers'; import {logInfo} from '@utils/log'; import {convertToNotificationData} from '@utils/notification'; @@ -248,7 +248,7 @@ class PushNotifications { if (Platform.OS === 'ios') { prefix = Device.PUSH_NOTIFY_APPLE_REACT_NATIVE; - if (DeviceInfo.getBundleId().includes('rnbeta')) { + if (isBetaApp) { prefix = `${prefix}beta`; } } else { diff --git a/app/managers/global_event_handler.ts b/app/managers/global_event_handler.ts index 7c0f75af4f..30f1f78b2a 100644 --- a/app/managers/global_event_handler.ts +++ b/app/managers/global_event_handler.ts @@ -11,9 +11,9 @@ import {Events, Sso} from '@constants'; import {MIN_REQUIRED_VERSION} from '@constants/supported_server'; import {DEFAULT_LOCALE, getTranslations, t} from '@i18n'; import {getServerCredentials} from '@init/credentials'; -import {getLaunchPropsFromDeepLink, relaunchApp} from '@init/launch'; import * as analytics from '@managers/analytics'; import {getAllServers} from '@queries/app/servers'; +import {handleDeepLink} from '@utils/deep_link'; import {logError} from '@utils/log'; import type {jsAndNativeErrorHandler} from '@typings/global/error_handling'; @@ -64,8 +64,7 @@ class GlobalEventHandler { } if (event.url) { - const props = getLaunchPropsFromDeepLink(event.url); - relaunchApp(props); + handleDeepLink(event.url); } }; diff --git a/app/managers/session_manager.ts b/app/managers/session_manager.ts index 542be18001..2b3627173c 100644 --- a/app/managers/session_manager.ts +++ b/app/managers/session_manager.ts @@ -185,7 +185,7 @@ class SessionManager { await storeOnboardingViewedValue(false); } - relaunchApp({launchType, serverUrl, displayName}, true); + relaunchApp({launchType, serverUrl, displayName}); } }; @@ -197,7 +197,7 @@ class SessionManager { const activeServerUrl = await DatabaseManager.getActiveServerUrl(); const serverDisplayName = await getServerDisplayName(serverUrl); - await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}, true); + await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}); if (activeServerUrl) { addNewServer(getThemeFromState(), serverUrl, serverDisplayName); } else { diff --git a/app/managers/websocket_manager.ts b/app/managers/websocket_manager.ts index 809ad34444..1e44a3da2c 100644 --- a/app/managers/websocket_manager.ts +++ b/app/managers/websocket_manager.ts @@ -21,10 +21,10 @@ import {isMainActivity} from '@utils/helpers'; import {logError} from '@utils/log'; const WAIT_TO_CLOSE = toMilliseconds({seconds: 15}); -const WAIT_UNTIL_NEXT = toMilliseconds({seconds: 20}); +const WAIT_UNTIL_NEXT = toMilliseconds({seconds: 5}); class WebsocketManager { - private connectedSubjects: {[serverUrl: string]: BehaviorSubject} = {}; + private connectedSubjects: {[serverUrl: string]: BehaviorSubject} = {}; private clients: Record = {}; private connectionTimerIDs: Record void>> = {}; @@ -69,7 +69,7 @@ class WebsocketManager { } delete this.clients[serverUrl]; - this.getConnectedSubject(serverUrl).next(false); + this.getConnectedSubject(serverUrl).next('not_connected'); delete this.connectedSubjects[serverUrl]; }; @@ -96,7 +96,7 @@ class WebsocketManager { const client = this.clients[url]; if (client.isConnected()) { client.close(true); - this.getConnectedSubject(url).next(false); + this.getConnectedSubject(url).next('not_connected'); } } }; @@ -107,6 +107,7 @@ class WebsocketManager { if (clientUrl === activeServerUrl) { this.initializeClient(clientUrl); } else { + this.getConnectedSubject(clientUrl).next('connecting'); const bounce = debounce(this.initializeClient.bind(this, clientUrl), WAIT_UNTIL_NEXT); this.connectionTimerIDs[clientUrl] = bounce; bounce(); @@ -118,7 +119,7 @@ class WebsocketManager { return this.clients[serverUrl]?.isConnected(); }; - public observeConnected = (serverUrl: string) => { + public observeWebsocketState = (serverUrl: string) => { return this.getConnectedSubject(serverUrl).asObservable().pipe( distinctUntilChanged(), ); @@ -126,7 +127,7 @@ class WebsocketManager { private getConnectedSubject = (serverUrl: string) => { if (!this.connectedSubjects[serverUrl]) { - this.connectedSubjects[serverUrl] = new BehaviorSubject(this.isConnected(serverUrl)); + this.connectedSubjects[serverUrl] = new BehaviorSubject(this.isConnected(serverUrl) ? 'connected' : 'not_connected'); } return this.connectedSubjects[serverUrl]; @@ -153,13 +154,13 @@ class WebsocketManager { private onFirstConnect = (serverUrl: string) => { this.startPeriodicStatusUpdates(serverUrl); handleFirstConnect(serverUrl); - this.getConnectedSubject(serverUrl).next(true); + this.getConnectedSubject(serverUrl).next('connected'); }; - private onReconnect = (serverUrl: string) => { + private onReconnect = async (serverUrl: string) => { this.startPeriodicStatusUpdates(serverUrl); - handleReconnect(serverUrl); - this.getConnectedSubject(serverUrl).next(true); + await handleReconnect(serverUrl); + this.getConnectedSubject(serverUrl).next('connected'); }; private onWebsocketClose = async (serverUrl: string, connectFailCount: number, lastDisconnect: number) => { @@ -168,7 +169,7 @@ class WebsocketManager { await handleClose(serverUrl, lastDisconnect); this.stopPeriodicStatusUpdates(serverUrl); - this.getConnectedSubject(serverUrl).next(false); + this.getConnectedSubject(serverUrl).next('not_connected'); } }; diff --git a/app/products/calls/components/channel_info_start/channel_info_start_button.tsx b/app/products/calls/components/channel_info_start/channel_info_start_button.tsx index 9f28574562..6cdeb09c83 100644 --- a/app/products/calls/components/channel_info_start/channel_info_start_button.tsx +++ b/app/products/calls/components/channel_info_start/channel_info_start_button.tsx @@ -69,7 +69,7 @@ const ChannelInfoStartButton = ({ destructiveText={leaveText} destructiveIconName={'phone-hangup'} isDestructive={alreadyInCall} - testID='channel_info.options.join_start_call.option' + testID='channel_info.channel_actions.join_start_call.action' /> ); }; diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index ad433ed5f4..bba482a043 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -273,7 +273,7 @@ const CallScreen = ({ const callThreadOptionTitle = intl.formatMessage({id: 'mobile.calls_call_thread', defaultMessage: 'Call Thread'}); const recordOptionTitle = intl.formatMessage({id: 'mobile.calls_record', defaultMessage: 'Record'}); const stopRecordingOptionTitle = intl.formatMessage({id: 'mobile.calls_stop_recording', defaultMessage: 'Stop Recording'}); - const openChannelOptionTitle = intl.formatMessage({id: 'mobile.calls_call_thread', defaultMessage: 'Open Channel'}); + const openChannelOptionTitle = intl.formatMessage({id: 'mobile.calls_open_channel', defaultMessage: 'Open Channel'}); useEffect(() => { mergeNavigationOptions('Call', { diff --git a/app/queries/servers/categories.ts b/app/queries/servers/categories.ts index 2210143ba9..7d80a86117 100644 --- a/app/queries/servers/categories.ts +++ b/app/queries/servers/categories.ts @@ -9,6 +9,7 @@ import {FAVORITES_CATEGORY} from '@constants/categories'; import {MM_TABLES} from '@constants/database'; import {makeCategoryChannelId} from '@utils/categories'; import {pluckUnique} from '@utils/helpers'; +import {logDebug} from '@utils/log'; import {observeChannelsByLastPostAt} from './channel'; @@ -38,16 +39,10 @@ export const queryCategoriesByTeamIds = (database: Database, teamIds: string[]) export async function prepareCategoriesAndCategoriesChannels(operator: ServerDataOperator, categories: CategoryWithChannels[], prune = false) { try { - const modelPromises: Array> = []; - const preparedCategories = prepareCategories(operator, categories); - if (preparedCategories) { - modelPromises.push(preparedCategories); - } - - const preparedCategoryChannels = prepareCategoryChannels(operator, categories); - if (preparedCategoryChannels) { - modelPromises.push(preparedCategoryChannels); - } + const modelPromises: Array> = [ + prepareCategories(operator, categories), + prepareCategoryChannels(operator, categories), + ]; const models = await Promise.all(modelPromises); const flattenedModels = models.flat(); @@ -71,7 +66,8 @@ export async function prepareCategoriesAndCategoriesChannels(operator: ServerDat } return flattenedModels; - } catch { + } catch (error) { + logDebug('error while preparing categories and categories channels', error); return []; } } diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index aae4ed41e2..20de391292 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -209,6 +209,7 @@ export const queryPinnedPostsInChannel = (database: Database, channelId: string) Q.where('channel_id', channelId), Q.where('is_pinned', Q.eq(true)), ), + Q.sortBy('create_at', Q.asc), ); }; diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index 2780a9e276..5306d7e1f2 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -404,7 +404,13 @@ export async function setCurrentTeamAndChannelId(operator: ServerDataOperator, t export const observeLastUnreadChannelId = (database: Database): Observable => { return querySystemValue(database, SYSTEM_IDENTIFIERS.LAST_UNREAD_CHANNEL_ID).observe().pipe( switchMap((result) => (result.length ? result[0].observe() : of$({value: ''}))), - switchMap((model) => of$(model.value)), + switchMap((model) => { + if (model.value) { + return of$(model.value); + } + + return observeCurrentChannelId(database); + }), ); }; diff --git a/app/queries/servers/team.ts b/app/queries/servers/team.ts index 4f00b776b0..a148c3c597 100644 --- a/app/queries/servers/team.ts +++ b/app/queries/servers/team.ts @@ -165,31 +165,6 @@ export const getLastTeam = async (database: Database, ignoreIdForDefault?: strin return getDefaultTeamId(database, ignoreIdForDefault); }; -export async function syncTeamTable(operator: ServerDataOperator, teams: Team[]) { - try { - const deletedTeams = teams.filter((t) => t.delete_at > 0).map((t) => t.id); - const deletedSet = new Set(deletedTeams); - const availableTeams = teams.filter((a) => !deletedSet.has(a.id)); - const models = []; - - if (deletedTeams.length) { - const notAvailable = await operator.database.get(TEAM).query(Q.where('id', Q.oneOf(deletedTeams))).fetch(); - const deletions = await Promise.all(notAvailable.map((t) => prepareDeleteTeam(t))); - for (const d of deletions) { - models.push(...d); - } - } - - models.push(...await operator.handleTeam({teams: availableTeams, prepareRecordsOnly: true})); - if (models.length) { - await operator.batchRecords(models); - } - return {}; - } catch (error) { - return {error}; - } -} - export const getDefaultTeamId = async (database: Database, ignoreId?: string) => { const user = await getCurrentUser(database); const config = await getConfig(database); diff --git a/app/queries/servers/terms_of_service.ts b/app/queries/servers/terms_of_service.ts new file mode 100644 index 0000000000..820ad88a5e --- /dev/null +++ b/app/queries/servers/terms_of_service.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; +import {of as of$, combineLatest} from 'rxjs'; +import {switchMap, distinctUntilChanged} from 'rxjs/operators'; + +import {observeLicense, observeConfigBooleanValue, observeConfigValue, observeConfigIntValue} from './system'; +import {observeCurrentUser} from './user'; + +export const observeShowToS = (database: Database) => { + const isLicensed = observeLicense(database).pipe( + switchMap((lcs) => (lcs ? of$(lcs.IsLicensed === 'true') : of$(false))), + ); + const currentUser = observeCurrentUser(database); + const customTermsOfServiceEnabled = observeConfigBooleanValue(database, 'EnableCustomTermsOfService'); + const customTermsOfServiceId = observeConfigValue(database, 'CustomTermsOfServiceId'); + const customTermsOfServicePeriod = observeConfigIntValue(database, 'CustomTermsOfServiceReAcceptancePeriod'); + + const showToS = combineLatest([ + isLicensed, + customTermsOfServiceEnabled, + currentUser, + customTermsOfServiceId, + customTermsOfServicePeriod, + ]).pipe( + switchMap(([lcs, cfg, user, id, period]) => { + if (!lcs || !cfg) { + return of$(false); + } + + if (user?.termsOfServiceId !== id) { + return of$(true); + } + + const timeElapsed = Date.now() - (user?.termsOfServiceCreateAt || 0); + return of$(timeElapsed > (period * 24 * 60 * 60 * 1000)); + }), + distinctUntilChanged(), + ); + + return showToS; +}; diff --git a/app/queries/servers/thread.ts b/app/queries/servers/thread.ts index 4ee78d7dbd..4a200ad8ef 100644 --- a/app/queries/servers/thread.ts +++ b/app/queries/servers/thread.ts @@ -141,7 +141,10 @@ export const prepareThreadsFromReceivedPosts = async (operator: ServerDataOperat }; export const queryThreadsInTeam = (database: Database, teamId: string, onlyUnreads?: boolean, hasReplies?: boolean, isFollowing?: boolean, sort?: boolean, earliest?: number): Query => { - const query: Q.Clause[] = []; + const query: Q.Clause[] = [ + Q.experimentalNestedJoin(POST, CHANNEL), + Q.on(POST, Q.on(CHANNEL, Q.where('delete_at', 0))), + ]; if (isFollowing) { query.push(Q.where('is_following', true)); @@ -189,30 +192,34 @@ export const queryThreads = (database: Database, teamId?: string, onlyUnreads = Q.where('reply_count', Q.gt(0)), ]; + // Only get threads from available channel + const channelCondition: Q.Condition[] = [ + Q.where('delete_at', 0), + ]; + // If teamId is specified, only get threads in that team if (teamId) { - let condition: Q.Condition = Q.where('team_id', teamId); - if (includeDmGm) { - condition = Q.or( - Q.where('team_id', teamId), - Q.where('team_id', ''), + channelCondition.push( + Q.or( + Q.where('team_id', teamId), + Q.where('team_id', ''), + ), ); + } else { + channelCondition.push(Q.where('team_id', teamId)); } - - query.push( - Q.experimentalNestedJoin(POST, CHANNEL), - Q.on(POST, Q.on(CHANNEL, condition)), - ); } else if (!includeDmGm) { // fetching all threads from all teams // excluding DM/GM channels - query.push( - Q.experimentalNestedJoin(POST, CHANNEL), - Q.on(POST, Q.on(CHANNEL, Q.where('team_id', Q.notEq('')))), - ); + channelCondition.push(Q.where('team_id', Q.notEq(''))); } + query.push( + Q.experimentalNestedJoin(POST, CHANNEL), + Q.on(POST, Q.on(CHANNEL, Q.and(...channelCondition))), + ); + if (onlyUnreads) { query.push(Q.where('unread_replies', Q.gt(0))); } diff --git a/app/queries/servers/user.ts b/app/queries/servers/user.ts index 3279ec1177..f8ff40b2e9 100644 --- a/app/queries/servers/user.ts +++ b/app/queries/servers/user.ts @@ -2,14 +2,12 @@ // See LICENSE.txt for license information. import {Database, Q} from '@nozbe/watermelondb'; -import {combineLatest, Observable, of as of$} from 'rxjs'; +import {combineLatest, of as of$} from 'rxjs'; import {distinctUntilChanged, switchMap} from 'rxjs/operators'; import {Preferences} from '@constants'; import {MM_TABLES} from '@constants/database'; import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; -import {observeMyChannel} from '@queries/servers/channel'; -import {isChannelAdmin} from '@utils/user'; import {queryPreferencesByCategoryAndName} from './preference'; import {observeCurrentUserId, observeLicense, getCurrentUserId, getConfig, getLicense, observeConfigValue} from './system'; @@ -106,24 +104,17 @@ export const observeUserIsTeamAdmin = (database: Database, userId: string, teamI const id = `${teamId}-${userId}`; return database.get(TEAM_MEMBERSHIP).query( Q.where('id', Q.eq(id)), - ).observe().pipe( + ).observeWithColumns(['scheme_admin']).pipe( switchMap((tm) => of$(tm.length ? tm[0].schemeAdmin : false)), ); }; export const observeUserIsChannelAdmin = (database: Database, userId: string, channelId: string) => { const id = `${channelId}-${userId}`; - const myChannelRoles = observeMyChannel(database, channelId).pipe( - switchMap((mc) => of$(mc?.roles || '')), - distinctUntilChanged(), - ); - const channelSchemeAdmin = database.get(CHANNEL_MEMBERSHIP).query( + return database.get(CHANNEL_MEMBERSHIP).query( Q.where('id', Q.eq(id)), - ).observe().pipe( + ).observeWithColumns(['scheme_admin']).pipe( switchMap((cm) => of$(cm.length ? cm[0].schemeAdmin : false)), distinctUntilChanged(), ); - return combineLatest([myChannelRoles, channelSchemeAdmin]).pipe( - switchMap(([mcr, csa]) => of$(isChannelAdmin(mcr) || csa)), - ) as Observable; }; diff --git a/app/screens/bottom_sheet/button.tsx b/app/screens/bottom_sheet/button.tsx index a05c54b284..6af564b8d5 100644 --- a/app/screens/bottom_sheet/button.tsx +++ b/app/screens/bottom_sheet/button.tsx @@ -26,8 +26,8 @@ const styles = StyleSheet.create({ icon_container: { width: 24, height: 24, - marginTop: 2, - marginRight: 8, + top: -1, + marginRight: 4, }, }); diff --git a/app/screens/bottom_sheet/content.tsx b/app/screens/bottom_sheet/content.tsx index bfdd56f9e2..8cd23947ea 100644 --- a/app/screens/bottom_sheet/content.tsx +++ b/app/screens/bottom_sheet/content.tsx @@ -45,9 +45,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, separator: { height: 1, - right: 16, + right: 20, borderTopWidth: 1, borderColor: changeOpacity(theme.centerChannelColor, 0.08), + marginBottom: 20, }, }; }); @@ -83,7 +84,7 @@ const BottomSheetContent = ({buttonText, buttonIcon, children, disableButton, on {showButton && ( <> - +