From 790b1beb22931de6eaf71805dd16881ca6a42652 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Wed, 27 Oct 2021 17:53:11 -0300 Subject: [PATCH] [Gekidou] push notifications (#5779) * Push notifications entry point * Process android notification natively only if RN is not initialized * Database changes to store local channel viewed_at * EphemeralStore wait until screen removed * Move schedule session notification to utility * Fix channel remote & local actions + added actions for markChannelAsViewed & fetchMyChannel * Add fetchMyTeam remote action * Add dismissAllModalsAndPopToScreen to navigation * Improve post list component & add app state to re-trigger queries * Improve WS implementation * Handle push notification events * Fix postsInChannel since handler * Handle in-app notifications * Post list to listen to column changes * Track selected bottom tab in ephemeral store * add useIsTablet hook * in-app notifications on tablets --- .../com/mattermost/helpers/DatabaseHelper.kt | 25 +- .../rnbeta/CustomPushNotification.java | 26 +- app/actions/local/channel.ts | 268 ++----- app/actions/local/notification.ts | 35 + app/actions/local/push_notification.ts | 58 -- app/actions/local/team.ts | 17 +- app/actions/remote/channel.ts | 274 +++++-- app/actions/remote/entry.ts | 415 ----------- app/actions/remote/entry/app.ts | 100 +++ app/actions/remote/entry/common.ts | 189 +++++ app/actions/remote/entry/index.ts | 6 + app/actions/remote/entry/login.ts | 178 +++++ app/actions/remote/entry/notification.ts | 144 ++++ app/actions/remote/notifications.ts | 139 ++++ app/actions/remote/session.ts | 6 +- app/actions/remote/team.ts | 38 + app/actions/websocket/index.ts | 41 +- app/client/rest/base.ts | 7 + app/client/websocket/index.ts | 15 +- .../markdown/markdown_link/index.tsx | 2 +- .../post_list/date_separator/index.tsx | 2 +- app/components/post_list/index.tsx | 22 +- .../team_sidebar/team_list/team_list.tsx | 4 +- app/constants/database.ts | 1 - app/constants/events.ts | 3 +- app/constants/navigation.ts | 1 + app/constants/screens.ts | 2 + app/database/components/index.tsx | 24 +- app/database/manager/__mocks__/index.ts | 73 +- app/database/manager/index.ts | 697 ++++++++++-------- app/database/manager/test.ts | 14 +- app/database/manager/test_manual.ts | 1 + app/database/models/app/servers.ts | 10 +- app/database/models/server/channel.ts | 24 +- app/database/models/server/my_channel.ts | 3 + .../transformers/channel.ts | 1 + app/database/operator/utils/post.ts | 2 +- app/database/schema/app/index.ts | 4 +- .../schema/app/table_schemas/servers.ts | 4 +- app/database/schema/app/test.ts | 12 +- .../schema/server/table_schemas/my_channel.ts | 1 + app/database/schema/server/test.ts | 2 + app/hooks/device.ts | 21 +- app/init/global_event_handler.ts | 11 +- app/init/launch.ts | 74 +- app/init/push_notifications.ts | 396 +++++----- app/init/websocket_manager.ts | 15 +- app/notifications/index.ios.ts | 15 - app/notifications/index.ts | 39 +- app/notifications/notifications.android.ts | 40 + app/notifications/notifications.ios.ts | 16 + app/queries/app/global.ts | 9 - app/queries/app/servers.ts | 34 +- app/queries/servers/channel.ts | 8 +- app/queries/servers/system.ts | 12 +- app/screens/channel/index.tsx | 5 +- .../components/clear_after_menu_item.tsx | 5 +- .../components/date_time_selector.tsx | 7 +- app/screens/home/index.tsx | 16 +- app/screens/home/tab_bar/index.tsx | 12 + app/screens/in_app_notification/icon.tsx | 106 +++ app/screens/in_app_notification/index.tsx | 188 +++++ app/screens/in_app_notification/server.tsx | 38 + app/screens/in_app_notification/title.tsx | 32 + app/screens/index.tsx | 8 + app/screens/navigation.ts | 33 + app/screens/server/index.tsx | 2 +- app/screens/sso/index.tsx | 2 +- app/store/ephemeral_store.ts | 89 ++- app/utils/helpers.ts | 2 +- app/utils/notification/index.ts | 100 +++ app/utils/post_list/index.ts | 10 +- assets/base/i18n/en.json | 3 + assets/base/images/icon.png | Bin 8285 -> 3465 bytes index.ts | 31 +- .../Gekidou/Networking/PushNotification.swift | 72 +- .../Gekidou/Storage/Database+Posts.swift | 27 +- .../Sources/Gekidou/Storage/Database.swift | 25 +- ios/Mattermost/AppDelegate.m | 18 +- .../NotificationService.swift | 2 +- package-lock.json | 4 +- patches/@nozbe+watermelondb+0.23.0.patch | 29 +- types/database/database.d.ts | 8 +- types/database/models/app/servers.d.ts | 10 +- types/database/models/servers/channel.d.ts | 2 + types/database/models/servers/my_channel.d.ts | 3 + types/global/push_notifications.d.ts | 4 + 87 files changed, 2923 insertions(+), 1550 deletions(-) create mode 100644 app/actions/local/notification.ts delete mode 100644 app/actions/local/push_notification.ts delete mode 100644 app/actions/remote/entry.ts create mode 100644 app/actions/remote/entry/app.ts create mode 100644 app/actions/remote/entry/common.ts create mode 100644 app/actions/remote/entry/index.ts create mode 100644 app/actions/remote/entry/login.ts create mode 100644 app/actions/remote/entry/notification.ts create mode 100644 app/actions/remote/notifications.ts delete mode 100644 app/notifications/index.ios.ts create mode 100644 app/notifications/notifications.android.ts create mode 100644 app/notifications/notifications.ios.ts create mode 100644 app/screens/in_app_notification/icon.tsx create mode 100644 app/screens/in_app_notification/index.tsx create mode 100644 app/screens/in_app_notification/server.tsx create mode 100644 app/screens/in_app_notification/title.tsx create mode 100644 app/utils/notification/index.ts diff --git a/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt b/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt index 7d5564eda..2c9dd142b 100644 --- a/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt +++ b/android/app/src/main/java/com/mattermost/helpers/DatabaseHelper.kt @@ -10,6 +10,7 @@ import com.facebook.react.bridge.ReadableMap import com.nozbe.watermelondb.Database import com.nozbe.watermelondb.mapCursor import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject import java.lang.Exception import java.util.* @@ -20,7 +21,7 @@ class DatabaseHelper { val onlyServerUrl: String? get() { - val query = "SELECT url FROM Servers WHERE last_active_at != 0" + val query = "SELECT url FROM Servers WHERE last_active_at != 0 AND identifier != ''" val cursor = defaultDatabase!!.rawQuery(query) if (cursor.count == 1) { cursor.moveToFirst() @@ -37,6 +38,19 @@ class DatabaseHelper { } } + fun getServerUrlForIdentifier(identifier: String): String? { + val args: Array = arrayOf(identifier) + val query = "SELECT url FROM Servers WHERE identifier=?" + val cursor = defaultDatabase!!.rawQuery(query, args) + if (cursor.count == 1) { + cursor.moveToFirst() + val url = cursor.getString(0) + cursor.close() + return url + } + return null + } + fun find(db: Database, tableName: String, id: String?): ReadableMap? { val args: Array = arrayOf(id) try { @@ -363,19 +377,22 @@ class DatabaseHelper { private fun insertFiles(db: Database, files: JSONArray) { for (i in 0 until files.length()) { val file = files.getJSONObject(i) + val miniPreview = try { file.getString("mini_preview") } catch (e: JSONException) { "" }; + val height = try { file.getInt("height") } catch (e: JSONException) { 0 }; + val width = try { file.getInt("width") } catch (e: JSONException) { 0 }; db.execute( "insert into File (id, extension, height, image_thumbnail, local_path, mime_type, name, post_id, size, width, _status) " + "values (?, ?, ?, ?, '', ?, ?, ?, ?, ?, 'created')", arrayOf( file.getString("id"), file.getString("extension"), - file.getInt("height"), - file.getString("mini_preview"), + height, + miniPreview, file.getString("mime_type"), file.getString("name"), file.getString("post_id"), file.getDouble("size"), - file.getInt("width") + width ) ) } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java index 536de78f3..4fb068981 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java @@ -9,6 +9,7 @@ import android.content.pm.PackageManager; import android.content.SharedPreferences; import android.os.Bundle; import android.service.notification.StatusBarNotification; +import android.text.TextUtils; import android.util.Log; import androidx.annotation.Nullable; @@ -149,6 +150,7 @@ public class CustomPushNotification extends PushNotification { } String serverUrl = addServerUrlToBundle(initialData); + boolean isReactInit = mAppLifecycleFacade.isReactInitialized(); if (ackId != null && serverUrl != null) { notificationReceiptDelivery(ackId, serverUrl, postId, type, isIdLoaded, new ResolvePromise() { @@ -157,7 +159,8 @@ public class CustomPushNotification extends PushNotification { if (isIdLoaded) { Bundle response = (Bundle) value; if (value != null) { - addServerUrlToBundle(response); + response.putString("server_url", serverUrl); + mNotificationProps = createProps(response); } } } @@ -176,7 +179,12 @@ public class CustomPushNotification extends PushNotification { if (!mAppLifecycleFacade.isAppVisible()) { if (type.equals(PUSH_TYPE_MESSAGE)) { if (channelId != null) { - if (serverUrl != null) { + if (serverUrl != null && !isReactInit) { + // We will only fetch the data related to the notification on the native side + // as updating the data directly to the db removes the wal & shm files needed + // by watermelonDB, if the DB is updated while WDB is running it causes WDB to + // detect the database as malformed, thus the app stop working and a restart is required. + // Data will be fetch from within the JS context instead. dataHelper.fetchAndStoreDataForPushNotification(mNotificationProps.asBundle()); } @@ -202,8 +210,6 @@ public class CustomPushNotification extends PushNotification { } buildNotification(notificationId, createSummary); - } else { - notifyReceivedToJS(); } break; case PUSH_TYPE_CLEAR: @@ -211,7 +217,7 @@ public class CustomPushNotification extends PushNotification { break; } - if (mAppLifecycleFacade.isReactInitialized()) { + if (isReactInit) { notifyReceivedToJS(); } } @@ -268,9 +274,15 @@ public class CustomPushNotification extends PushNotification { } private String addServerUrlToBundle(Bundle bundle) { - String serverUrl = bundle.getString("server_url"); - if (serverUrl == null) { + String serverId = bundle.getString("server_id"); + String serverUrl; + if (serverId == null) { serverUrl = Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).getOnlyServerUrl(); + } else { + serverUrl = Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).getServerUrlForIdentifier(serverId); + } + + if (!TextUtils.isEmpty(serverUrl)) { bundle.putString("server_url", serverUrl); mNotificationProps = createProps(bundle); } diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index c1f42f448..bb5e07c6b 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -2,26 +2,19 @@ // See LICENSE.txt for license information. import {Model} from '@nozbe/watermelondb'; +import {DeviceEventEmitter} from 'react-native'; -import {fetchChannelByName, fetchMyChannelsForTeam, joinChannel, markChannelAsViewed} from '@actions/remote/channel'; -import {fetchPostsForChannel} from '@actions/remote/post'; -import {fetchRolesIfNeeded} from '@actions/remote/role'; -import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from '@actions/remote/team'; -import {General} from '@constants'; +import {Navigation as NavigationConstants, Screens} from '@constants'; import DatabaseManager from '@database/manager'; -import {privateChannelJoinPrompt} from '@helpers/api/channel'; -import {prepareDeleteChannel, prepareMyChannelsForTeam, queryChannelsById, queryMyChannel} from '@queries/servers/channel'; -import {queryCommonSystemValues, queryCurrentTeamId, setCurrentChannelId, setCurrentTeamAndChannelId} from '@queries/servers/system'; -import {addChannelToTeamHistory, prepareMyTeams, queryMyTeamById, queryTeamById, queryTeamByName, removeChannelFromTeamHistory} from '@queries/servers/team'; -import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; +import {prepareDeleteChannel, queryAllMyChannelIds, queryChannelsById, queryMyChannel} from '@queries/servers/channel'; +import {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, queryCommonSystemValues, queryCurrentTeamId} from '@queries/servers/system'; +import {addChannelToTeamHistory, addTeamToTeamHistory, removeChannelFromTeamHistory} from '@queries/servers/team'; +import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation'; +import {isTablet} from '@utils/helpers'; import type ChannelModel from '@typings/database/models/servers/channel'; -import type MyChannelModel from '@typings/database/models/servers/my_channel'; -import type MyTeamModel from '@typings/database/models/servers/my_team'; -import type TeamModel from '@typings/database/models/servers/team'; -import type {IntlShape} from 'react-intl'; -export const switchToChannel = async (serverUrl: string, channelId: string) => { +export const switchToChannel = async (serverUrl: string, channelId: string, teamId?: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; @@ -29,26 +22,49 @@ export const switchToChannel = async (serverUrl: string, channelId: string) => { try { const dt = Date.now(); + const isTabletDevice = await isTablet(); const system = await queryCommonSystemValues(database); const member = await queryMyChannel(database, channelId); if (member) { - fetchPostsForChannel(serverUrl, channelId); - const channel: ChannelModel = await member.channel.fetch(); const {operator} = DatabaseManager.serverDatabases[serverUrl]; - const result = await setCurrentChannelId(operator, channelId); + const models = []; + const commonValues: PrepareCommonSystemValuesArgs = {currentChannelId: channelId}; + + if (teamId && system.currentTeamId !== teamId) { + commonValues.currentTeamId = teamId; + const history = await addTeamToTeamHistory(operator, teamId, true); + models.push(...history); + } + + const common = await prepareCommonSystemValues(operator, commonValues); + if (common) { + models.push(...common); + } - let previousChannelId: string | undefined; if (system.currentChannelId !== channelId) { - previousChannelId = system.currentChannelId; - await addChannelToTeamHistory(operator, system.currentTeamId, channelId, false); + const history = await addChannelToTeamHistory(operator, system.currentTeamId, channelId, true); + models.push(...history); } - await markChannelAsViewed(serverUrl, channelId, previousChannelId, true); - if (!result.error) { - console.log('channel switch to', channel?.displayName, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line + const viewedAt = await markChannelAsViewed(serverUrl, channelId, true); + if (viewedAt instanceof Model) { + models.push(viewedAt); } + + if (models.length) { + await operator.batchRecords(models); + } + + if (isTabletDevice) { + dismissAllModalsAndPopToRoot(); + DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_HOME); + } else { + dismissAllModalsAndPopToScreen(Screens.CHANNEL, '', undefined, {topBar: {visible: false}}); + } + + console.log('channel switch to', channel?.displayName, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line } } catch (error) { return {error}; @@ -57,171 +73,6 @@ export const switchToChannel = async (serverUrl: string, channelId: string) => { return {error: undefined}; }; -export const switchToChannelByName = async (serverUrl: string, channelName: string, teamName: string, errorHandler: (intl: IntlShape) => void, intl: IntlShape) => { - const database = DatabaseManager.serverDatabases[serverUrl]?.database; - if (!database) { - return {error: `${serverUrl} database not found`}; - } - - try { - let myChannel: MyChannelModel | ChannelMembership | undefined; - let team: TeamModel | Team | undefined; - let myTeam: MyTeamModel | TeamMembership | undefined; - let name = teamName; - const roles: string [] = []; - const system = await queryCommonSystemValues(database); - const currentTeam = await queryTeamById(database, system.currentTeamId); - - if (name === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) { - name = currentTeam!.name; - } else { - team = await queryTeamByName(database, teamName); - } - - if (!team) { - const fetchTeam = await fetchTeamByName(serverUrl, name, true); - if (fetchTeam.error) { - errorHandler(intl); - return {error: fetchTeam.error}; - } - - team = fetchTeam.team!; - } - - let joinedNewTeam = false; - myTeam = await queryMyTeamById(database, team.id); - if (!myTeam) { - const added = await addUserToTeam(serverUrl, team.id, system.currentUserId, true); - if (added.error) { - errorHandler(intl); - return {error: added.error}; - } - myTeam = added.member!; - roles.push(...myTeam.roles.split(' ')); - joinedNewTeam = true; - } - - if (!myTeam) { - errorHandler(intl); - return {error: 'Could not fetch team member'}; - } - - let isArchived = false; - const chReq = await fetchChannelByName(serverUrl, team.id, channelName); - if (chReq.error) { - errorHandler(intl); - return {error: chReq.error}; - } - const channel = chReq.channel; - if (!channel) { - errorHandler(intl); - return {error: 'Could not fetch channel'}; - } - - isArchived = channel.delete_at > 0; - if (isArchived && system.config.ExperimentalViewArchivedChannels !== 'true') { - errorHandler(intl); - return {error: 'Channel is archived'}; - } - - myChannel = await queryMyChannel(database, channel.id); - - if (!myChannel) { - if (channel.type === General.PRIVATE_CHANNEL) { - const displayName = channel.display_name; - const {join} = await privateChannelJoinPrompt(displayName, intl); - if (!join) { - if (joinedNewTeam) { - await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true); - } - errorHandler(intl); - return {error: 'Refused to join Private channel'}; - } - console.log('joining channel', displayName, channel.id); //eslint-disable-line - const result = await joinChannel(serverUrl, system.currentUserId, team.id, channel.id, undefined, true); - if (result.error || !result.channel) { - if (joinedNewTeam) { - await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true); - } - - errorHandler(intl); - return {error: result.error}; - } - - myChannel = result.member!; - roles.push(...myChannel.roles.split(' ')); - } - } - - if (!myChannel) { - errorHandler(intl); - return {error: 'could not fetch channel member'}; - } - - const modelPromises: Array> = []; - const {operator} = DatabaseManager.serverDatabases[serverUrl]; - if (!(team instanceof Model)) { - const prepT = prepareMyTeams(operator, [team], [(myTeam as TeamMembership)]); - if (prepT) { - modelPromises.push(...prepT); - } - } else if (!(myTeam instanceof Model)) { - const mt: MyTeam[] = [{ - id: myTeam.team_id, - roles: myTeam.roles, - }]; - modelPromises.push( - operator.handleMyTeam({myTeams: mt, prepareRecordsOnly: true}), - operator.handleTeamMemberships({teamMemberships: [myTeam], prepareRecordsOnly: true}), - ); - } - - if (!(myChannel instanceof Model)) { - const prepCh = await prepareMyChannelsForTeam(operator, team.id, [channel], [myChannel]); - if (prepCh) { - modelPromises.push(...prepCh); - } - } - - let teamId; - if (team.id !== system.currentTeamId) { - teamId = team.id; - } - - let channelId; - if (channel.id !== system.currentChannelId) { - channelId = channel.id; - } - - if (modelPromises.length) { - const models = await Promise.all(modelPromises); - await operator.batchRecords(models.flat()); - } - - if (channelId) { - fetchPostsForChannel(serverUrl, channelId); - } - - if (teamId) { - fetchMyChannelsForTeam(serverUrl, teamId, true, 0, false, true); - } - - await setCurrentTeamAndChannelId(operator, teamId, channelId); - if (teamId && channelId) { - await addChannelToTeamHistory(operator, teamId, channelId, false); - } - - if (roles.length) { - fetchRolesIfNeeded(serverUrl, roles); - } - - return {error: undefined}; - } catch (error) { - errorHandler(intl); - return {error}; - } -}; - export const localRemoveUserFromChannel = async (serverUrl: string, channelId: string) => { const serverDatabase = DatabaseManager.serverDatabases[serverUrl]; if (!serverDatabase) { @@ -278,3 +129,42 @@ export const localSetChannelDeleteAt = async (serverUrl: string, channelId: stri console.log('FAILED TO BATCH CHANGES FOR CHANNEL DELETE AT'); } }; + +export const selectAllMyChannelIds = async (serverUrl: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return []; + } + + return queryAllMyChannelIds(database); +}; + +export const markChannelAsViewed = async (serverUrl: string, channelId: string, prepareRecordsOnly = false) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + const member = await queryMyChannel(database, channelId); + if (!member) { + return {error: 'not a member'}; + } + + member.prepareUpdate((m) => { + m.messageCount = 0; + m.mentionsCount = 0; + m.manuallyUnread = false; + m.viewedAt = member.lastViewedAt; + }); + + try { + if (!prepareRecordsOnly) { + const {operator} = DatabaseManager.serverDatabases[serverUrl]; + await operator.batchRecords([member]); + } + + return member; + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/local/notification.ts b/app/actions/local/notification.ts new file mode 100644 index 000000000..f4a1922d7 --- /dev/null +++ b/app/actions/local/notification.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MM_TABLES} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {queryPostsInChannel} from '@queries/servers/post'; + +import type PostModel from '@typings/database/models/servers/post'; + +export const updatePostSinceCache = async (serverUrl: string, notification: NotificationWithData) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + if (notification.payload?.channel_id) { + const {database} = operator; + const chunks = await queryPostsInChannel(database, notification.payload.channel_id); + if (chunks.length) { + const recent = chunks[0]; + const lastPost = await database.get(MM_TABLES.SERVER.POST).find(notification.payload.post_id); + await operator.database.write(async () => { + await recent.update(() => { + recent.latest = lastPost.createAt; + }); + }); + } + } + return {}; + } catch (error) { + return {error}; + } +}; + diff --git a/app/actions/local/push_notification.ts b/app/actions/local/push_notification.ts deleted file mode 100644 index 3b2a3840e..000000000 --- a/app/actions/local/push_notification.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import moment from 'moment-timezone'; -import {createIntl} from 'react-intl'; - -import {getSessions} from '@actions/remote/session'; -import {DEFAULT_LOCALE, getTranslations} from '@i18n'; -import PushNotifications from '@init/push_notifications'; -import {sortByNewest} from '@utils/general'; - -export const scheduleExpiredNotification = async (serverUrl: string, config: Partial, userId: string, locale = DEFAULT_LOCALE) => { - if (config.ExtendSessionLengthWithActivity === 'true') { - PushNotifications.cancelAllLocalNotifications(); - return null; - } - - const timeOut = setTimeout(async () => { - clearTimeout(timeOut); - let sessions: Session[]|undefined; - - try { - sessions = await getSessions(serverUrl, userId); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('Failed to get user sessions', e); - return; - } - - if (!Array.isArray(sessions)) { - return; - } - - const session = sessions.sort(sortByNewest)[0]; - const expiresAt = session?.expires_at || 0; - const expiresInDays = Math.ceil(Math.abs(moment.duration(moment().diff(moment(expiresAt))).asDays())); - const intl = createIntl({locale, messages: getTranslations(locale)}); - const body = intl.formatMessage({ - id: 'mobile.session_expired', - defaultMessage: 'Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.', - }, {siteName: config.SiteName, daysCount: expiresInDays}); - - if (expiresAt && body) { - //@ts-expect-error: Does not need to set all Notification properties - PushNotifications.scheduleNotification({ - fireDate: expiresAt, - body, - payload: { - userInfo: { - local: true, - }, - }, - }); - } - }, 1000); - - return null; -}; diff --git a/app/actions/local/team.ts b/app/actions/local/team.ts index 29dba19a9..07ad0fa7f 100644 --- a/app/actions/local/team.ts +++ b/app/actions/local/team.ts @@ -8,7 +8,7 @@ import {fetchPostsForChannel, fetchPostsForUnreadChannels} from '@actions/remote import {fetchAllTeams} from '@actions/remote/team'; import Events from '@constants/events'; import DatabaseManager from '@database/manager'; -import {queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system'; +import {prepareCommonSystemValues, queryCurrentTeamId} from '@queries/servers/system'; import {prepareDeleteTeam, queryMyTeamById, removeTeamFromTeamHistory, queryLastChannelFromTeam, addTeamToTeamHistory} from '@queries/servers/team'; import {isTablet} from '@utils/helpers'; @@ -29,8 +29,19 @@ export const handleTeamChange = async (serverUrl: string, teamId: string) => { fetchPostsForChannel(serverUrl, channelId); } } - setCurrentTeamAndChannelId(operator, teamId, channelId); - addTeamToTeamHistory(operator, teamId); + const models = []; + const system = await prepareCommonSystemValues(operator, {currentChannelId: channelId, currentTeamId: teamId}); + if (system?.length) { + models.push(...system); + } + const history = await addTeamToTeamHistory(operator, teamId, true); + if (history.length) { + models.push(...history); + } + + if (models.length) { + operator.batchRecords(models); + } const {channels, memberships, error} = await fetchMyChannelsForTeam(serverUrl, teamId); if (error) { diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 5a458fd14..965809923 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -1,18 +1,29 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Model} from '@nozbe/watermelondb'; +import {IntlShape} from 'react-intl'; + +import {switchToChannel} from '@actions/local/channel'; import {General} from '@constants'; import DatabaseManager from '@database/manager'; +import {privateChannelJoinPrompt} from '@helpers/api/channel'; import NetworkManager from '@init/network_manager'; import {prepareMyChannelsForTeam, queryMyChannel} from '@queries/servers/channel'; +import {queryCommonSystemValues} from '@queries/servers/system'; +import {prepareMyTeams, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team'; +import MyChannelModel from '@typings/database/models/servers/my_channel'; +import MyTeamModel from '@typings/database/models/servers/my_team'; +import TeamModel from '@typings/database/models/servers/team'; +import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import {displayGroupMessageName, displayUsername} from '@utils/user'; import {fetchRolesIfNeeded} from './role'; import {forceLogoutIfNecessary} from './session'; +import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from './team'; import {fetchProfilesPerChannels, fetchUsersByIds} from './user'; import type {Client} from '@client/rest'; -import type {Model} from '@nozbe/watermelondb'; export type MyChannelsRequest = { channels?: Channel[]; @@ -132,11 +143,58 @@ export const fetchMyChannelsForTeam = async (serverUrl: string, teamId: string, } }; -export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels: Channel[], locale?: string, teammateDisplayNameSetting?: string, exludeUserId?: string) => { +export const fetchMyChannel = async (serverUrl: string, teamId: string, channelId: string, fetchOnly = false): Promise => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const [channel, member] = await Promise.all([ + client.getChannel(channelId), + client.getChannelMember(channelId, 'me'), + ]); + + if (!fetchOnly) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + const modelPromises: Array> = []; + if (operator) { + const prepare = await prepareMyChannelsForTeam(operator, teamId, [channel], [member]); + if (prepare) { + modelPromises.push(...prepare); + } + if (modelPromises.length) { + const models = await Promise.all(modelPromises); + const flattenedModels = models.flat() as Model[]; + if (flattenedModels?.length > 0) { + try { + await operator.batchRecords(flattenedModels); + } catch { + // eslint-disable-next-line no-console + console.log('FAILED TO BATCH CHANNELS'); + } + } + } + } + } + + return { + channels: [channel], + memberships: [member], + }; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels: Channel[], locale?: string, teammateDisplayNameSetting?: string, exludeUserId?: string, fetchOnly = false) => { const channelIds = directChannels.map((dc) => dc.id); const result = await fetchProfilesPerChannels(serverUrl, channelIds, exludeUserId, false); if (result.error) { - return; + return {error: result.error}; } const displayNameByChannel: Record = {}; @@ -160,10 +218,14 @@ export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels: } }); - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (operator) { - operator.handleChannel({channels: directChannels, prepareRecordsOnly: false}); + if (!fetchOnly) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (operator) { + await operator.handleChannel({channels: directChannels, prepareRecordsOnly: false}); + } } + + return {directChannels}; }; export const joinChannel = async (serverUrl: string, userId: string, teamId: string, channelId?: string, channelName?: string, fetchOnly = false) => { @@ -227,57 +289,173 @@ export const joinChannel = async (serverUrl: string, userId: string, teamId: str return {channel, member}; }; -export const markChannelAsViewed = async (serverUrl: string, channelId: string, previousChannelId = '', markOnServer = true) => { +export const markChannelAsRead = async (serverUrl: string, channelId: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + await client.viewMyChannel(channelId); + + return {}; + } catch (error) { + return {error}; + } +}; + +export const switchToChannelByName = async (serverUrl: string, channelName: string, teamName: string, errorHandler: (intl: IntlShape) => void, intl: IntlShape) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; } - const member = await queryMyChannel(database, channelId); - const prevMember = await queryMyChannel(database, previousChannelId); - if (markOnServer) { - try { - const client = NetworkManager.getClient(serverUrl); - client.viewMyChannel(channelId, prevMember?.manuallyUnread ? '' : previousChannelId).catch(() => { - // do nothing just adding the handler to avoid the warning - }); - } catch (error) { - return {error}; - } - } - - const models = []; - const lastViewedAt = Date.now(); - if (member) { - member.prepareUpdate((m) => { - m.messageCount = 0; - m.mentionsCount = 0; - m.manuallyUnread = false; - m.lastViewedAt = lastViewedAt; - }); - - models.push(member); - } - - if (prevMember && !prevMember.manuallyUnread) { - prevMember.prepareUpdate((m) => { - m.messageCount = 0; - m.mentionsCount = 0; - m.manuallyUnread = false; - m.lastViewedAt = lastViewedAt; - }); - - models.push(prevMember); - } - try { - if (models.length) { - const {operator} = DatabaseManager.serverDatabases[serverUrl]; - await operator.batchRecords(models); + let myChannel: MyChannelModel | ChannelMembership | undefined; + let team: TeamModel | Team | undefined; + let myTeam: MyTeamModel | TeamMembership | undefined; + let name = teamName; + const roles: string [] = []; + const system = await queryCommonSystemValues(database); + const currentTeam = await queryTeamById(database, system.currentTeamId); + + if (name === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) { + name = currentTeam!.name; + } else { + team = await queryTeamByName(database, teamName); } - return {data: true}; + if (!team) { + const fetchTeam = await fetchTeamByName(serverUrl, name, true); + if (fetchTeam.error) { + errorHandler(intl); + return {error: fetchTeam.error}; + } + + team = fetchTeam.team!; + } + + let joinedNewTeam = false; + myTeam = await queryMyTeamById(database, team.id); + if (!myTeam) { + const added = await addUserToTeam(serverUrl, team.id, system.currentUserId, true); + if (added.error) { + errorHandler(intl); + return {error: added.error}; + } + myTeam = added.member!; + roles.push(...myTeam.roles.split(' ')); + joinedNewTeam = true; + } + + if (!myTeam) { + errorHandler(intl); + return {error: 'Could not fetch team member'}; + } + + let isArchived = false; + const chReq = await fetchChannelByName(serverUrl, team.id, channelName); + if (chReq.error) { + errorHandler(intl); + return {error: chReq.error}; + } + const channel = chReq.channel; + if (!channel) { + errorHandler(intl); + return {error: 'Could not fetch channel'}; + } + + isArchived = channel.delete_at > 0; + if (isArchived && system.config.ExperimentalViewArchivedChannels !== 'true') { + errorHandler(intl); + return {error: 'Channel is archived'}; + } + + myChannel = await queryMyChannel(database, channel.id); + + if (!myChannel) { + if (channel.type === General.PRIVATE_CHANNEL) { + const displayName = channel.display_name; + const {join} = await privateChannelJoinPrompt(displayName, intl); + if (!join) { + if (joinedNewTeam) { + await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true); + } + errorHandler(intl); + return {error: 'Refused to join Private channel'}; + } + console.log('joining channel', displayName, channel.id); //eslint-disable-line + const result = await joinChannel(serverUrl, system.currentUserId, team.id, channel.id, undefined, true); + if (result.error || !result.channel) { + if (joinedNewTeam) { + await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true); + } + + errorHandler(intl); + return {error: result.error}; + } + + myChannel = result.member!; + roles.push(...myChannel.roles.split(' ')); + } + } + + if (!myChannel) { + errorHandler(intl); + return {error: 'could not fetch channel member'}; + } + + const modelPromises: Array> = []; + const {operator} = DatabaseManager.serverDatabases[serverUrl]; + if (!(team instanceof Model)) { + const prepT = prepareMyTeams(operator, [team], [(myTeam as TeamMembership)]); + if (prepT) { + modelPromises.push(...prepT); + } + } else if (!(myTeam instanceof Model)) { + const mt: MyTeam[] = [{ + id: myTeam.team_id, + roles: myTeam.roles, + }]; + modelPromises.push( + operator.handleMyTeam({myTeams: mt, prepareRecordsOnly: true}), + operator.handleTeamMemberships({teamMemberships: [myTeam], prepareRecordsOnly: true}), + ); + } + + if (!(myChannel instanceof Model)) { + const prepCh = await prepareMyChannelsForTeam(operator, team.id, [channel], [myChannel]); + if (prepCh) { + modelPromises.push(...prepCh); + } + } + + let teamId; + if (team.id !== system.currentTeamId) { + teamId = team.id; + } + + let channelId; + if (channel.id !== system.currentChannelId) { + channelId = channel.id; + } + + if (modelPromises.length) { + const models = await Promise.all(modelPromises); + await operator.batchRecords(models.flat()); + } + + if (teamId) { + fetchMyChannelsForTeam(serverUrl, teamId, true, 0, false, true); + } + + if (teamId && channelId) { + await switchToChannel(serverUrl, channelId, teamId); + } + + if (roles.length) { + fetchRolesIfNeeded(serverUrl, roles); + } + + return {error: undefined}; } catch (error) { + errorHandler(intl); return {error}; } }; diff --git a/app/actions/remote/entry.ts b/app/actions/remote/entry.ts deleted file mode 100644 index a6cdf190c..000000000 --- a/app/actions/remote/entry.ts +++ /dev/null @@ -1,415 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Model} from '@nozbe/watermelondb'; - -import {scheduleExpiredNotification} from '@actions/local/push_notification'; -import {General, Preferences} from '@constants'; -import DatabaseManager from '@database/manager'; -import {getPreferenceValue, getTeammateNameDisplaySetting} from '@helpers/api/preference'; -import {selectDefaultTeam} from '@helpers/api/team'; -import {DEFAULT_LOCALE} from '@i18n'; -import NetworkManager from '@init/network_manager'; -import {queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel'; -import {prepareModels} from '@queries/servers/entry'; -import {prepareCommonSystemValues, queryCommonSystemValues, queryConfig, queryCurrentTeamId, queryWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system'; -import {addChannelToTeamHistory, deleteMyTeams, queryAvailableTeamIds, queryMyTeams, queryTeamsById} from '@queries/servers/team'; -import {queryCurrentUser} from '@queries/servers/user'; -import {selectDefaultChannelForTeam} from '@utils/channel'; -import {deleteV1Data} from '@utils/file'; - -import {fetchMissingSidebarInfo, fetchMyChannelsForTeam, MyChannelsRequest} from './channel'; -import {fetchGroupsForTeam} from './group'; -import {fetchPostsForChannel, fetchPostsForUnreadChannels} from './post'; -import {MyPreferencesRequest, fetchMyPreferences} from './preference'; -import {fetchRoles, fetchRolesIfNeeded} from './role'; -import {ConfigAndLicenseRequest, fetchConfigAndLicense} from './systems'; -import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, MyTeamsRequest} from './team'; -import {fetchMe, MyUserRequest} from './user'; - -import type {Client} from '@client/rest'; -import type ClientError from '@client/rest/error'; - -type AfterLoginArgs = { - serverUrl: string; - user: UserProfile; - deviceToken?: string; -} - -type AppEntryData = { - initialTeamId: string; - teamData: MyTeamsRequest; - chData?: MyChannelsRequest; - prefData: MyPreferencesRequest; - meData: MyUserRequest; - removeTeamIds?: string[]; - removeChannelIds?: string[]; -} - -type AppEntryError = { - error?: Error | ClientError | string; -} - -export const appEntry = async (serverUrl: string) => { - const dt = Date.now(); - - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - return {error: `${serverUrl} database not found`}; - } - const {database} = operator; - - const currentTeamId = await queryCurrentTeamId(database); - const fetchedData = await fetchAppEntryData(serverUrl, currentTeamId); - const fetchedError = (fetchedData as AppEntryError).error; - - if (fetchedError) { - return {error: fetchedError, time: Date.now() - dt}; - } - - const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData; - - if (initialTeamId !== currentTeamId) { - // Immediately set the new team as the current team in the database so that the UI - // renders the correct team. - setCurrentTeamAndChannelId(operator, initialTeamId, ''); - } - - let removeTeams; - if (removeTeamIds?.length) { - // Immediately delete myTeams so that the UI renders only teams the user is a member of. - removeTeams = await queryTeamsById(database, removeTeamIds); - await deleteMyTeams(operator, removeTeams!); - } - - fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user); - - let removeChannels; - if (removeChannelIds?.length) { - removeChannels = await queryChannelsById(database, removeChannelIds); - } - - const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData}); - const models = await Promise.all(modelPromises); - if (models.length) { - await operator.batchRecords(models.flat() as Model[]); - } - - const {id: currentUserId, locale: currentUserLocale} = meData.user || (await queryCurrentUser(database))!; - const {config, license} = await queryCommonSystemValues(database); - deferredAppEntryActions(serverUrl, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId); - - const error = teamData.error || chData?.error || prefData.error || meData.error; - return {error, time: Date.now() - dt, userId: meData?.user?.id}; -}; - -export const loginEntry = async ({serverUrl, user, deviceToken}: AfterLoginArgs) => { - const dt = Date.now(); - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - return {error: `${serverUrl} database not found`}; - } - - let client: Client; - try { - client = NetworkManager.getClient(serverUrl); - } catch (error) { - return {error}; - } - - if (deviceToken) { - try { - client.attachDevice(deviceToken); - } catch { - // do nothing, the token could've failed to attach to the session but is not a blocker - } - } - - try { - let initialTeam: Team|undefined; - let initialChannel: Channel|undefined; - let myTeams: Team[]|undefined; - - // Fetch in parallel server config & license / user preferences / teams / team membership - const promises: [Promise, Promise, Promise] = [ - fetchConfigAndLicense(serverUrl, true), - fetchMyPreferences(serverUrl, true), - fetchMyTeams(serverUrl, true), - ]; - - const [clData, prefData, teamData] = await Promise.all(promises); - let chData: MyChannelsRequest|undefined; - - // schedule local push notification if needed - if (clData.config) { - scheduleExpiredNotification(serverUrl, clData.config, user.id, user.locale); - } - - // select initial team - if (!clData.error && !prefData.error && !teamData.error) { - const teamOrderPreference = getPreferenceValue(prefData.preferences!, Preferences.TEAMS_ORDER, '', '') as string; - const teamRoles: string[] = []; - const teamMembers: string[] = []; - - teamData.memberships?.forEach((tm) => { - teamRoles.push(...tm.roles.split(' ')); - teamMembers.push(tm.team_id); - }); - - myTeams = teamData.teams!.filter((t) => teamMembers?.includes(t.id)); - initialTeam = selectDefaultTeam(myTeams, user.locale, teamOrderPreference, clData.config?.ExperimentalPrimaryTeam); - - if (initialTeam) { - const rolesToFetch = new Set([...user.roles.split(' '), ...teamRoles]); - - // fetch channels / channel membership for initial team - chData = await fetchMyChannelsForTeam(serverUrl, initialTeam.id, false, 0, true); - if (chData.channels?.length && chData.memberships?.length) { - const {channels, memberships} = chData; - const channelIds = new Set(channels?.map((c) => c.id)); - for (let i = 0; i < memberships!.length; i++) { - const member = memberships[i]; - if (channelIds.has(member.channel_id)) { - member.roles.split(' ').forEach(rolesToFetch.add, rolesToFetch); - } - } - - // fetch user roles - const rData = await fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch)); - - // select initial channel - initialChannel = selectDefaultChannelForTeam(channels!, memberships!, initialTeam!.id, rData.roles, user.locale); - } - } - } - - const modelPromises = await prepareModels({operator, teamData, chData, prefData, initialTeamId: initialTeam?.id}); - - const systemModels = prepareCommonSystemValues( - operator, - { - config: clData.config || ({} as ClientConfig), - license: clData.license || ({} as ClientLicense), - currentTeamId: initialTeam?.id || '', - currentChannelId: initialChannel?.id || '', - }, - ); - if (systemModels) { - modelPromises.push(systemModels); - } - - if (initialTeam && initialChannel) { - try { - const tch = addChannelToTeamHistory(operator, initialTeam.id, initialChannel.id, true); - modelPromises.push(tch); - } catch { - // do nothing - } - } - - const models = await Promise.all(modelPromises); - if (models.length) { - await operator.batchRecords(models.flat() as Model[]); - } - - const config = clData.config || {} as ClientConfig; - const license = clData.license || {} as ClientLicense; - deferredAppEntryActions(serverUrl, user.id, user.locale, prefData.preferences, config, license, teamData, chData, initialTeam?.id, initialChannel?.id); - - const error = clData.error || prefData.error || teamData.error || chData?.error; - return {error, time: Date.now() - dt, hasTeams: Boolean((myTeams?.length || 0) > 0 && !teamData.error)}; - } catch (error) { - const systemModels = await prepareCommonSystemValues(operator, { - config: ({} as ClientConfig), - license: ({} as ClientLicense), - currentTeamId: '', - currentChannelId: '', - }); - if (systemModels) { - await operator.batchRecords(systemModels); - } - - return {error}; - } -}; - -export const upgradeEntry = async (serverUrl: string) => { - const dt = Date.now(); - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - return {error: `${serverUrl} database not found`}; - } - - try { - const configAndLicense = await fetchConfigAndLicense(serverUrl, false); - const entry = await appEntry(serverUrl); - - const error = configAndLicense.error || entry.error; - - if (!error) { - const models = await prepareCommonSystemValues(operator, {currentUserId: entry.userId}); - if (models?.length) { - await operator.batchRecords(models); - } - DatabaseManager.setActiveServerDatabase(serverUrl); - deleteV1Data(); - } - - return {error, time: Date.now() - dt}; - } catch (e) { - return {error: e, time: Date.now() - dt}; - } -}; - -const fetchAppEntryData = async (serverUrl: string, initialTeamId: string): Promise => { - const database = DatabaseManager.serverDatabases[serverUrl]?.database; - if (!database) { - return {error: `${serverUrl} database not found`}; - } - - const lastDisconnected = await queryWebSocketLastDisconnected(database); - const includeDeletedChannels = true; - const fetchOnly = true; - - // Fetch in parallel teams / team membership / channels for current team / user preferences / user - const promises: [Promise, Promise, Promise, Promise] = [ - fetchMyTeams(serverUrl, fetchOnly), - initialTeamId ? fetchMyChannelsForTeam(serverUrl, initialTeamId, includeDeletedChannels, lastDisconnected, fetchOnly) : Promise.resolve(undefined), - fetchMyPreferences(serverUrl, fetchOnly), - fetchMe(serverUrl, fetchOnly), - ]; - - const removeTeamIds: string[] = []; - const resolution = await Promise.all(promises); - const [teamData, , prefData, meData] = resolution; - let [, chData] = resolution; - - if (!initialTeamId && teamData.teams?.length && teamData.memberships?.length) { - // If no initial team was set in the database but got teams in the response - const config = await queryConfig(database); - const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.TEAMS_ORDER, '', '') as string; - const teamMembers = teamData.memberships.filter((m) => m.delete_at === 0).map((m) => m.team_id); - const myTeams = teamData.teams!.filter((t) => teamMembers?.includes(t.id)); - const defaultTeam = selectDefaultTeam(myTeams, meData.user?.locale || DEFAULT_LOCALE, teamOrderPreference, config.ExperimentalPrimaryTeam); - if (defaultTeam?.id) { - chData = await fetchMyChannelsForTeam(serverUrl, defaultTeam.id, includeDeletedChannels, lastDisconnected, fetchOnly); - } - } - - const removedFromTeam = teamData.memberships?.filter((m) => m.delete_at > 0); - if (removedFromTeam?.length) { - removeTeamIds.push(...removedFromTeam.map((m) => m.team_id)); - } - - let data: AppEntryData = { - initialTeamId, - teamData, - chData, - prefData, - meData, - removeTeamIds, - }; - - if (teamData.teams?.length === 0) { - // User is no longer a member of any team - const myTeams = await queryMyTeams(database); - removeTeamIds.push(...(myTeams?.map((myTeam) => myTeam.id) || [])); - - return { - ...data, - initialTeamId: '', - removeTeamIds, - }; - } - - const inTeam = teamData.teams?.find((t) => t.id === initialTeamId); - const chError = chData?.error as ClientError | undefined; - if (!inTeam || chError?.status_code === 403) { - // User is no longer a member of the current team - if (!removeTeamIds.includes(initialTeamId)) { - removeTeamIds.push(initialTeamId); - } - - const availableTeamIds = await queryAvailableTeamIds(database, initialTeamId, teamData.teams, prefData.preferences, meData.user?.locale); - const alternateTeamData = await fetchAlternateTeamData(serverUrl, availableTeamIds, removeTeamIds, includeDeletedChannels, lastDisconnected, fetchOnly); - - data = { - ...data, - ...alternateTeamData, - }; - } - - if (data.chData?.channels) { - const removeChannelIds: string[] = []; - const fetchedChannelIds = data.chData.channels.map((channel) => channel.id); - - const channels = await queryAllChannelsForTeam(database, initialTeamId); - for (const channel of channels) { - if (!fetchedChannelIds.includes(channel.id)) { - removeChannelIds.push(channel.id); - } - } - - data = { - ...data, - removeChannelIds, - }; - } - - return data; -}; - -const fetchAlternateTeamData = async (serverUrl: string, availableTeamIds: string[], removeTeamIds: string[], includeDeleted = true, since = 0, fetchOnly = false) => { - let initialTeamId = ''; - let chData; - - for (const teamId of availableTeamIds) { - // eslint-disable-next-line no-await-in-loop - chData = await fetchMyChannelsForTeam(serverUrl, teamId, includeDeleted, since, fetchOnly); - const chError = chData.error as ClientError | undefined; - if (chError?.status_code === 403) { - removeTeamIds.push(teamId); - } else { - initialTeamId = teamId; - break; - } - } - - if (chData) { - return {initialTeamId, chData, removeTeamIds}; - } - - return {initialTeamId, removeTeamIds}; -}; - -const deferredAppEntryActions = async ( - serverUrl: string, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined, config: ClientConfig, license: ClientLicense, teamData: MyTeamsRequest, - chData: MyChannelsRequest | undefined, initialTeamId?: string, initialChannelId?: string) => { - // defer fetching posts for initial channel - if (initialChannelId) { - fetchPostsForChannel(serverUrl, initialChannelId); - } - - // defer sidebar DM & GM profiles - if (chData?.channels?.length && chData.memberships?.length) { - const directChannels = chData.channels.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL); - const channelsToFetchProfiles = new Set(directChannels); - if (channelsToFetchProfiles.size) { - const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license); - await fetchMissingSidebarInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId); - } - - // defer fetching posts for unread channels on initial team - fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId); - } - - // defer groups for team - if (initialTeamId) { - await fetchGroupsForTeam(serverUrl, initialTeamId); - } - - // defer fetch channels and unread posts for other teams - if (teamData.teams?.length && teamData.memberships?.length) { - fetchTeamsChannelsAndUnreadPosts(serverUrl, teamData.teams, teamData.memberships, initialTeamId); - } -}; diff --git a/app/actions/remote/entry/app.ts b/app/actions/remote/entry/app.ts new file mode 100644 index 000000000..f47ede703 --- /dev/null +++ b/app/actions/remote/entry/app.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchRoles} from '@actions/remote/role'; +import {fetchConfigAndLicense} from '@actions/remote/systems'; +import DatabaseManager from '@database/manager'; +import {queryChannelsById, queryDefaultChannelForTeam} from '@queries/servers/channel'; +import {prepareModels} from '@queries/servers/entry'; +import {prepareCommonSystemValues, queryCommonSystemValues, queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system'; +import {deleteMyTeams, queryTeamsById} from '@queries/servers/team'; +import {queryCurrentUser} from '@queries/servers/user'; +import {deleteV1Data} from '@utils/file'; +import {isTablet} from '@utils/helpers'; + +import {AppEntryData, AppEntryError, deferredAppEntryActions, fetchAppEntryData} from './common'; + +export const appEntry = async (serverUrl: string) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + const {database} = operator; + + const currentTeamId = await queryCurrentTeamId(database); + const fetchedData = await fetchAppEntryData(serverUrl, currentTeamId); + const fetchedError = (fetchedData as AppEntryError).error; + + if (fetchedError) { + return {error: fetchedError}; + } + + const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData; + + if (initialTeamId !== currentTeamId) { + // Immediately set the new team as the current team in the database so that the UI + // renders the correct team. + let channelId = ''; + if ((await isTablet())) { + const channel = await queryDefaultChannelForTeam(database, initialTeamId); + channelId = channel?.id || ''; + } + setCurrentTeamAndChannelId(operator, initialTeamId, channelId); + } + + let removeTeams; + if (removeTeamIds?.length) { + // Immediately delete myTeams so that the UI renders only teams the user is a member of. + removeTeams = await queryTeamsById(database, removeTeamIds); + await deleteMyTeams(operator, removeTeams!); + } + + fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user); + + let removeChannels; + if (removeChannelIds?.length) { + removeChannels = await queryChannelsById(database, removeChannelIds); + } + + const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData}); + const models = await Promise.all(modelPromises); + if (models.length) { + await operator.batchRecords(models.flat()); + } + + const {id: currentUserId, locale: currentUserLocale} = meData.user || (await queryCurrentUser(database))!; + const {config, license} = await queryCommonSystemValues(database); + deferredAppEntryActions(serverUrl, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId); + + const error = teamData.error || chData?.error || prefData.error || meData.error; + return {error, userId: meData?.user?.id}; +}; + +export const upgradeEntry = async (serverUrl: string) => { + const dt = Date.now(); + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + const configAndLicense = await fetchConfigAndLicense(serverUrl, false); + const entry = await appEntry(serverUrl); + + const error = configAndLicense.error || entry.error; + + if (!error) { + const models = await prepareCommonSystemValues(operator, {currentUserId: entry.userId}); + if (models?.length) { + await operator.batchRecords(models); + } + DatabaseManager.updateServerIdentifier(serverUrl, configAndLicense.config!.DiagnosticId); + DatabaseManager.setActiveServerDatabase(serverUrl); + deleteV1Data(); + } + + return {error, time: Date.now() - dt}; + } catch (e) { + return {error: e, time: Date.now() - dt}; + } +}; diff --git a/app/actions/remote/entry/common.ts b/app/actions/remote/entry/common.ts new file mode 100644 index 000000000..b3080b826 --- /dev/null +++ b/app/actions/remote/entry/common.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchMissingSidebarInfo, fetchMyChannelsForTeam, MyChannelsRequest} from '@actions/remote/channel'; +import {fetchGroupsForTeam} from '@actions/remote/group'; +import {fetchPostsForChannel, fetchPostsForUnreadChannels} from '@actions/remote/post'; +import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference'; +import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, MyTeamsRequest} from '@actions/remote/team'; +import {fetchMe, MyUserRequest} from '@actions/remote/user'; +import {General, Preferences} from '@constants'; +import DatabaseManager from '@database/manager'; +import {getPreferenceValue, getTeammateNameDisplaySetting} from '@helpers/api/preference'; +import {selectDefaultTeam} from '@helpers/api/team'; +import {DEFAULT_LOCALE} from '@i18n'; +import {queryAllChannelsForTeam} from '@queries/servers/channel'; +import {queryConfig, queryWebSocketLastDisconnected} from '@queries/servers/system'; +import {queryAvailableTeamIds, queryMyTeams} from '@queries/servers/team'; + +import type ClientError from '@client/rest/error'; + +export type AppEntryData = { + initialTeamId: string; + teamData: MyTeamsRequest; + chData?: MyChannelsRequest; + prefData: MyPreferencesRequest; + meData: MyUserRequest; + removeTeamIds?: string[]; + removeChannelIds?: string[]; +} + +export type AppEntryError = { + error?: Error | ClientError | string; +} + +export const fetchAppEntryData = async (serverUrl: string, initialTeamId: string): Promise => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + const lastDisconnected = await queryWebSocketLastDisconnected(database); + const includeDeletedChannels = true; + const fetchOnly = true; + + // Fetch in parallel teams / team membership / channels for current team / user preferences / user + const promises: [Promise, Promise, Promise, Promise] = [ + fetchMyTeams(serverUrl, fetchOnly), + initialTeamId ? fetchMyChannelsForTeam(serverUrl, initialTeamId, includeDeletedChannels, lastDisconnected, fetchOnly) : Promise.resolve(undefined), + fetchMyPreferences(serverUrl, fetchOnly), + fetchMe(serverUrl, fetchOnly), + ]; + + const removeTeamIds: string[] = []; + const resolution = await Promise.all(promises); + const [teamData, , prefData, meData] = resolution; + let [, chData] = resolution; + + if (!initialTeamId && teamData.teams?.length && teamData.memberships?.length) { + // If no initial team was set in the database but got teams in the response + const config = await queryConfig(database); + const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.TEAMS_ORDER, '', '') as string; + const teamMembers = teamData.memberships.filter((m) => m.delete_at === 0).map((m) => m.team_id); + const myTeams = teamData.teams!.filter((t) => teamMembers?.includes(t.id)); + const defaultTeam = selectDefaultTeam(myTeams, meData.user?.locale || DEFAULT_LOCALE, teamOrderPreference, config.ExperimentalPrimaryTeam); + if (defaultTeam?.id) { + chData = await fetchMyChannelsForTeam(serverUrl, defaultTeam.id, includeDeletedChannels, lastDisconnected, fetchOnly); + } + } + + const removedFromTeam = teamData.memberships?.filter((m) => m.delete_at > 0); + if (removedFromTeam?.length) { + removeTeamIds.push(...removedFromTeam.map((m) => m.team_id)); + } + + let data: AppEntryData = { + initialTeamId, + teamData, + chData, + prefData, + meData, + removeTeamIds, + }; + + if (teamData.teams?.length === 0) { + // User is no longer a member of any team + const myTeams = await queryMyTeams(database); + removeTeamIds.push(...(myTeams?.map((myTeam) => myTeam.id) || [])); + + return { + ...data, + initialTeamId: '', + removeTeamIds, + }; + } + + const inTeam = teamData.teams?.find((t) => t.id === initialTeamId); + const chError = chData?.error as ClientError | undefined; + if (!inTeam || chError?.status_code === 403) { + // User is no longer a member of the current team + if (!removeTeamIds.includes(initialTeamId)) { + removeTeamIds.push(initialTeamId); + } + + const availableTeamIds = await queryAvailableTeamIds(database, initialTeamId, teamData.teams, prefData.preferences, meData.user?.locale); + const alternateTeamData = await fetchAlternateTeamData(serverUrl, availableTeamIds, removeTeamIds, includeDeletedChannels, lastDisconnected, fetchOnly); + + data = { + ...data, + ...alternateTeamData, + }; + } + + if (data.chData?.channels) { + const removeChannelIds: string[] = []; + const fetchedChannelIds = data.chData.channels.map((channel) => channel.id); + + const channels = await queryAllChannelsForTeam(database, initialTeamId); + for (const channel of channels) { + if (!fetchedChannelIds.includes(channel.id)) { + removeChannelIds.push(channel.id); + } + } + + data = { + ...data, + removeChannelIds, + }; + } + + return data; +}; + +export const fetchAlternateTeamData = async ( + serverUrl: string, availableTeamIds: string[], removeTeamIds: string[], + includeDeleted = true, since = 0, fetchOnly = false) => { + let initialTeamId = ''; + let chData; + + for (const teamId of availableTeamIds) { + // eslint-disable-next-line no-await-in-loop + chData = await fetchMyChannelsForTeam(serverUrl, teamId, includeDeleted, since, fetchOnly); + const chError = chData.error as ClientError | undefined; + if (chError?.status_code === 403) { + removeTeamIds.push(teamId); + } else { + initialTeamId = teamId; + break; + } + } + + if (chData) { + return {initialTeamId, chData, removeTeamIds}; + } + + return {initialTeamId, removeTeamIds}; +}; + +export const deferredAppEntryActions = async ( + serverUrl: string, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined, + config: ClientConfig, license: ClientLicense, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined, + initialTeamId?: string, initialChannelId?: string) => { + // defer fetching posts for initial channel + if (initialChannelId) { + fetchPostsForChannel(serverUrl, initialChannelId); + } + + // defer sidebar DM & GM profiles + if (chData?.channels?.length && chData.memberships?.length) { + const directChannels = chData.channels.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL); + const channelsToFetchProfiles = new Set(directChannels); + if (channelsToFetchProfiles.size) { + const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license); + await fetchMissingSidebarInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId); + } + + // defer fetching posts for unread channels on initial team + fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId); + } + + // defer groups for team + if (initialTeamId) { + await fetchGroupsForTeam(serverUrl, initialTeamId); + } + + // defer fetch channels and unread posts for other teams + if (teamData.teams?.length && teamData.memberships?.length) { + fetchTeamsChannelsAndUnreadPosts(serverUrl, teamData.teams, teamData.memberships, initialTeamId); + } +}; diff --git a/app/actions/remote/entry/index.ts b/app/actions/remote/entry/index.ts new file mode 100644 index 000000000..fae0bad0b --- /dev/null +++ b/app/actions/remote/entry/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {appEntry, upgradeEntry} from './app'; +export {loginEntry} from './login'; +export {pushNotificationEntry} from './notification'; diff --git a/app/actions/remote/entry/login.ts b/app/actions/remote/entry/login.ts new file mode 100644 index 000000000..b5a76f8e7 --- /dev/null +++ b/app/actions/remote/entry/login.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Model} from '@nozbe/watermelondb'; + +import {fetchMyChannelsForTeam, MyChannelsRequest} from '@actions/remote/channel'; +import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference'; +import {fetchRolesIfNeeded} from '@actions/remote/role'; +import {getSessions} from '@actions/remote/session'; +import {ConfigAndLicenseRequest, fetchConfigAndLicense} from '@actions/remote/systems'; +import {fetchMyTeams, MyTeamsRequest} from '@actions/remote/team'; +import {Preferences} from '@constants'; +import DatabaseManager from '@database/manager'; +import {getPreferenceValue} from '@helpers/api/preference'; +import {selectDefaultTeam} from '@helpers/api/team'; +import NetworkManager from '@init/network_manager'; +import {prepareModels} from '@queries/servers/entry'; +import {prepareCommonSystemValues} from '@queries/servers/system'; +import {addChannelToTeamHistory} from '@queries/servers/team'; +import {selectDefaultChannelForTeam} from '@utils/channel'; +import {scheduleExpiredNotification} from '@utils/notification'; + +import {deferredAppEntryActions} from './common'; + +import type {Client} from '@client/rest'; + +type AfterLoginArgs = { + serverUrl: string; + user: UserProfile; + deviceToken?: string; +} + +export const loginEntry = async ({serverUrl, user, deviceToken}: AfterLoginArgs) => { + const dt = Date.now(); + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + if (deviceToken) { + try { + client.attachDevice(deviceToken); + } catch { + // do nothing, the token could've failed to attach to the session but is not a blocker + } + } + + try { + let initialTeam: Team|undefined; + let initialChannel: Channel|undefined; + let myTeams: Team[]|undefined; + + // Fetch in parallel server config & license / user preferences / teams / team membership + const promises: [Promise, Promise, Promise] = [ + fetchConfigAndLicense(serverUrl, true), + fetchMyPreferences(serverUrl, true), + fetchMyTeams(serverUrl, true), + ]; + + const [clData, prefData, teamData] = await Promise.all(promises); + let chData: MyChannelsRequest|undefined; + + // schedule local push notification if needed + if (clData.config) { + if (clData.config.ExtendSessionLengthWithActivity !== 'true') { + const timeOut = setTimeout(async () => { + clearTimeout(timeOut); + let sessions: Session[]|undefined; + + try { + sessions = await getSessions(serverUrl, 'me'); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Failed to get user sessions', e); + return; + } + + if (sessions && Array.isArray(sessions)) { + scheduleExpiredNotification(sessions, clData.config?.SiteName || serverUrl, user.locale); + } + }, 500); + } + } + + // select initial team + if (!clData.error && !prefData.error && !teamData.error) { + const teamOrderPreference = getPreferenceValue(prefData.preferences!, Preferences.TEAMS_ORDER, '', '') as string; + const teamRoles: string[] = []; + const teamMembers: string[] = []; + + teamData.memberships?.forEach((tm) => { + teamRoles.push(...tm.roles.split(' ')); + teamMembers.push(tm.team_id); + }); + + myTeams = teamData.teams!.filter((t) => teamMembers?.includes(t.id)); + initialTeam = selectDefaultTeam(myTeams, user.locale, teamOrderPreference, clData.config?.ExperimentalPrimaryTeam); + + if (initialTeam) { + const rolesToFetch = new Set([...user.roles.split(' '), ...teamRoles]); + + // fetch channels / channel membership for initial team + chData = await fetchMyChannelsForTeam(serverUrl, initialTeam.id, false, 0, true); + if (chData.channels?.length && chData.memberships?.length) { + const {channels, memberships} = chData; + const channelIds = new Set(channels?.map((c) => c.id)); + for (let i = 0; i < memberships!.length; i++) { + const member = memberships[i]; + if (channelIds.has(member.channel_id)) { + member.roles.split(' ').forEach(rolesToFetch.add, rolesToFetch); + } + } + + // fetch user roles + const rData = await fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch)); + + // select initial channel + initialChannel = selectDefaultChannelForTeam(channels!, memberships!, initialTeam!.id, rData.roles, user.locale); + } + } + } + + const modelPromises = await prepareModels({operator, teamData, chData, prefData, initialTeamId: initialTeam?.id}); + + const systemModels = prepareCommonSystemValues( + operator, + { + config: clData.config || ({} as ClientConfig), + license: clData.license || ({} as ClientLicense), + currentTeamId: initialTeam?.id || '', + currentChannelId: initialChannel?.id || '', + }, + ); + if (systemModels) { + modelPromises.push(systemModels); + } + + if (initialTeam && initialChannel) { + try { + const tch = addChannelToTeamHistory(operator, initialTeam.id, initialChannel.id, true); + modelPromises.push(tch); + } catch { + // do nothing + } + } + + const models = await Promise.all(modelPromises); + if (models.length) { + await operator.batchRecords(models.flat() as Model[]); + } + + const config = clData.config || {} as ClientConfig; + const license = clData.license || {} as ClientLicense; + deferredAppEntryActions(serverUrl, user.id, user.locale, prefData.preferences, config, license, teamData, chData, initialTeam?.id, initialChannel?.id); + + const error = clData.error || prefData.error || teamData.error || chData?.error; + return {error, time: Date.now() - dt, hasTeams: Boolean((myTeams?.length || 0) > 0 && !teamData.error)}; + } catch (error) { + const systemModels = await prepareCommonSystemValues(operator, { + config: ({} as ClientConfig), + license: ({} as ClientLicense), + currentTeamId: '', + currentChannelId: '', + }); + if (systemModels) { + await operator.batchRecords(systemModels); + } + + return {error}; + } +}; diff --git a/app/actions/remote/entry/notification.ts b/app/actions/remote/entry/notification.ts new file mode 100644 index 000000000..5e5a61beb --- /dev/null +++ b/app/actions/remote/entry/notification.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Model} from '@nozbe/watermelondb'; + +import {switchToChannel} from '@actions/local/channel'; +import {markChannelAsRead} from '@actions/remote/channel'; +import {fetchRoles} from '@actions/remote/role'; +import {Screens} from '@constants'; +import DatabaseManager from '@database/manager'; +import {queryChannelsById, queryDefaultChannelForTeam, queryMyChannel} from '@queries/servers/channel'; +import {prepareModels} from '@queries/servers/entry'; +import {queryCommonSystemValues, queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system'; +import {deleteMyTeams, queryMyTeamById, queryTeamsById} from '@queries/servers/team'; +import {queryCurrentUser} from '@queries/servers/user'; +import EphemeralStore from '@store/ephemeral_store'; +import {isTablet} from '@utils/helpers'; +import {emitNotificationError} from '@utils/notification'; + +import {AppEntryData, AppEntryError, deferredAppEntryActions, fetchAppEntryData} from './common'; + +export const pushNotificationEntry = async (serverUrl: string, notification: NotificationWithData) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + // We only reach this point if we have a channel Id in the notification payload + const channelId = notification.payload!.channel_id!; + const isTabletDevice = await isTablet(); + const {database} = operator; + const currentTeamId = await queryCurrentTeamId(database); + const currentServerUrl = await DatabaseManager.getActiveServerUrl(); + let isDirectChannel = false; + + let teamId = notification.payload?.team_id; + if (!teamId) { + // If the notification payload does not have a teamId we assume is a DM/GM + isDirectChannel = true; + teamId = currentTeamId; + } + + if (currentServerUrl !== serverUrl) { + await DatabaseManager.setActiveServerDatabase(serverUrl); + } + + // To make the switch faster we determine if we already have the team & channel + const myChannel = await queryMyChannel(database, channelId); + const myTeam = await queryMyTeamById(database, teamId); + let switchedToTeamAndChanel = false; + if (myChannel && myTeam) { + switchedToTeamAndChanel = true; + await EphemeralStore.waitUntilScreenHasLoaded(Screens.HOME); + markChannelAsRead(serverUrl, channelId); + await switchToChannel(serverUrl, channelId, teamId); + } + + const fetchedData = await fetchAppEntryData(serverUrl, teamId); + const fetchedError = (fetchedData as AppEntryError).error; + + if (fetchedError) { + return {error: fetchedError}; + } + + const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData; + + // There is a chance that after the above request returns + // the user is no longer part of the team or channel + // that triggered the notification (rare but possible) + let selectedTeamId = teamId; + let selectedChannelId = channelId; + if (initialTeamId !== teamId) { + // We are no longer a part of the team that the notification belongs to + // Immediately set the new team as the current team in the database so that the UI + // renders the correct team. + + selectedTeamId = initialTeamId; + if (!isDirectChannel) { + if (isTabletDevice) { + const channel = await queryDefaultChannelForTeam(operator.database, selectedTeamId); + selectedChannelId = channel?.id || ''; + } else { + selectedChannelId = ''; + } + } + } + + if (removeChannelIds?.includes(channelId)) { + // We are no longer a part of the channel that the notification belongs to + // Immediately set the new channel as the current channel in the database so that the UI + // renders the correct channel. + + if (isTabletDevice) { + const channel = await queryDefaultChannelForTeam(operator.database, selectedTeamId); + selectedChannelId = channel?.id || ''; + } else { + selectedChannelId = ''; + } + } + + // If in the end the selected team or channel is different than the one from the notification + // we switch again + if (selectedTeamId !== teamId || selectedChannelId !== channelId) { + setCurrentTeamAndChannelId(operator, selectedTeamId, selectedChannelId); + } + + if (selectedTeamId !== teamId) { + emitNotificationError('Team'); + } else if (selectedChannelId === channelId) { + if (!switchedToTeamAndChanel) { + markChannelAsRead(serverUrl, channelId); + switchToChannel(serverUrl, channelId, teamId); + } + } else { + emitNotificationError('Channel'); + } + + let removeTeams; + if (removeTeamIds?.length) { + // Immediately delete myTeams so that the UI renders only teams the user is a member of. + removeTeams = await queryTeamsById(operator.database, removeTeamIds); + await deleteMyTeams(operator, removeTeams!); + } + + fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user); + + let removeChannels; + if (removeChannelIds?.length) { + removeChannels = await queryChannelsById(operator.database, removeChannelIds); + } + + const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData}); + const models = await Promise.all(modelPromises); + if (models.length) { + await operator.batchRecords(models.flat() as Model[]); + } + + const {id: currentUserId, locale: currentUserLocale} = meData.user || (await queryCurrentUser(operator.database))!; + const {config, license} = await queryCommonSystemValues(operator.database); + + deferredAppEntryActions(serverUrl, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, selectedTeamId, selectedChannelId); + const error = teamData.error || chData?.error || prefData.error || meData.error; + return {error, userId: meData?.user?.id}; +}; diff --git a/app/actions/remote/notifications.ts b/app/actions/remote/notifications.ts new file mode 100644 index 000000000..d794ecf50 --- /dev/null +++ b/app/actions/remote/notifications.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Platform} from 'react-native'; + +import {switchToChannel} from '@actions/local/channel'; +import {updatePostSinceCache} from '@actions/local/notification'; +import {fetchMissingSidebarInfo, fetchMyChannel, markChannelAsRead} from '@actions/remote/channel'; +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import {fetchMyTeam} from '@actions/remote/team'; +import {Preferences} from '@constants'; +import DatabaseManager from '@database/manager'; +import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; +import {queryChannelsById, queryMyChannel} from '@queries/servers/channel'; +import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; +import {queryCommonSystemValues} from '@queries/servers/system'; +import {queryMyTeamById} from '@queries/servers/team'; +import {queryCurrentUser} from '@queries/servers/user'; +import {emitNotificationError} from '@utils/notification'; + +import {fetchPostsForChannel} from './post'; + +const fetchNotificationData = async (serverUrl: string, notification: NotificationWithData, skipEvents = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + const channelId = notification.payload!.channel_id!; + const {database} = operator; + const system = await queryCommonSystemValues(database); + let teamId = notification.payload?.team_id; + let isDirectChannel = false; + + if (!teamId) { + // If the notification payload does not have a teamId we assume is a DM/GM + isDirectChannel = true; + teamId = system.currentTeamId; + } + + // To make the switch faster we determine if we already have the team & channel + const myChannel = await queryMyChannel(database, channelId); + const myTeam = await queryMyTeamById(database, teamId); + + if (!myTeam) { + const teamsReq = await fetchMyTeam(serverUrl, teamId, false); + if (teamsReq.error || !teamsReq.memberships?.length) { + if (!skipEvents) { + emitNotificationError('Team'); + } + return {error: teamsReq.error || 'Team'}; + } + } + + if (!myChannel) { + // We only fetch the channel that the notification belongs to + const channelReq = await fetchMyChannel(serverUrl, teamId, channelId); + if (channelReq.error || + !channelReq.channels?.find((c) => c.id === channelId && c.delete_at === 0) || + !channelReq.memberships?.find((m) => m.channel_id === channelId)) { + if (!skipEvents) { + emitNotificationError('Channel'); + } + return {error: channelReq.error || 'Channel'}; + } + + if (isDirectChannel) { + const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT); + const currentUser = await queryCurrentUser(database); + const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, system.license); + const channel = await queryChannelsById(database, [channelId]); + if (channel?.length) { + fetchMissingSidebarInfo(serverUrl, [channel[0].toApi()], currentUser?.locale, teammateDisplayNameSetting, currentUser?.id); + } + } + } + + fetchPostsForChannel(serverUrl, channelId); + return {}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const backgroundNotification = async (serverUrl: string, notification: NotificationWithData) => { + if (Platform.OS === 'ios') { + updatePostSinceCache(serverUrl, notification); + } + + await fetchNotificationData(serverUrl, notification, true); +}; + +export const openNotification = async (serverUrl: string, notification: NotificationWithData) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + try { + const channelId = notification.payload!.channel_id!; + await markChannelAsRead(serverUrl, channelId); + + const {database} = operator; + const system = await queryCommonSystemValues(database); + const currentServerUrl = await DatabaseManager.getActiveServerUrl(); + let teamId = notification.payload?.team_id; + + if (!teamId) { + // If the notification payload does not have a teamId we assume is a DM/GM + teamId = system.currentTeamId; + } + + if (currentServerUrl !== serverUrl) { + await DatabaseManager.setActiveServerDatabase(serverUrl); + } + + // To make the switch faster we determine if we already have the team & channel + const myChannel = await queryMyChannel(database, channelId); + const myTeam = await queryMyTeamById(database, teamId); + + if (myChannel && myTeam) { + fetchPostsForChannel(serverUrl, channelId); + switchToChannel(serverUrl, channelId, teamId); + return {}; + } + + const result = await fetchNotificationData(serverUrl, notification); + if (result.error) { + return {error: result.error}; + } + + return switchToChannel(serverUrl, channelId, teamId); + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; diff --git a/app/actions/remote/session.ts b/app/actions/remote/session.ts index 03510db86..3096de76f 100644 --- a/app/actions/remote/session.ts +++ b/app/actions/remote/session.ts @@ -86,7 +86,7 @@ export const getSessions = async (serverUrl: string, currentUserId: string) => { return undefined; }; -export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaToken, password}: LoginArgs): Promise => { +export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaToken, password, config}: LoginArgs): Promise => { let deviceToken; let user: UserProfile; @@ -116,6 +116,7 @@ export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaTo config: { dbName: serverUrl, serverUrl, + identifier: config.DiagnosticId, }, }); await DatabaseManager.setActiveServerDatabase(serverUrl); @@ -179,7 +180,7 @@ export const sendPasswordResetEmail = async (serverUrl: string, email: string) = }; }; -export const ssoLogin = async (serverUrl: string, bearerToken: string, csrfToken: string): Promise => { +export const ssoLogin = async (serverUrl: string, serverIdentifier: string, bearerToken: string, csrfToken: string): Promise => { let deviceToken; let user; @@ -204,6 +205,7 @@ export const ssoLogin = async (serverUrl: string, bearerToken: string, csrfToken config: { dbName: serverUrl, serverUrl, + identifier: serverIdentifier, }, }); await DatabaseManager.setActiveServerDatabase(serverUrl); diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 07bfbc6c9..d5fcc458f 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -126,6 +126,44 @@ export const fetchMyTeams = async (serverUrl: string, fetchOnly = false): Promis } }; +export const fetchMyTeam = async (serverUrl: string, teamId: string, fetchOnly = false): Promise => { + let client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const [team, membership] = await Promise.all([ + client.getTeam(teamId), + client.getTeamMember(teamId, 'me'), + ]); + if (!fetchOnly) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + const modelPromises: Array> = []; + if (operator) { + const prepare = prepareMyTeams(operator, [team], [membership]); + if (prepare) { + modelPromises.push(...prepare); + } + if (modelPromises.length) { + const models = await Promise.all(modelPromises); + const flattenedModels = models.flat() as Model[]; + if (flattenedModels?.length > 0) { + await operator.batchRecords(flattenedModels); + } + } + } + } + + return {teams: [team], memberships: [membership]}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientError); + return {error}; + } +}; + export const fetchAllTeams = async (serverUrl: string, fetchOnly = false): Promise => { let client; try { diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 3dacb1e6a..a3405adb3 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -3,7 +3,7 @@ import {DeviceEventEmitter} from 'react-native'; -import {fetchMyChannelsForTeam} from '@actions/remote/channel'; +import {fetchMissingSidebarInfo, fetchMyChannelsForTeam} from '@actions/remote/channel'; import {fetchPostsSince} from '@actions/remote/post'; import {fetchMyPreferences} from '@actions/remote/preference'; import {fetchRoles} from '@actions/remote/role'; @@ -11,14 +11,19 @@ import {fetchConfigAndLicense} from '@actions/remote/systems'; import {fetchAllTeams, fetchMyTeams} from '@actions/remote/team'; import {fetchMe, updateAllUsersSinceLastDisconnect} from '@actions/remote/user'; import Events from '@app/constants/events'; -import {WebsocketEvents} from '@constants'; +import {General, WebsocketEvents} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; +import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; +import {prepareMyChannelsForTeam} from '@queries/servers/channel'; import {queryCommonSystemValues, queryConfig, queryWebSocketLastDisconnected} from '@queries/servers/system'; +import {queryCurrentUser} from '@queries/servers/user'; import {handleChannelDeletedEvent, handleUserRemovedEvent} from './channel'; import {handleLeaveTeamEvent} from './teams'; +import type {Model} from '@nozbe/watermelondb'; + export async function handleFirstConnect(serverUrl: string) { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { @@ -64,7 +69,7 @@ async function doReconnect(serverUrl: string) { return; } - const {currentUserId, currentTeamId, currentChannelId} = await queryCommonSystemValues(database.database); + const system = await queryCommonSystemValues(database.database); const lastDisconnectedAt = await queryWebSocketLastDisconnected(database.database); // TODO consider fetch only and batch all the results. @@ -72,16 +77,42 @@ async function doReconnect(serverUrl: string) { fetchMyPreferences(serverUrl); const {config} = await fetchConfigAndLicense(serverUrl); const {memberships: teamMemberships, error: teamMembershipsError} = await fetchMyTeams(serverUrl); - + const {currentChannelId, currentUserId, currentTeamId, license} = system; const currentTeamMembership = teamMemberships?.find((tm) => tm.team_id === currentTeamId && tm.delete_at === 0); let channelMemberships: ChannelMembership[] | undefined; if (currentTeamMembership) { - const {memberships, channels, error} = await fetchMyChannelsForTeam(serverUrl, currentTeamMembership.team_id, false, lastDisconnectedAt); + const {memberships, channels, error} = await fetchMyChannelsForTeam(serverUrl, currentTeamMembership.team_id, true, lastDisconnectedAt, true); if (error) { DeviceEventEmitter.emit(Events.TEAM_LOAD_ERROR, serverUrl, error); return; } + const currentUser = await queryCurrentUser(database.database); + const preferences = currentUser ? (await currentUser.preferences.fetch()) : []; + const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, license); + const directChannels = channels?.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL); + if (directChannels?.length) { + await fetchMissingSidebarInfo(serverUrl, directChannels, currentUser?.locale, teammateDisplayNameSetting, currentUserId, true); + } + + const modelPromises: Array> = []; + const prepare = await prepareMyChannelsForTeam(database.operator, currentTeamMembership.team_id, channels!, memberships!); + if (prepare) { + modelPromises.push(...prepare); + } + if (modelPromises.length) { + const models = await Promise.all(modelPromises); + const flattenedModels = models.flat(); + if (flattenedModels?.length > 0) { + try { + await database.operator.batchRecords(flattenedModels); + } catch { + // eslint-disable-next-line no-console + console.log('FAILED TO BATCH CHANNELS'); + } + } + } + channelMemberships = memberships; if (currentChannelId) { diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index 30b480c8c..941c7a5d0 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -44,6 +44,13 @@ export default class ClientBase { } } + getAbsoluteUrl(baseUrl?: string) { + if (typeof baseUrl !== 'string' || !baseUrl.startsWith('/')) { + return baseUrl; + } + return this.apiClient.baseUrl + baseUrl; + } + getRequestHeaders(requestMethod: string) { const headers = {...this.requestHeaders}; diff --git a/app/client/websocket/index.ts b/app/client/websocket/index.ts index 39c8f0611..26d2c2e6b 100644 --- a/app/client/websocket/index.ts +++ b/app/client/websocket/index.ts @@ -136,13 +136,14 @@ export default class WebSocketClient { } if (this.connectFailCount > 0) { - console.log('websocket re-established connection'); //eslint-disable-line no-console + console.log('websocket re-established connection to', url); //eslint-disable-line no-console if (!reliableWebSockets && this.reconnectCallback) { this.reconnectCallback(); } else if (reliableWebSockets && this.serverSequence && this.missedEventsCallback) { this.missedEventsCallback(); } } else if (this.firstConnectCallback) { + console.log('websocket connected to', url); //eslint-disable-line no-console this.firstConnectCallback(); } @@ -159,7 +160,7 @@ export default class WebSocketClient { this.responseSequence = 1; if (this.connectFailCount === 0) { - console.log('websocket closed'); //eslint-disable-line no-console + console.log('websocket closed', url); //eslint-disable-line no-console } this.connectFailCount++; @@ -200,7 +201,7 @@ export default class WebSocketClient { this.conn!.onError((evt: any) => { if (this.connectFailCount <= 1) { - console.log('websocket error'); //eslint-disable-line no-console + console.log('websocket error', url); //eslint-disable-line no-console console.log(evt); //eslint-disable-line no-console } @@ -224,14 +225,14 @@ export default class WebSocketClient { // We check the hello packet, which is always the first packet in a stream. if (msg.event === WebsocketEvents.HELLO && this.reconnectCallback) { //eslint-disable-next-line no-console - console.log('got connection id ', msg.data.connection_id); + console.log(url, 'got connection id ', msg.data.connection_id); // If we already have a connectionId present, and server sends a different one, // that means it's either a long timeout, or server restart, or sequence number is not found. // Then we do the sync calls, and reset sequence number to 0. if (this.connectionId !== '' && this.connectionId !== msg.data.connection_id) { //eslint-disable-next-line no-console - console.log('long timeout, or server restart, or sequence number is not found.'); + console.log(url, 'long timeout, or server restart, or sequence number is not found.'); this.reconnectCallback(); this.serverSequence = 0; } @@ -245,7 +246,7 @@ export default class WebSocketClient { // we just disconnect and reconnect. if (msg.seq !== this.serverSequence) { // eslint-disable-next-line no-console - console.log('missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence); + console.log(url, 'missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence); // We are not calling this.close() because we need to auto-restart. this.connectFailCount = 0; @@ -255,7 +256,7 @@ export default class WebSocketClient { } } else if (msg.seq !== this.serverSequence && this.reconnectCallback) { // eslint-disable-next-line no-console - console.log('missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence); + console.log(url, 'missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence); this.reconnectCallback(); } diff --git a/app/components/markdown/markdown_link/index.tsx b/app/components/markdown/markdown_link/index.tsx index f070e3fa0..a0629d73b 100644 --- a/app/components/markdown/markdown_link/index.tsx +++ b/app/components/markdown/markdown_link/index.tsx @@ -12,8 +12,8 @@ import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import urlParse from 'url-parse'; -import {switchToChannelByName} from '@actions/local/channel'; import {showPermalink} from '@actions/local/permalink'; +import {switchToChannelByName} from '@actions/remote/channel'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; import {Navigation} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; diff --git a/app/components/post_list/date_separator/index.tsx b/app/components/post_list/date_separator/index.tsx index ae8d0502b..5c3212390 100644 --- a/app/components/post_list/date_separator/index.tsx +++ b/app/components/post_list/date_separator/index.tsx @@ -12,7 +12,7 @@ type DateSeparatorProps = { date: number | Date; style?: StyleProp; theme: Theme; - timezone?: UserTimezone | null; + timezone?: string | null; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { diff --git a/app/components/post_list/index.tsx b/app/components/post_list/index.tsx index 9b8802594..53c992891 100644 --- a/app/components/post_list/index.tsx +++ b/app/components/post_list/index.tsx @@ -5,7 +5,7 @@ import {Q} from '@nozbe/watermelondb'; import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import React, {ReactElement, useCallback} from 'react'; -import {DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleSheet, ViewToken} from 'react-native'; +import {AppStateStatus, DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleSheet, ViewToken} from 'react-native'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; @@ -19,6 +19,7 @@ import {useTheme} from '@context/theme'; import {getPreferenceAsBool} from '@helpers/api/preference'; import {emptyFunction} from '@utils/general'; import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList} from '@utils/post_list'; +import {getTimezone} from '@utils/user'; import type {WithDatabaseArgs} from '@typings/database/database'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; @@ -36,7 +37,7 @@ type RefreshProps = { } type Props = { - currentTimezone: UserTimezone | null; + currentTimezone: string | null; currentUsername: string; isTimezoneEnabled: boolean; lastViewedAt: number; @@ -142,7 +143,7 @@ const PostList = ({currentTimezone, currentUsername, isTimezoneEnabled, lastView date={getDateForDateLine(item)} theme={theme} style={style.scale} - timezone={currentTimezone} + timezone={isTimezoneEnabled ? currentTimezone : null} /> ); } @@ -214,36 +215,35 @@ const PostList = ({currentTimezone, currentUsername, isTimezoneEnabled, lastView keyExtractor={(item) => (typeof item === 'string' ? item : item.id)} style={{flex: 1}} contentContainerStyle={{paddingTop: 5}} - initialNumToRender={25} - maxToRenderPerBatch={25} + initialNumToRender={10} + maxToRenderPerBatch={10} removeClippedSubviews={true} onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={VIEWABILITY_CONFIG} - windowSize={30} scrollEventThrottle={60} /> ); }; -const withPosts = withObservables(['channelId'], ({database, channelId}: {channelId: string} & WithDatabaseArgs) => { +const withPosts = withObservables(['channelId', 'forceQueryAfterAppState'], ({database, channelId}: {channelId: string; forceQueryAfterAppState: AppStateStatus} & WithDatabaseArgs) => { const currentUser = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( switchMap((currentUserId) => database.get(USER).findAndObserve(currentUserId.value)), ); return { - currentTimezone: currentUser.pipe((switchMap((user) => of$(user.timezone)))), + currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))), currentUsername: currentUser.pipe((switchMap((user) => of$(user.username)))), isTimezoneEnabled: database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')), ), lastViewedAt: database.get(MY_CHANNEL).findAndObserve(channelId).pipe( - switchMap((myChannel) => of$(myChannel.lastViewedAt)), + switchMap((myChannel) => of$(myChannel.viewedAt)), ), posts: database.get(POSTS_IN_CHANNEL).query( Q.where('channel_id', channelId), Q.experimentalSortBy('latest', Q.desc), - ).observe().pipe( + ).observeWithColumns(['earliest', 'latest']).pipe( switchMap((postsInChannel) => { if (!postsInChannel.length) { return of$([]); @@ -269,4 +269,4 @@ const withPosts = withObservables(['channelId'], ({database, channelId}: {channe }; }); -export default withDatabase(withPosts(React.memo(PostList))); +export default withDatabase(withPosts(PostList)); diff --git a/app/components/team_sidebar/team_list/team_list.tsx b/app/components/team_sidebar/team_list/team_list.tsx index 1f21fdad5..cf1e68a98 100644 --- a/app/components/team_sidebar/team_list/team_list.tsx +++ b/app/components/team_sidebar/team_list/team_list.tsx @@ -29,9 +29,10 @@ export default function TeamList({myOrderedTeams}: Props) { bounces={false} contentContainerStyle={styles.contentContainer} data={myOrderedTeams} - fadingEdgeLength={36} + fadingEdgeLength={30} keyExtractor={keyExtractor} renderItem={renderTeam} + showsVerticalScrollIndicator={false} /> ); @@ -44,5 +45,6 @@ const styles = StyleSheet.create({ contentContainer: { alignItems: 'center', marginVertical: 6, + paddingBottom: 10, }, }); diff --git a/app/constants/database.ts b/app/constants/database.ts index 50ef81520..c7ad7ef2f 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -63,7 +63,6 @@ export const SYSTEM_IDENTIFIERS = { export const GLOBAL_IDENTIFIERS = { DEVICE_TOKEN: 'deviceToken', - MENTION_COUNT: 'mentionCount', }; export default { diff --git a/app/constants/events.ts b/app/constants/events.ts index c9777d319..5e2cab4c0 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -5,8 +5,9 @@ import keyMirror from '@utils/key_mirror'; export default keyMirror({ ACCOUNT_SELECT_TABLET_VIEW: null, + CHANNEL_DELETED: null, LEAVE_CHANNEL: null, LEAVE_TEAM: null, + NOTIFICATION_ERROR: null, TEAM_LOAD_ERROR: null, - CHANNEL_DELETED: null, }); diff --git a/app/constants/navigation.ts b/app/constants/navigation.ts index bbf0ec474..ec52c20ff 100644 --- a/app/constants/navigation.ts +++ b/app/constants/navigation.ts @@ -5,6 +5,7 @@ import keyMirror from '@utils/key_mirror'; const Navigation = keyMirror({ NAVIGATION_CLOSE_MODAL: null, + NAVIGATION_HOME: null, NAVIGATION_NO_TEAMS: null, NAVIGATION_ERROR_TEAMS: null, NAVIGATION_SHOW_OVERLAY: null, diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 085ab32d8..1fb0f0201 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -11,6 +11,7 @@ export const CUSTOM_STATUS = 'CustomStatus'; export const FORGOT_PASSWORD = 'ForgotPassword'; export const HOME = 'Home'; export const INTEGRATION_SELECTOR = 'IntegrationSelector'; +export const IN_APP_NOTIFICATION = 'InAppNotification'; export const LOGIN = 'Login'; export const LOGIN_OPTIONS = 'LoginOptions'; export const MAIN_SIDEBAR = 'MainSidebar'; @@ -33,6 +34,7 @@ export default { FORGOT_PASSWORD, HOME, INTEGRATION_SELECTOR, + IN_APP_NOTIFICATION, LOGIN, LOGIN_OPTIONS, MAIN_SIDEBAR, diff --git a/app/database/components/index.tsx b/app/database/components/index.tsx index 029ed1563..45c18721b 100644 --- a/app/database/components/index.tsx +++ b/app/database/components/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database} from '@nozbe/watermelondb'; +import {Database, Q} from '@nozbe/watermelondb'; import DatabaseProvider from '@nozbe/watermelondb/DatabaseProvider'; import React, {ComponentType, useEffect, useState} from 'react'; @@ -26,23 +26,27 @@ export function withServerDatabase(Component: ComponentType): ComponentTyp const db = DatabaseManager.appDatabase?.database; const observer = (servers: ServersModel[]) => { - const server = servers.reduce((a, b) => + const server = servers?.length ? servers.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a), - ); + ) : undefined; - const serverDatabase = - DatabaseManager.serverDatabases[server?.url]?.database; + if (server) { + const serverDatabase = + DatabaseManager.serverDatabases[server?.url]?.database; - setState({ - database: serverDatabase, - serverUrl: server?.url, - }); + setState({ + database: serverDatabase, + serverUrl: server?.url, + }); + } else { + setState(undefined); + } }; useEffect(() => { const subscription = db?.collections. get(SERVERS). - query(). + query(Q.where('identifier', Q.notEq(''))). observeWithColumns(['last_active_at']). subscribe(observer); diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index 4c15ed8e6..225f41d14 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -6,7 +6,6 @@ import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'; import logger from '@nozbe/watermelondb/utils/common/logger'; import {DeviceEventEmitter, Platform} from 'react-native'; import {FileSystem} from 'react-native-unimodules'; -import urlParse from 'url-parse'; import {MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; @@ -22,7 +21,7 @@ import AppDataOperator from '@database/operator/app_data_operator'; import ServerDataOperator from '@database/operator/server_data_operator'; import {schema as appSchema} from '@database/schema/app'; import {serverSchema} from '@database/schema/server'; -import {queryActiveServer, queryServer} from '@queries/app/servers'; +import {queryActiveServer, queryServer, queryServerByIdentifier} from '@queries/app/servers'; import {DatabaseType} from '@typings/database/enums'; import {deleteIOSDatabase} from '@utils/mattermost_managed'; import {hashCode} from '@utils/security'; @@ -96,7 +95,7 @@ class DatabaseManager { }; public createServerDatabase = async ({config}: CreateServerDatabaseArgs): Promise => { - const {dbName, displayName, serverUrl} = config; + const {dbName, displayName, identifier, serverUrl} = config; if (serverUrl) { try { @@ -111,6 +110,7 @@ class DatabaseManager { await this.addServerToAppDatabase({ databaseFilePath, displayName: displayName || dbName, + identifier, serverUrl, }); @@ -139,30 +139,56 @@ class DatabaseManager { }); }; - private addServerToAppDatabase = async ({databaseFilePath, displayName, serverUrl}: RegisterServerDatabaseArgs): Promise => { + private addServerToAppDatabase = async ({databaseFilePath, displayName, identifier = '', serverUrl}: RegisterServerDatabaseArgs): Promise => { try { - const isServerPresent = await this.isServerPresent(serverUrl); + const appDatabase = this.appDatabase?.database; + if (appDatabase) { + const isServerPresent = await this.isServerPresent(serverUrl); - if (this.appDatabase?.database && !isServerPresent) { - const appDatabase = this.appDatabase.database; - await appDatabase.write(async () => { - const serversCollection = appDatabase.collections.get(SERVERS); - await serversCollection.create((server: ServersModel) => { - server.dbPath = databaseFilePath; - server.displayName = displayName; - server.mentionCount = 0; - server.unreadCount = 0; - server.url = serverUrl; - server.isSecured = urlParse(serverUrl).protocol === 'https'; - server.lastActiveAt = 0; + if (!isServerPresent) { + await appDatabase.write(async () => { + const serversCollection = appDatabase.collections.get(SERVERS); + await serversCollection.create((server: ServersModel) => { + server.dbPath = databaseFilePath; + server.displayName = displayName; + server.url = serverUrl; + server.identifier = identifier; + server.lastActiveAt = 0; + }); }); - }); + } else if (identifier) { + await this.updateServerIdentifier(serverUrl, identifier); + } } } catch (e) { // do nothing } }; + public updateServerIdentifier = async (serverUrl: string, identifier: string) => { + const appDatabase = this.appDatabase?.database; + if (appDatabase) { + const server = await queryServer(appDatabase, serverUrl); + await appDatabase.write(async () => { + await server.update((record) => { + record.identifier = identifier; + }); + }); + } + } + + public updateServerDisplayName = async (serverUrl: string, displayName: string) => { + const appDatabase = this.appDatabase?.database; + if (appDatabase) { + const server = await queryServer(appDatabase, serverUrl); + await appDatabase.write(async () => { + await server.update((record) => { + record.displayName = displayName; + }); + }); + } + } + private isServerPresent = async (serverUrl: string): Promise => { if (this.appDatabase?.database) { const server = await queryServer(this.appDatabase.database, serverUrl); @@ -182,6 +208,16 @@ class DatabaseManager { return null; } + public getServerUrlFromIdentifier = async (identifier: string): Promise => { + const database = this.appDatabase?.database; + if (database) { + const server = await queryServerByIdentifier(database, identifier); + return server?.url; + } + + return undefined; + } + public getActiveServerDatabase = async (): Promise => { const database = this.appDatabase?.database; if (database) { @@ -216,6 +252,7 @@ class DatabaseManager { database.write(async () => { await server.update((record) => { record.lastActiveAt = 0; + record.identifier = ''; }); }); diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index 388a66d8c..127e6d76b 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -7,7 +7,6 @@ import logger from '@nozbe/watermelondb/utils/common/logger'; import {DeviceEventEmitter, Platform} from 'react-native'; import DeviceInfo from 'react-native-device-info'; import {FileSystem} from 'react-native-unimodules'; -import urlParse from 'url-parse'; import {MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; @@ -23,7 +22,7 @@ import AppDataOperator from '@database/operator/app_data_operator'; import ServerDataOperator from '@database/operator/server_data_operator'; import {schema as appSchema} from '@database/schema/app'; import {serverSchema} from '@database/schema/server'; -import {queryActiveServer, queryServer} from '@queries/app/servers'; +import {queryActiveServer, queryServer, queryServerByIdentifier} from '@queries/app/servers'; import {DatabaseType} from '@typings/database/enums'; import {deleteIOSDatabase, getIOSAppGroupDetails} from '@utils/mattermost_managed'; import {hashCode} from '@utils/security'; @@ -34,372 +33,416 @@ const {SERVERS} = MM_TABLES.APP; const APP_DATABASE = 'app'; class DatabaseManager { - public appDatabase?: AppDatabase; - public serverDatabases: ServerDatabases = {}; - private readonly appModels: Models; - private readonly databaseDirectory: string | null; - private readonly serverModels: Models; + public appDatabase?: AppDatabase; + public serverDatabases: ServerDatabases = {}; + private readonly appModels: Models; + private readonly databaseDirectory: string | null; + private readonly serverModels: Models; - constructor() { - this.appModels = [InfoModel, GlobalModel, ServersModel]; - this.serverModels = [ - ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, - GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, - PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, - SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, - TermsOfServiceModel, UserModel, - ]; + constructor() { + this.appModels = [InfoModel, GlobalModel, ServersModel]; + this.serverModels = [ + ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, + GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, + PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, + SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, + TermsOfServiceModel, UserModel, + ]; - this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : FileSystem.documentDirectory; - } + this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : `${FileSystem.documentDirectory}databases/`; + } - /** - * init : Retrieves all the servers registered in the default database - * @param {string[]} serverUrls - * @returns {Promise} - */ - public init = async (serverUrls: string[]): Promise => { - await this.createAppDatabase(); - for await (const serverUrl of serverUrls) { - await this.initServerDatabase(serverUrl); - } - this.appDatabase?.operator.handleInfo({ - info: [{ - build_number: DeviceInfo.getBuildNumber(), - created_at: Date.now(), - version_number: DeviceInfo.getVersion(), - }], - prepareRecordsOnly: false, - }); - }; + /** + * init : Retrieves all the servers registered in the default database + * @param {string[]} serverUrls + * @returns {Promise} + */ + public init = async (serverUrls: string[]): Promise => { + await this.createAppDatabase(); + for await (const serverUrl of serverUrls) { + await this.initServerDatabase(serverUrl); + } + this.appDatabase?.operator.handleInfo({ + info: [{ + build_number: DeviceInfo.getBuildNumber(), + created_at: Date.now(), + version_number: DeviceInfo.getVersion(), + }], + prepareRecordsOnly: false, + }); + }; - /** - * createAppDatabase: Creates the App database. However, - * if a database could not be created, it will return undefined. - * @returns {Promise} - */ - private createAppDatabase = async (): Promise => { - try { - const databaseName = APP_DATABASE; + /** + * createAppDatabase: Creates the App database. However, + * if a database could not be created, it will return undefined. + * @returns {Promise} + */ + private createAppDatabase = async (): Promise => { + try { + const databaseName = APP_DATABASE; - const databaseFilePath = this.getDatabaseFilePath(databaseName); - const modelClasses = this.appModels; - const schema = appSchema; + if (Platform.OS === 'android') { + await FileSystem.makeDirectoryAsync(this.databaseDirectory!, {intermediates: true}); + } + const databaseFilePath = this.getDatabaseFilePath(databaseName); + const modelClasses = this.appModels; + const schema = appSchema; - const adapter = new SQLiteAdapter({ - dbName: databaseFilePath, - migrationEvents: this.buildMigrationCallbacks(databaseName), - migrations: AppDatabaseMigrations, - jsi: true, - schema, - }); + const adapter = new SQLiteAdapter({ + dbName: databaseFilePath, + migrationEvents: this.buildMigrationCallbacks(databaseName), + migrations: AppDatabaseMigrations, + jsi: true, + schema, + }); - const database = new Database({adapter, modelClasses}); - const operator = new AppDataOperator(database); + const database = new Database({adapter, modelClasses}); + const operator = new AppDataOperator(database); - this.appDatabase = { - database, - operator, - }; + this.appDatabase = { + database, + operator, + }; - return this.appDatabase; - } catch (e) { - // TODO : report to sentry? Show something on the UI ? - } + return this.appDatabase; + } catch (e) { + // TODO : report to sentry? Show something on the UI ? + } - return undefined; - }; + return undefined; + }; - /** - * createServerDatabase: Creates a server database and registers the the server in the app database. However, - * if a database connection could not be created, it will return undefined. - * @param {CreateServerDatabaseArgs} createServerDatabaseArgs - * - * @returns {Promise} - */ - public createServerDatabase = async ({config}: CreateServerDatabaseArgs): Promise => { - const {dbName, displayName, serverUrl} = config; + /** + * createServerDatabase: Creates a server database and registers the the server in the app database. However, + * if a database connection could not be created, it will return undefined. + * @param {CreateServerDatabaseArgs} createServerDatabaseArgs + * + * @returns {Promise} + */ + public createServerDatabase = async ({config}: CreateServerDatabaseArgs): Promise => { + const {dbName, displayName, identifier, serverUrl} = config; - if (serverUrl) { - try { - const databaseName = hashCode(serverUrl); - const databaseFilePath = this.getDatabaseFilePath(databaseName); - const migrations = ServerDatabaseMigrations; - const modelClasses = this.serverModels; - const schema = serverSchema; + if (serverUrl) { + try { + const databaseName = hashCode(serverUrl); + const databaseFilePath = this.getDatabaseFilePath(databaseName); + const migrations = ServerDatabaseMigrations; + const modelClasses = this.serverModels; + const schema = serverSchema; - const adapter = new SQLiteAdapter({ - dbName: databaseFilePath, - migrationEvents: this.buildMigrationCallbacks(databaseName), - migrations, - jsi: true, - schema, - }); + const adapter = new SQLiteAdapter({ + dbName: databaseFilePath, + migrationEvents: this.buildMigrationCallbacks(databaseName), + migrations, + jsi: true, + schema, + }); - // Registers the new server connection into the DEFAULT database - await this.addServerToAppDatabase({ - databaseFilePath, - displayName: displayName || dbName, - serverUrl, - }); + // Registers the new server connection into the DEFAULT database + await this.addServerToAppDatabase({ + databaseFilePath, + displayName: displayName || dbName, + identifier, + serverUrl, + }); - const database = new Database({adapter, modelClasses}); - const operator = new ServerDataOperator(database); - const serverDatabase = {database, operator}; + const database = new Database({adapter, modelClasses}); + const operator = new ServerDataOperator(database); + const serverDatabase = {database, operator}; - this.serverDatabases[serverUrl] = serverDatabase; + this.serverDatabases[serverUrl] = serverDatabase; - return serverDatabase; - } catch (e) { - // TODO : report to sentry? Show something on the UI ? - } - } + return serverDatabase; + } catch (e) { + // TODO : report to sentry? Show something on the UI ? + } + } - return undefined; - }; + return undefined; + }; - /** - * initServerDatabase : initializes the server database. - * @param {string} serverUrl - * @returns {Promise} - */ - private initServerDatabase = async (serverUrl: string): Promise => { - await this.createServerDatabase({ - config: { - dbName: serverUrl, - dbType: DatabaseType.SERVER, - serverUrl, - }, - }); - }; + /** + * initServerDatabase : initializes the server database. + * @param {string} serverUrl + * @returns {Promise} + */ + private initServerDatabase = async (serverUrl: string): Promise => { + await this.createServerDatabase({ + config: { + dbName: serverUrl, + dbType: DatabaseType.SERVER, + serverUrl, + }, + }); + }; - /** - * addServerToAppDatabase: Adds a record in the 'app' database - into the 'servers' table - for this new server connection - * @param {string} databaseFilePath - * @param {string} displayName - * @param {string} serverUrl - * @returns {Promise} - */ - private addServerToAppDatabase = async ({databaseFilePath, displayName, serverUrl}: RegisterServerDatabaseArgs): Promise => { - try { - const isServerPresent = await this.isServerPresent(serverUrl); + /** + * addServerToAppDatabase: Adds a record in the 'app' database - into the 'servers' table - for this new server connection + * @param {string} databaseFilePath + * @param {string} displayName + * @param {string} serverUrl + * @param {string} identifier + * @returns {Promise} + */ + private addServerToAppDatabase = async ({databaseFilePath, displayName, serverUrl, identifier = ''}: RegisterServerDatabaseArgs): Promise => { + try { + const appDatabase = this.appDatabase?.database; + if (appDatabase) { + const isServerPresent = await this.isServerPresent(serverUrl); - if (this.appDatabase?.database && !isServerPresent) { - const appDatabase = this.appDatabase.database; - await appDatabase.write(async () => { - const serversCollection = appDatabase.collections.get(SERVERS); - await serversCollection.create((server: ServersModel) => { - server.dbPath = databaseFilePath; - server.displayName = displayName; - server.mentionCount = 0; - server.unreadCount = 0; - server.url = serverUrl; - server.isSecured = urlParse(serverUrl).protocol === 'https'; - server.lastActiveAt = 0; - }); - }); - } - } catch (e) { - // TODO : report to sentry? Show something on the UI ? - } - }; + if (!isServerPresent) { + await appDatabase.write(async () => { + const serversCollection = appDatabase.collections.get(SERVERS); + await serversCollection.create((server: ServersModel) => { + server.dbPath = databaseFilePath; + server.displayName = displayName; + server.url = serverUrl; + server.identifier = identifier; + server.lastActiveAt = 0; + }); + }); + } else if (identifier) { + await this.updateServerIdentifier(serverUrl, identifier); + } + } + } catch (e) { + // TODO : report to sentry? Show something on the UI ? + } + }; - /** - * isServerPresent : Confirms if the current serverUrl does not already exist in the database - * @param {String} serverUrl - * @returns {Promise} - */ - private isServerPresent = async (serverUrl: string): Promise => { - if (this.appDatabase?.database) { - const server = await queryServer(this.appDatabase.database, serverUrl); - return Boolean(server); - } + public updateServerIdentifier = async (serverUrl: string, identifier: string) => { + const appDatabase = this.appDatabase?.database; + if (appDatabase) { + const server = await queryServer(appDatabase, serverUrl); + await appDatabase.write(async () => { + await server.update((record) => { + record.identifier = identifier; + }); + }); + } + } - return false; - } + public updateServerDisplayName = async (serverUrl: string, displayName: string) => { + const appDatabase = this.appDatabase?.database; + if (appDatabase) { + const server = await queryServer(appDatabase, serverUrl); + await appDatabase.write(async () => { + await server.update((record) => { + record.displayName = displayName; + }); + }); + } + } - /** - * getActiveServerUrl: Get the record for active server database. - * @returns {Promise} - */ - public getActiveServerUrl = async (): Promise => { - const database = this.appDatabase?.database; - if (database) { - const server = await queryActiveServer(database); - return server?.url; - } + /** + * isServerPresent : Confirms if the current serverUrl does not already exist in the database + * @param {String} serverUrl + * @returns {Promise} + */ + private isServerPresent = async (serverUrl: string): Promise => { + if (this.appDatabase?.database) { + const server = await queryServer(this.appDatabase.database, serverUrl); + return Boolean(server); + } - return null; - } + return false; + } - /** - * getActiveServerDatabase: Get the record for active server database. - * @returns {Promise} - */ - public getActiveServerDatabase = async (): Promise => { - const database = this.appDatabase?.database; - if (database) { - const server = await queryActiveServer(database); - if (server?.url) { - return this.serverDatabases[server.url]?.database; - } - } + /** + * getActiveServerUrl: Get the record for active server database. + * @returns {Promise} + */ + public getActiveServerUrl = async (): Promise => { + const database = this.appDatabase?.database; + if (database) { + const server = await queryActiveServer(database); + return server?.url; + } - return undefined; - } + return null; + } - /** - * setActiveServerDatabase: Set the new active server database. - * This method should be called when switching to another server. - * @param {string} serverUrl - * @returns {Promise} - */ - public setActiveServerDatabase = async (serverUrl: string): Promise => { - if (this.appDatabase?.database) { - const database = this.appDatabase?.database; - await database.write(async () => { - const servers = await database.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch(); - if (servers.length) { - servers[0].update((server: ServersModel) => { - server.lastActiveAt = Date.now(); - }); - } - }); - } - }; + public getServerUrlFromIdentifier = async (identifier: string): Promise => { + const database = this.appDatabase?.database; + if (database) { + const server = await queryServerByIdentifier(database, identifier); + return server?.url; + } - /** - * deleteServerDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android. - * Also, it sets the last_active_at to '0' entry in the 'servers' table from the APP database - * @param {string} serverUrl - * @returns {Promise} - */ - public deleteServerDatabase = async (serverUrl: string): Promise => { - if (this.appDatabase?.database) { - const database = this.appDatabase?.database; - const server = await queryServer(database, serverUrl); - if (server) { - database.write(async () => { - await server.update((record) => { - record.lastActiveAt = 0; - }); - }); + return undefined; + } - delete this.serverDatabases[serverUrl]; - this.deleteServerDatabaseFiles(serverUrl); - } - } - } + /** + * getActiveServerDatabase: Get the record for active server database. + * @returns {Promise} + */ + public getActiveServerDatabase = async (): Promise => { + const database = this.appDatabase?.database; + if (database) { + const server = await queryActiveServer(database); + if (server?.url) { + return this.serverDatabases[server.url]?.database; + } + } - /** - * destroyServerDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android. - * Also, removes the entry in the 'servers' table from the APP database - * @param {string} serverUrl - * @returns {Promise} - */ - public destroyServerDatabase = async (serverUrl: string): Promise => { - if (this.appDatabase?.database) { - const database = this.appDatabase?.database; - const server = await queryServer(database, serverUrl); - if (server) { - database.write(async () => { - await server.destroyPermanently(); - }); + return undefined; + } - delete this.serverDatabases[serverUrl]; - this.deleteServerDatabaseFiles(serverUrl); - } - } - } + /** + * setActiveServerDatabase: Set the new active server database. + * This method should be called when switching to another server. + * @param {string} serverUrl + * @returns {Promise} + */ + public setActiveServerDatabase = async (serverUrl: string): Promise => { + if (this.appDatabase?.database) { + const database = this.appDatabase?.database; + await database.write(async () => { + const servers = await database.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch(); + if (servers.length) { + servers[0].update((server: ServersModel) => { + server.lastActiveAt = Date.now(); + }); + } + }); + } + }; - /** - * deleteServerDatabaseFiles: Removes the *.db file from the App-Group directory for iOS or the files directory on Android. - * @param {string} serverUrl - * @returns {Promise} - */ - private deleteServerDatabaseFiles = async (serverUrl: string): Promise => { - const databaseName = hashCode(serverUrl); + /** + * deleteServerDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android. + * Also, it sets the last_active_at to '0' entry in the 'servers' table from the APP database + * @param {string} serverUrl + * @returns {Promise} + */ + public deleteServerDatabase = async (serverUrl: string): Promise => { + if (this.appDatabase?.database) { + const database = this.appDatabase?.database; + const server = await queryServer(database, serverUrl); + if (server) { + database.write(async () => { + await server.update((record) => { + record.lastActiveAt = 0; + record.identifier = ''; + }); + }); - if (Platform.OS === 'ios') { - // On iOS, we'll delete the *.db file under the shared app-group/databases folder - deleteIOSDatabase({databaseName}); - return; - } + delete this.serverDatabases[serverUrl]; + this.deleteServerDatabaseFiles(serverUrl); + } + } + } - // On Android, we'll delete both the *.db file and the *.db-journal file - const androidFilesDir = `${this.databaseDirectory}databases/`; - const databaseFile = `${androidFilesDir}${databaseName}.db`; - const databaseJournal = `${androidFilesDir}${databaseName}.db-journal`; + /** + * destroyServerDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android. + * Also, removes the entry in the 'servers' table from the APP database + * @param {string} serverUrl + * @returns {Promise} + */ + public destroyServerDatabase = async (serverUrl: string): Promise => { + if (this.appDatabase?.database) { + const database = this.appDatabase?.database; + const server = await queryServer(database, serverUrl); + if (server) { + database.write(async () => { + await server.destroyPermanently(); + }); - await FileSystem.deleteAsync(databaseFile); - await FileSystem.deleteAsync(databaseJournal); - } + delete this.serverDatabases[serverUrl]; + this.deleteServerDatabaseFiles(serverUrl); + } + } + } - /** - * factoryReset: Removes the databases directory and all its contents on the respective platform - * @param {boolean} shouldRemoveDirectory - * @returns {Promise} - */ - factoryReset = async (shouldRemoveDirectory: boolean): Promise => { - try { - //On iOS, we'll delete the databases folder under the shared AppGroup folder - if (Platform.OS === 'ios') { - deleteIOSDatabase({shouldRemoveDirectory}); - return true; - } + /** + * deleteServerDatabaseFiles: Removes the *.db file from the App-Group directory for iOS or the files directory on Android. + * @param {string} serverUrl + * @returns {Promise} + */ + private deleteServerDatabaseFiles = async (serverUrl: string): Promise => { + const databaseName = hashCode(serverUrl); - // On Android, we'll remove the databases folder under the Document Directory - const androidFilesDir = `${this.databaseDirectory}databases/`; - await FileSystem.deleteAsync(androidFilesDir); - return true; - } catch (e) { - return false; - } - }; + if (Platform.OS === 'ios') { + // On iOS, we'll delete the *.db file under the shared app-group/databases folder + deleteIOSDatabase({databaseName}); + return; + } - /** - * buildMigrationCallbacks: Creates a set of callbacks that can be used to monitor the migration process. - * For example, we can display a processing spinner while we have a migration going on. Moreover, we can also - * hook into those callbacks to assess how many of our servers successfully completed their migration. - * @param {string} dbName - * @returns {MigrationEvents} - */ - private buildMigrationCallbacks = (dbName: string) => { - const migrationEvents = { - onSuccess: () => { - return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_SUCCESS, { - dbName, - }); - }, - onStart: () => { - return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_STARTED, { - dbName, - }); - }, - onError: (error: Error) => { - return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_ERROR, { - dbName, - error, - }); - }, - }; + // On Android, we'll delete the *.db, the *.db-shm and *.db-wal files + const androidFilesDir = this.databaseDirectory; + const databaseFile = `${androidFilesDir}${databaseName}.db`; + const databaseShm = `${androidFilesDir}${databaseName}.db-shm`; + const databaseWal = `${androidFilesDir}${databaseName}.db-wal`; - return migrationEvents; - }; + FileSystem.deleteAsync(databaseFile); + FileSystem.deleteAsync(databaseShm); + FileSystem.deleteAsync(databaseWal); + } - /** - * getDatabaseFilePath: Using the database name, this method will return the database file path for each platform. - * On iOS, it will point towards the AppGroup shared directory while on Android, it will point towards the Files Directory. - * Please note that in each case, the *.db files will be created/grouped under a 'databases' sub-folder. - * iOS Simulator : appGroup => /Users/{username}/Library/Developer/CoreSimulator/Devices/DA6F1C73/data/Containers/Shared/AppGroup/ACA65327/databases"} - * Android Device: file:///data/user/0/com.mattermost.rnbeta/files/databases - * - * @param {string} dbName - * @returns {string} - */ - private getDatabaseFilePath = (dbName: string): string => { - return Platform.OS === 'ios' ? `${this.databaseDirectory}/${dbName}.db` : `${this.databaseDirectory}${dbName}.db`; - }; + /** + * factoryReset: Removes the databases directory and all its contents on the respective platform + * @param {boolean} shouldRemoveDirectory + * @returns {Promise} + */ + factoryReset = async (shouldRemoveDirectory: boolean): Promise => { + try { + //On iOS, we'll delete the databases folder under the shared AppGroup folder + if (Platform.OS === 'ios') { + deleteIOSDatabase({shouldRemoveDirectory}); + return true; + } + + // On Android, we'll remove the databases folder under the Document Directory + const androidFilesDir = `${this.databaseDirectory}databases/`; + await FileSystem.deleteAsync(androidFilesDir); + return true; + } catch (e) { + return false; + } + }; + + /** + * buildMigrationCallbacks: Creates a set of callbacks that can be used to monitor the migration process. + * For example, we can display a processing spinner while we have a migration going on. Moreover, we can also + * hook into those callbacks to assess how many of our servers successfully completed their migration. + * @param {string} dbName + * @returns {MigrationEvents} + */ + private buildMigrationCallbacks = (dbName: string) => { + const migrationEvents = { + onSuccess: () => { + return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_SUCCESS, { + dbName, + }); + }, + onStart: () => { + return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_STARTED, { + dbName, + }); + }, + onError: (error: Error) => { + return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_ERROR, { + dbName, + error, + }); + }, + }; + + return migrationEvents; + }; + + /** + * getDatabaseFilePath: Using the database name, this method will return the database file path for each platform. + * On iOS, it will point towards the AppGroup shared directory while on Android, it will point towards the Files Directory. + * Please note that in each case, the *.db files will be created/grouped under a 'databases' sub-folder. + * iOS Simulator : appGroup => /Users/{username}/Library/Developer/CoreSimulator/Devices/DA6F1C73/data/Containers/Shared/AppGroup/ACA65327/databases"} + * Android Device: file:///data/user/0/com.mattermost.rnbeta/files/databases + * + * @param {string} dbName + * @returns {string} + */ + private getDatabaseFilePath = (dbName: string): string => { + return Platform.OS === 'ios' ? `${this.databaseDirectory}/${dbName}.db` : `${this.databaseDirectory}${dbName}.db`; + }; } if (!__DEV__) { diff --git a/app/database/manager/test.ts b/app/database/manager/test.ts index faa5a63f5..c1745d450 100644 --- a/app/database/manager/test.ts +++ b/app/database/manager/test.ts @@ -19,6 +19,8 @@ describe('*** Database Manager tests ***', () => { const serverUrls = ['https://appv1.mattermost.com', 'https://appv2.mattermost.com']; beforeAll(async () => { await DatabaseManager.init(serverUrls); + await DatabaseManager.updateServerIdentifier(serverUrls[0], 'appv1'); + await DatabaseManager.updateServerIdentifier(serverUrls[1], 'appv2'); }); it('=> should return a default database', async () => { @@ -37,8 +39,9 @@ describe('*** Database Manager tests ***', () => { const connection1 = await DatabaseManager!.createServerDatabase({ config: { - dbName: 'community mattermost', - serverUrl: 'https://appv1.mattermost.com', + dbName: 'appv3 mattermost', + serverUrl: 'https://appv3.mattermost.com', + identifier: 'appv3', }, }); @@ -84,4 +87,11 @@ describe('*** Database Manager tests ***', () => { const activeServerUrl = await DatabaseManager.getActiveServerUrl(); expect(activeServerUrl).toEqual(serverUrls[1]); }); + + it('=> should return appv3 server url from the servers table of App database', async () => { + expect.assertions(1); + + const serverUrl = await DatabaseManager.getServerUrlFromIdentifier('appv3'); + expect(serverUrl).toBe('https://appv3.mattermost.com'); + }); }); diff --git a/app/database/manager/test_manual.ts b/app/database/manager/test_manual.ts index 838d4b8c1..d14f4d53d 100644 --- a/app/database/manager/test_manual.ts +++ b/app/database/manager/test_manual.ts @@ -34,6 +34,7 @@ export default async () => { dbName: 'community mattermost', dbType: DatabaseType.SERVER, serverUrl: 'https://comm4.mattermost.com', + identifier: 'test-server', }, }); }; diff --git a/app/database/models/app/servers.ts b/app/database/models/app/servers.ts index b2014fc94..1629db8b3 100644 --- a/app/database/models/app/servers.ts +++ b/app/database/models/app/servers.ts @@ -22,18 +22,12 @@ export default class ServersModel extends Model { /** display_name : The server display name */ @field('display_name') displayName!: string; - /** mention_count : The number of mention on this server */ - @field('mention_count') mentionCount!: number; - - /** unread_count : The number of unread messages on this server */ - @field('unread_count') unreadCount!: number; - /** url : The online address for the Mattermost server */ @field('url') url!: string; /** last_active_at: The last time this server was active */ @field('last_active_at') lastActiveAt!: number; - /** is_secured: Determines if the protocol used for this server url is HTTP or HTTPS */ - @field('is_secured') isSecured!: boolean; + /** identifier: Determines the installation identifier of a server */ + @field('identifier') identifier!: string; } diff --git a/app/database/models/server/channel.ts b/app/database/models/server/channel.ts index 529a1c571..baaea2bd3 100644 --- a/app/database/models/server/channel.ts +++ b/app/database/models/server/channel.ts @@ -98,7 +98,7 @@ export default class ChannelModel extends Model { @field('team_id') teamId!: string; /** type : The type of the channel ( e.g. G: group messages, D: direct messages, P: private channel and O: public channel) */ - @field('type') type!: string; + @field('type') type!: ChannelType; /** members : Users belonging to this channel */ @children(CHANNEL_MEMBERSHIP) members!: ChannelMembershipModel[]; @@ -130,4 +130,26 @@ export default class ChannelModel extends Model { /** settings: User specific settings/preferences for this channel */ @immutableRelation(MY_CHANNEL_SETTINGS, 'id') settings!: Relation; + + toApi = (): Channel => { + return { + id: this.id, + create_at: this.createAt, + update_at: this.updateAt, + delete_at: this.deleteAt, + team_id: this.teamId, + type: this.type, + display_name: this.displayName, + name: this.name, + header: '', + purpose: '', + last_post_at: 0, + total_msg_count: 0, + extra_update_at: 0, + creator_id: this.creatorId, + scheme_id: null, + group_constrained: null, + shared: this.shared, + }; + }; } diff --git a/app/database/models/server/my_channel.ts b/app/database/models/server/my_channel.ts index 91eacfed4..9925ca35f 100644 --- a/app/database/models/server/my_channel.ts +++ b/app/database/models/server/my_channel.ts @@ -40,6 +40,9 @@ export default class MyChannelModel extends Model { /** roles : The user's privileges on this channel */ @field('roles') roles!: string; + /** viewed_at : The timestamp showing when the user's last opened this channel (this is used for the new line message indicator) */ + @field('viewed_at') viewedAt!: number; + /** channel : The relation pointing to the CHANNEL table */ @immutableRelation(CHANNEL, 'id') channel!: Relation; } diff --git a/app/database/operator/server_data_operator/transformers/channel.ts b/app/database/operator/server_data_operator/transformers/channel.ts index ed1af536a..0a66c3b01 100644 --- a/app/database/operator/server_data_operator/transformers/channel.ts +++ b/app/database/operator/server_data_operator/transformers/channel.ts @@ -128,6 +128,7 @@ export const transformMyChannelRecord = ({action, database, value}: TransformerA myChannel.mentionsCount = raw.mention_count; myChannel.lastPostAt = raw.last_post_at || 0; myChannel.lastViewedAt = raw.last_viewed_at; + myChannel.viewedAt = record?.viewedAt || 0; }; return prepareBaseRecord({ diff --git a/app/database/operator/utils/post.ts b/app/database/operator/utils/post.ts index 0eb216a91..12f0a44c9 100644 --- a/app/database/operator/utils/post.ts +++ b/app/database/operator/utils/post.ts @@ -50,7 +50,7 @@ export const createPostsChain = ({order, posts, previousPostId = ''}: ChainPosts } return result; - }, [] as Post[]); + }, [] as Post[]).reverse(); }; export const getPostListEdges = (posts: Post[]) => { diff --git a/app/database/schema/app/index.ts b/app/database/schema/app/index.ts index 8141bb541..0cc2605c2 100644 --- a/app/database/schema/app/index.ts +++ b/app/database/schema/app/index.ts @@ -29,11 +29,9 @@ export const schema: AppSchema = appSchema({ columns: [ {name: 'db_path', type: 'string'}, {name: 'display_name', type: 'string'}, - {name: 'mention_count', type: 'number'}, - {name: 'unread_count', type: 'number'}, {name: 'url', type: 'string', isIndexed: true}, {name: 'last_active_at', type: 'number', isIndexed: true}, - {name: 'is_secured', type: 'boolean'}, + {name: 'identifier', type: 'string', isIndexed: true}, ], }), ], diff --git a/app/database/schema/app/table_schemas/servers.ts b/app/database/schema/app/table_schemas/servers.ts index 92c26a870..98f8d322c 100644 --- a/app/database/schema/app/table_schemas/servers.ts +++ b/app/database/schema/app/table_schemas/servers.ts @@ -12,10 +12,8 @@ export default tableSchema({ columns: [ {name: 'db_path', type: 'string'}, {name: 'display_name', type: 'string'}, - {name: 'mention_count', type: 'number'}, - {name: 'unread_count', type: 'number'}, {name: 'url', type: 'string', isIndexed: true}, {name: 'last_active_at', type: 'number', isIndexed: true}, - {name: 'is_secured', type: 'boolean'}, + {name: 'identifier', type: 'string', isIndexed: true}, ], }); diff --git a/app/database/schema/app/test.ts b/app/database/schema/app/test.ts index bfd5503ab..e582ccdab 100644 --- a/app/database/schema/app/test.ts +++ b/app/database/schema/app/test.ts @@ -7,8 +7,8 @@ import {schema} from './index'; const {INFO, GLOBAL, SERVERS} = MM_TABLES.APP; -describe('*** Test schema for DEFAULT database ***', () => { - it('=> The DEFAULT SCHEMA should strictly match', () => { +describe('*** Test schema for APP database ***', () => { + it('=> The APP SCHEMA should strictly match', () => { expect(schema).toEqual({ version: 1, tables: { @@ -39,20 +39,16 @@ describe('*** Test schema for DEFAULT database ***', () => { columns: { db_path: {name: 'db_path', type: 'string'}, display_name: {name: 'display_name', type: 'string'}, - mention_count: {name: 'mention_count', type: 'number'}, - unread_count: {name: 'unread_count', type: 'number'}, url: {name: 'url', type: 'string', isIndexed: true}, last_active_at: {name: 'last_active_at', type: 'number', isIndexed: true}, - is_secured: {name: 'is_secured', type: 'boolean'}, + identifier: {name: 'identifier', type: 'string', isIndexed: true}, }, columnArray: [ {name: 'db_path', type: 'string'}, {name: 'display_name', type: 'string'}, - {name: 'mention_count', type: 'number'}, - {name: 'unread_count', type: 'number'}, {name: 'url', type: 'string', isIndexed: true}, {name: 'last_active_at', type: 'number', isIndexed: true}, - {name: 'is_secured', type: 'boolean'}, + {name: 'identifier', type: 'string', isIndexed: true}, ], }, }, diff --git a/app/database/schema/server/table_schemas/my_channel.ts b/app/database/schema/server/table_schemas/my_channel.ts index cea26920c..89c7ffab3 100644 --- a/app/database/schema/server/table_schemas/my_channel.ts +++ b/app/database/schema/server/table_schemas/my_channel.ts @@ -16,5 +16,6 @@ export default tableSchema({ {name: 'mentions_count', type: 'number'}, {name: 'message_count', type: 'number'}, {name: 'roles', type: 'string'}, + {name: 'viewed_at', type: 'number'}, ], }); diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index 9d0dd6701..53765dda5 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -117,6 +117,7 @@ describe('*** Test schema for SERVER database ***', () => { mentions_count: {name: 'mentions_count', type: 'number'}, message_count: {name: 'message_count', type: 'number'}, roles: {name: 'roles', type: 'string'}, + viewed_at: {name: 'viewed_at', type: 'number'}, }, columnArray: [ {name: 'last_post_at', type: 'number'}, @@ -125,6 +126,7 @@ describe('*** Test schema for SERVER database ***', () => { {name: 'mentions_count', type: 'number'}, {name: 'message_count', type: 'number'}, {name: 'roles', type: 'string'}, + {name: 'viewed_at', type: 'number'}, ], }, [MY_CHANNEL_SETTINGS]: { diff --git a/app/hooks/device.ts b/app/hooks/device.ts index 848b017e2..f0186fca4 100644 --- a/app/hooks/device.ts +++ b/app/hooks/device.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {useEffect, useState} from 'react'; -import {NativeModules, useWindowDimensions} from 'react-native'; +import {AppState, NativeModules, useWindowDimensions} from 'react-native'; import {Device} from '@constants'; @@ -23,3 +23,22 @@ export function useSplitView() { return isSplitView; } + +export function useAppState() { + const [appState, setAppState] = useState(AppState.currentState); + + useEffect(() => { + const listener = AppState.addEventListener('change', (nextState) => { + setAppState(nextState); + }); + + return () => listener.remove(); + }, [appState]); + + return appState; +} + +export function useIsTablet() { + const isSplitView = useSplitView(); + return Device.IS_TABLET && !isSplitView; +} diff --git a/app/init/global_event_handler.ts b/app/init/global_event_handler.ts index 14d4867a1..19d7ba2af 100644 --- a/app/init/global_event_handler.ts +++ b/app/init/global_event_handler.ts @@ -5,6 +5,7 @@ import CookieManager, {Cookie} from '@react-native-cookies/cookies'; import {Alert, DeviceEventEmitter, Linking, Platform} from 'react-native'; import semver from 'semver'; +import {selectAllMyChannelIds} from '@actions/local/channel'; import {fetchConfigAndLicense} from '@actions/remote/systems'; import LocalConfig from '@assets/config.json'; import {General, REDIRECT_URL_SCHEME, REDIRECT_URL_SCHEME_DEV} from '@constants'; @@ -84,6 +85,8 @@ class GlobalEventHandler { onLogout = async (serverUrl: string) => { await removeServerCredentials(serverUrl); + const channelIds = await selectAllMyChannelIds(serverUrl); + PushNotifications.cancelChannelsNotifications(channelIds); NetworkManager.invalidateClient(serverUrl); WebsocketManager.invalidateClient(serverUrl); @@ -95,20 +98,14 @@ class GlobalEventHandler { analytics.invalidate(serverUrl); } - deleteFileCache(serverUrl); - PushNotifications.clearNotifications(serverUrl); - this.resetLocale(); this.clearCookiesForServer(serverUrl); + deleteFileCache(serverUrl); relaunchApp({launchType: LaunchType.Normal}, true); }; onServerConfigChanged = ({serverUrl, config}: {serverUrl: string; config: ClientConfig}) => { this.configureAnalytics(serverUrl, config); - - if (config.ExtendSessionLengthWithActivity === 'true') { - PushNotifications.cancelAllLocalNotifications(); - } }; onServerVersionChanged = async ({serverUrl, serverVersion}: {serverUrl: string; serverVersion?: string}) => { diff --git a/app/init/launch.ts b/app/init/launch.ts index 4b59fbb7c..1a57f1391 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -2,10 +2,10 @@ // See LICENSE.txt for license information. import Emm from '@mattermost/react-native-emm'; -import {Alert, Linking} from 'react-native'; +import {Alert, Linking, Platform} from 'react-native'; import {Notifications} from 'react-native-notifications'; -import {appEntry, upgradeEntry} from '@actions/remote/entry'; +import {appEntry, pushNotificationEntry, upgradeEntry} from '@actions/remote/entry'; import {Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials'; @@ -14,6 +14,7 @@ import {queryCurrentUserId} from '@queries/servers/system'; import {goToScreen, resetToHome, resetToSelectServer} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkType, DeepLinkWithData, LaunchProps, LaunchType} from '@typings/launch'; +import {convertToNotificationData} from '@utils/notification'; import {parseDeepLink} from '@utils/url'; export const initialLaunch = async () => { @@ -24,8 +25,18 @@ export const initialLaunch = async () => { } const notification = await Notifications.getInitialNotification(); - if (notification && notification.payload?.type === 'message') { - launchAppFromNotification(notification); + let tapped = Platform.select({android: true, ios: false})!; + if (Platform.OS === 'ios') { + // when a notification is received on iOS, getInitialNotification, will return the notification + // as the app will initialized cause we are using background fetch, + // that does not necessarily mean that the app was opened cause of the notification was tapped. + // Here we are going to dettermine if the notification still exists in NotificationCenter to determine if + // the app was opened because of a tap or cause of the background fetch init + const delivered = await Notifications.ios.getDeliveredNotifications(); + tapped = delivered.find((d) => (d as unknown as NotificationData).ack_id === notification?.payload.ack_id) == null; + } + if (notification?.payload?.type === 'message' && tapped) { + launchAppFromNotification(convertToNotificationData(notification)); return; } @@ -37,8 +48,8 @@ const launchAppFromDeepLink = (deepLinkUrl: string) => { launchApp(props); }; -const launchAppFromNotification = (notification: NotificationWithData) => { - const props = getLaunchPropsFromNotification(notification); +const launchAppFromNotification = async (notification: NotificationWithData) => { + const props = await getLaunchPropsFromNotification(notification); launchApp(props); }; @@ -52,8 +63,7 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => { } break; case LaunchType.Notification: { - const extra = props.extra as NotificationWithData; - serverUrl = extra.payload?.server_url; + serverUrl = props.serverUrl; break; } default: @@ -76,8 +86,13 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => { hasCurrentUser = Boolean(currentUserId); } + let launchType = props.launchType; if (!hasCurrentUser) { // migrating from v1 + if (launchType === LaunchType.Normal) { + launchType = LaunchType.Upgrade; + } + const result = await upgradeEntry(serverUrl); if (result.error) { Alert.alert( @@ -96,7 +111,7 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => { } } - launchToHome({...props, launchType: hasCurrentUser ? LaunchType.Normal : LaunchType.Upgrade, serverUrl}, resetNavigation); + launchToHome({...props, launchType, serverUrl}); return; } } @@ -104,15 +119,22 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => { launchToServer(props, resetNavigation); }; -const launchToHome = (props: LaunchProps, resetNavigation: Boolean) => { +const launchToHome = async (props: LaunchProps) => { + let openPushNotification = false; + switch (props.launchType) { case LaunchType.DeepLink: // TODO: // deepLinkEntry({props.serverUrl, props.extra}); break; case LaunchType.Notification: { - // TODO: - // pushNotificationEntry({props.serverUrl, props.extra}) + const extra = props.extra as NotificationWithData; + openPushNotification = Boolean(props.serverUrl && !props.launchError && extra.userInteraction && extra.payload?.channel_id && !extra.payload?.userInfo?.local); + if (openPushNotification) { + pushNotificationEntry(props.serverUrl!, extra); + } else { + appEntry(props.serverUrl!); + } break; } case LaunchType.Normal: @@ -125,15 +147,9 @@ const launchToHome = (props: LaunchProps, resetNavigation: Boolean) => { ...props, }; - if (resetNavigation) { - // eslint-disable-next-line no-console - console.log('Launch app in Home screen'); - resetToHome(passProps); - return; - } - - const title = ''; - goToScreen(Screens.HOME, title, passProps); + // eslint-disable-next-line no-console + console.log('Launch app in Home screen'); + resetToHome(passProps); }; const launchToServer = (props: LaunchProps, resetNavigation: Boolean) => { @@ -185,15 +201,23 @@ export const getLaunchPropsFromDeepLink = (deepLinkUrl: string): LaunchProps => return launchProps; }; -export const getLaunchPropsFromNotification = (notification: NotificationWithData): LaunchProps => { - const {payload} = notification; - +export const getLaunchPropsFromNotification = async (notification: NotificationWithData): Promise => { const launchProps: LaunchProps = { launchType: LaunchType.Notification, }; + const {payload} = notification; + (launchProps.extra as NotificationWithData) = notification; + if (payload?.server_url) { - (launchProps.extra as NotificationWithData) = notification; + launchProps.serverUrl = payload.server_url; + } else if (payload?.server_id) { + const serverUrl = await DatabaseManager.getServerUrlFromIdentifier(payload.server_id); + if (serverUrl) { + launchProps.serverUrl = serverUrl; + } else { + launchProps.launchError = true; + } } else { launchProps.launchError = true; } diff --git a/app/init/push_notifications.ts b/app/init/push_notifications.ts index fb6977b21..dc6ee79ba 100644 --- a/app/init/push_notifications.ts +++ b/app/init/push_notifications.ts @@ -14,15 +14,21 @@ import { Registered, } from 'react-native-notifications'; -import {Device, General, Navigation} from '@constants'; +import {markChannelAsViewed} from '@actions/local/channel'; +import {backgroundNotification, openNotification} from '@actions/remote/notifications'; +import EphemeralStore from '@app/store/ephemeral_store'; +import {Device, General, Navigation, Screens} from '@constants'; import {GLOBAL_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n'; -import {getLaunchPropsFromNotification, relaunchApp} from '@init/launch'; import NativeNotifications from '@notifications'; -import {queryMentionCount} from '@queries/app/global'; +import {queryServerName} from '@queries/app/servers'; import {queryCurrentChannelId} from '@queries/servers/system'; import {showOverlay} from '@screens/navigation'; +import {isTablet} from '@utils/helpers'; +import {convertToNotificationData} from '@utils/notification'; + +import {getActiveServerUrl} from './credentials'; const CATEGORY = 'CAN_REPLY'; const REPLY_ACTION = 'REPLY_ACTION'; @@ -33,203 +39,257 @@ const NOTIFICATION_TYPE = { }; class PushNotifications { - configured = false; + configured = false; - init() { - Notifications.registerRemoteNotifications(); - Notifications.events().registerNotificationOpened(this.onNotificationOpened); - Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered); - Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground); - Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground); - } + init() { + Notifications.registerRemoteNotifications(); + Notifications.events().registerNotificationOpened(this.onNotificationOpened); + Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered); + Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground); + Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground); + } - cancelAllLocalNotifications = () => { - Notifications.cancelAllLocalNotifications(); - }; + cancelAllLocalNotifications = () => { + Notifications.cancelAllLocalNotifications(); + }; - clearNotifications = (serverUrl: string) => { - // TODO Notifications: Only cancel the local notifications that belong to this server + cancelChannelNotifications = async (channelId: string) => { + const notifications = await NativeNotifications.getDeliveredNotifications(); + this.cancelNotificationsForChannel(notifications, channelId); + }; - // eslint-disable-next-line no-console - console.log('Clear notifications for server', serverUrl); - this.cancelAllLocalNotifications(); + cancelChannelsNotifications = async (channelIds: string[]) => { + const notifications = await NativeNotifications.getDeliveredNotifications(); + for (const channelId of channelIds) { + this.cancelNotificationsForChannel(notifications, channelId); + } + }; - if (Platform.OS === 'ios') { - // TODO Notifications: Set the badge number to the total amount of mentions on other servers - Notifications.ios.setBadgeCount(0); - } - }; + cancelNotificationsForChannel = (notifications: NotificationWithChannel[], channelId: string) => { + if (Platform.OS === 'android') { + NativeNotifications.removeDeliveredNotifications(channelId); + } else { + const ids: string[] = []; + let badgeCount = notifications.length; - clearChannelNotifications = async (channelId: string) => { - const notifications = await NativeNotifications.getDeliveredNotifications(); - if (Platform.OS === 'android') { - const notificationForChannel = notifications.find( - (n: NotificationWithChannel) => n.channel_id === channelId, - ); - if (notificationForChannel) { - NativeNotifications.removeDeliveredNotifications( - notificationForChannel.identifier, - channelId, - ); - } - } else { - const ids: string[] = []; - let badgeCount = notifications.length; + for (const notification of notifications) { + if (notification.channel_id === channelId) { + ids.push(notification.identifier); + badgeCount--; + } + } - for (const notification of notifications) { - if (notification.channel_id === channelId) { - ids.push(notification.identifier); - badgeCount--; - } - } + // TODO: Set the badgeCount with databases mention count aggregate ?? + // or should we use the badge count from the icon? - // Set the badgeCount with default database mention count aggregate - const appDatabase = DatabaseManager.appDatabase?.database; - if (appDatabase) { - const mentions = await queryMentionCount(appDatabase); - if (mentions) { - badgeCount = parseInt(mentions, 10); - } - } + if (ids.length) { + NativeNotifications.removeDeliveredNotifications(ids); + } - if (ids.length) { - NativeNotifications.removeDeliveredNotifications(ids); - } + if (Platform.OS === 'ios') { + badgeCount = badgeCount <= 0 ? 0 : badgeCount; + Notifications.ios.setBadgeCount(badgeCount); + } + } + } - if (Platform.OS === 'ios') { - badgeCount = badgeCount <= 0 ? 0 : badgeCount; - Notifications.ios.setBadgeCount(badgeCount); - } - } - }; + createReplyCategory = () => { + const replyTitle = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.title')); + const replyButton = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.button')); + const replyPlaceholder = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.placeholder')); + const replyTextInput: NotificationTextInput = {buttonTitle: replyButton, placeholder: replyPlaceholder}; + const replyAction = new NotificationAction(REPLY_ACTION, 'background', replyTitle, true, replyTextInput); + return new NotificationCategory(CATEGORY, [replyAction]); + }; - createReplyCategory = () => { - const replyTitle = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.title')); - const replyButton = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.button')); - const replyPlaceholder = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.placeholder')); - const replyTextInput: NotificationTextInput = {buttonTitle: replyButton, placeholder: replyPlaceholder}; - const replyAction = new NotificationAction(REPLY_ACTION, 'background', replyTitle, true, replyTextInput); - return new NotificationCategory(CATEGORY, [replyAction]); - }; + getServerUrlFromNotification = async (notification: NotificationWithData) => { + const {payload} = notification; - handleNotification = async (notification: NotificationWithData) => { - const {payload, foreground, userInteraction} = notification; + if (!payload?.channel_id && (!payload?.server_url || !payload.server_id)) { + return undefined; + } - if (payload) { - switch (payload.type) { - case NOTIFICATION_TYPE.CLEAR: - // TODO Notifications: Mark the channel as read - break; - case NOTIFICATION_TYPE.MESSAGE: - // TODO Notifications: fetch the posts for the channel + let serverUrl = payload.server_url; + if (!serverUrl && payload.server_id) { + serverUrl = await DatabaseManager.getServerUrlFromIdentifier(payload.server_id); + } - if (foreground) { - this.handleInAppNotification(notification); - } else if (userInteraction && !payload.userInfo?.local) { - const props = getLaunchPropsFromNotification(notification); - relaunchApp(props, true); - } - break; - case NOTIFICATION_TYPE.SESSION: - // eslint-disable-next-line no-console - console.log('Session expired notification'); + return serverUrl; + } - if (payload.server_url) { - DeviceEventEmitter.emit(General.SERVER_LOGOUT, payload.server_url); - } - break; - } - } - }; + handleClearNotification = async (notification: NotificationWithData) => { + const {payload} = notification; + const serverUrl = await this.getServerUrlFromNotification(notification); - handleInAppNotification = async (notification: NotificationWithData) => { - const {payload} = notification; + if (serverUrl && payload?.channel_id) { + markChannelAsViewed(serverUrl, payload?.channel_id, false); + } + } - if (payload?.server_url) { - const database = DatabaseManager.serverDatabases[payload.server_url]?.database; - const channelId = await queryCurrentChannelId(database); + handleInAppNotification = async (serverUrl: string, notification: NotificationWithData) => { + const {payload} = notification; - if (channelId && payload.channel_id !== channelId) { - const screen = 'Notification'; - const passProps = { - notification, - }; + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (database) { + const isTabletDevice = await isTablet(); + const activeServerUrl = await getActiveServerUrl(); + const displayName = await queryServerName(DatabaseManager.appDatabase!.database, serverUrl); + const channelId = await queryCurrentChannelId(database); + let serverName; + if (serverUrl !== activeServerUrl && Object.keys(DatabaseManager.serverDatabases).length > 1) { + serverName = displayName; + } - DeviceEventEmitter.emit(Navigation.NAVIGATION_SHOW_OVERLAY); - showOverlay(screen, passProps); - } - } - }; + const isDifferentChannel = payload?.channel_id !== channelId; + let isChannelScreenVisible = EphemeralStore.getNavigationTopComponentId() === Screens.CHANNEL; + if (isTabletDevice) { + isChannelScreenVisible = EphemeralStore.getVisibleTab() === Screens.HOME; + } - localNotification = (notification: Notification) => { - Notifications.postLocalNotification(notification); - }; + if (isDifferentChannel || !isChannelScreenVisible) { + DeviceEventEmitter.emit(Navigation.NAVIGATION_SHOW_OVERLAY); - onNotificationOpened = (notification: NotificationWithData, completion: () => void) => { - notification.userInteraction = true; - this.handleNotification(notification); - completion(); - }; + const screen = Screens.IN_APP_NOTIFICATION; + const passProps = { + notification, + overlay: true, + serverName, + serverUrl, + }; - onNotificationReceivedBackground = (notification: NotificationWithData, completion: (response: NotificationBackgroundFetchResult) => void) => { - this.handleNotification(notification); - completion(NotificationBackgroundFetchResult.NO_DATA); - }; + showOverlay(screen, passProps); + } + } + }; - onNotificationReceivedForeground = (notification: NotificationWithData, completion: (response: NotificationCompletion) => void) => { - notification.foreground = AppState.currentState === 'active'; - completion({alert: false, sound: true, badge: true}); - this.handleNotification(notification); - }; + handleMessageNotification = async (notification: NotificationWithData) => { + const {payload, foreground, userInteraction} = notification; + const serverUrl = await this.getServerUrlFromNotification(notification); - onRemoteNotificationsRegistered = async (event: Registered) => { - if (!this.configured) { - this.configured = true; - const {deviceToken} = event; - let prefix; + if (serverUrl) { + if (foreground) { + // Move this to a local action + this.handleInAppNotification(serverUrl, notification); + } else if (userInteraction && !payload?.userInfo?.local) { + // Handle notification tapped + openNotification(serverUrl, notification); + this.cancelChannelNotifications(notification.payload!.channel_id); + } else { + backgroundNotification(serverUrl, notification); + } + } + } - if (Platform.OS === 'ios') { - prefix = Device.PUSH_NOTIFY_APPLE_REACT_NATIVE; - if (DeviceInfo.getBundleId().includes('rnbeta')) { - prefix = `${prefix}beta`; - } - } else { - prefix = Device.PUSH_NOTIFY_ANDROID_REACT_NATIVE; - } + handleSessionNotification = async (notification: NotificationWithData) => { + // eslint-disable-next-line no-console + console.log('Session expired notification'); - const operator = DatabaseManager.appDatabase?.operator; + const serverUrl = await this.getServerUrlFromNotification(notification); - if (!operator) { - return {error: 'No App database found'}; - } + if (serverUrl) { + DeviceEventEmitter.emit(General.SERVER_LOGOUT, serverUrl); + } + } - operator.handleGlobal({ - global: [{id: GLOBAL_IDENTIFIERS.DEVICE_TOKEN, value: `${prefix}:${deviceToken}`}], - prepareRecordsOnly: false, - }); + processNotification = async (notification: NotificationWithData) => { + const {payload} = notification; - // Store the device token in the default database - this.requestNotificationReplyPermissions(); - } - return null; - }; + if (payload) { + switch (payload.type) { + case NOTIFICATION_TYPE.CLEAR: + this.handleClearNotification(notification); + break; + case NOTIFICATION_TYPE.MESSAGE: + this.handleMessageNotification(notification); + break; + case NOTIFICATION_TYPE.SESSION: + this.handleSessionNotification(notification); + break; + } + } + }; - requestNotificationReplyPermissions = () => { - if (Platform.OS === 'ios') { - const replyCategory = this.createReplyCategory(); - Notifications.setCategories([replyCategory]); - } - }; + localNotification = (notification: Notification) => { + Notifications.postLocalNotification(notification); + }; - scheduleNotification = (notification: Notification) => { - if (notification.fireDate) { - if (Platform.OS === 'ios') { - notification.fireDate = new Date(notification.fireDate).toISOString(); - } + // This triggers when a notification is tapped and the app was in the background (iOS) + onNotificationOpened = (incoming: Notification, completion: () => void) => { + const notification = convertToNotificationData(incoming, false); + notification.userInteraction = true; - Notifications.postLocalNotification(notification); - } - }; + this.processNotification(notification); + completion(); + }; + + // This triggers when the app was in the background (iOS) + onNotificationReceivedBackground = async (incoming: Notification, completion: (response: NotificationBackgroundFetchResult) => void) => { + const notification = convertToNotificationData(incoming, false); + this.processNotification(notification); + + completion(NotificationBackgroundFetchResult.NEW_DATA); + }; + + // This triggers when the app was in the foreground (Android and iOS) + // Also triggers when the app was in the background (Android) + onNotificationReceivedForeground = (incoming: Notification, completion: (response: NotificationCompletion) => void) => { + const notification = convertToNotificationData(incoming, false); + if (AppState.currentState !== 'inactive') { + notification.foreground = AppState.currentState === 'active'; + + this.processNotification(notification); + } + completion({alert: false, sound: true, badge: true}); + }; + + onRemoteNotificationsRegistered = async (event: Registered) => { + if (!this.configured) { + this.configured = true; + const {deviceToken} = event; + let prefix; + + if (Platform.OS === 'ios') { + prefix = Device.PUSH_NOTIFY_APPLE_REACT_NATIVE; + if (DeviceInfo.getBundleId().includes('rnbeta')) { + prefix = `${prefix}beta`; + } + } else { + prefix = Device.PUSH_NOTIFY_ANDROID_REACT_NATIVE; + } + + const operator = DatabaseManager.appDatabase?.operator; + + if (!operator) { + return {error: 'No App database found'}; + } + + operator.handleGlobal({ + global: [{id: GLOBAL_IDENTIFIERS.DEVICE_TOKEN, value: `${prefix}:${deviceToken}`}], + prepareRecordsOnly: false, + }); + + // Store the device token in the default database + this.requestNotificationReplyPermissions(); + } + return null; + }; + + requestNotificationReplyPermissions = () => { + if (Platform.OS === 'ios') { + const replyCategory = this.createReplyCategory(); + Notifications.setCategories([replyCategory]); + } + }; + + scheduleNotification = (notification: Notification) => { + if (notification.fireDate) { + if (Platform.OS === 'ios') { + notification.fireDate = new Date(notification.fireDate).toISOString(); + } + + Notifications.postLocalNotification(notification); + } + }; } export default new PushNotifications(); diff --git a/app/init/websocket_manager.ts b/app/init/websocket_manager.ts index 71550ac4b..5e5f59240 100644 --- a/app/init/websocket_manager.ts +++ b/app/init/websocket_manager.ts @@ -9,7 +9,7 @@ import {fetchStatusByIds} from '@actions/remote/user'; import {handleClose, handleEvent, handleFirstConnect, handleReconnect} from '@actions/websocket'; import WebSocketClient from '@app/client/websocket'; import {General} from '@app/constants'; -import {queryWebSocketLastDisconnected} from '@app/queries/servers/system'; +import {queryCurrentUserId, resetWebSocketLastDisconnected} from '@app/queries/servers/system'; import {queryAllUsers} from '@app/queries/servers/user'; import DatabaseManager from '@database/manager'; @@ -30,13 +30,13 @@ class WebsocketManager { await Promise.all( serverCredentials.map( async ({serverUrl, token}) => { - const database = DatabaseManager.serverDatabases[serverUrl]?.database; - if (!database) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { return; } - const lastDisconnect = await queryWebSocketLastDisconnected(database); + await resetWebSocketLastDisconnected(operator); try { - this.createClient(serverUrl, token, lastDisconnect); + this.createClient(serverUrl, token, 0); } catch (error) { console.log('WebsocketManager init error', error); //eslint-disable-line no-console } @@ -64,7 +64,7 @@ class WebsocketManager { client.setReconnectCallback(() => this.onReconnect(serverUrl)); client.setCloseCallback((connectFailCount: number, lastDisconnect: number) => this.onWebsocketClose(serverUrl, connectFailCount, lastDisconnect)); - if (this.netConnected) { + if (this.netConnected && ['unknown', 'active'].includes(AppState.currentState)) { client.initialize(); } this.clients[serverUrl] = client; @@ -121,9 +121,10 @@ class WebsocketManager { return; } + const currentUserId = await queryCurrentUserId(database.database); const users = await queryAllUsers(database.database); - const userIds = users.map((u) => u.id); + const userIds = users.map((u) => u.id).filter((id) => id !== currentUserId); if (!userIds.length) { return; } diff --git a/app/notifications/index.ios.ts b/app/notifications/index.ios.ts deleted file mode 100644 index 0f42c3bed..000000000 --- a/app/notifications/index.ios.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Notifications} from 'react-native-notifications'; - -/* eslint-disable */ -export default { - getDeliveredNotifications: Notifications.ios.getDeliveredNotifications, - getPreferences: async () => null, - play: (soundUri: string) => {}, - removeDeliveredNotifications: Notifications.ios.removeDeliveredNotifications, - setNotificationSound: () => {}, - setShouldBlink: (shouldBlink: boolean) => {}, - setShouldVibrate: (shouldVibrate: boolean) => {}, -} as NativeNotification; diff --git a/app/notifications/index.ts b/app/notifications/index.ts index eefc02ef8..8eff48aa5 100644 --- a/app/notifications/index.ts +++ b/app/notifications/index.ts @@ -1,40 +1,5 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {NativeModules, PermissionsAndroid} from 'react-native'; - -const {NotificationPreferences} = NativeModules; - -const defaultPreferences: NativeNotificationPreferences = { - sounds: [], - shouldBlink: false, - shouldVibrate: true, -}; - -export default { - getDeliveredNotifications: NotificationPreferences.getDeliveredNotifications, - getPreferences: async () => { - try { - const hasPermission = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE); - let granted; - if (!hasPermission) { - granted = await PermissionsAndroid.request( - PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, - ); - } - - if (hasPermission || granted === PermissionsAndroid.RESULTS.GRANTED) { - return await NotificationPreferences.getPreferences(); - } - - return defaultPreferences; - } catch (error) { - return defaultPreferences; - } - }, - play: NotificationPreferences.previewSound, - removeDeliveredNotifications: NotificationPreferences.removeDeliveredNotifications, - setNotificationSound: NotificationPreferences.setNotificationSound, - setShouldBlink: NotificationPreferences.setShouldBlink, - setShouldVibrate: NotificationPreferences.setShouldVibrate, -} as NativeNotification; +// @ts-expect-error platform specific +export {default} from './notifications'; diff --git a/app/notifications/notifications.android.ts b/app/notifications/notifications.android.ts new file mode 100644 index 000000000..eefc02ef8 --- /dev/null +++ b/app/notifications/notifications.android.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {NativeModules, PermissionsAndroid} from 'react-native'; + +const {NotificationPreferences} = NativeModules; + +const defaultPreferences: NativeNotificationPreferences = { + sounds: [], + shouldBlink: false, + shouldVibrate: true, +}; + +export default { + getDeliveredNotifications: NotificationPreferences.getDeliveredNotifications, + getPreferences: async () => { + try { + const hasPermission = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE); + let granted; + if (!hasPermission) { + granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE, + ); + } + + if (hasPermission || granted === PermissionsAndroid.RESULTS.GRANTED) { + return await NotificationPreferences.getPreferences(); + } + + return defaultPreferences; + } catch (error) { + return defaultPreferences; + } + }, + play: NotificationPreferences.previewSound, + removeDeliveredNotifications: NotificationPreferences.removeDeliveredNotifications, + setNotificationSound: NotificationPreferences.setNotificationSound, + setShouldBlink: NotificationPreferences.setShouldBlink, + setShouldVibrate: NotificationPreferences.setShouldVibrate, +} as NativeNotification; diff --git a/app/notifications/notifications.ios.ts b/app/notifications/notifications.ios.ts new file mode 100644 index 000000000..9e8edaee2 --- /dev/null +++ b/app/notifications/notifications.ios.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Notifications} from 'react-native-notifications'; + +import {emptyFunction} from '@utils/general'; + +export default { + getDeliveredNotifications: async () => Notifications.ios.getDeliveredNotifications(), + getPreferences: async () => null, + play: (soundUri: string) => emptyFunction(soundUri), + removeDeliveredNotifications: async (ids: string[]) => Notifications.ios.removeDeliveredNotifications(ids), + setNotificationSound: () => emptyFunction(), + setShouldBlink: (shouldBlink: boolean) => emptyFunction(shouldBlink), + setShouldVibrate: (shouldVibrate: boolean) => emptyFunction(shouldVibrate), +} as NativeNotification; diff --git a/app/queries/app/global.ts b/app/queries/app/global.ts index 6efea1382..eb0e468b1 100644 --- a/app/queries/app/global.ts +++ b/app/queries/app/global.ts @@ -17,12 +17,3 @@ export const queryDeviceToken = async (appDatabase: Database) => { return ''; } }; - -export const queryMentionCount = async (appDatabase: Database) => { - try { - const mentions = await appDatabase.get(GLOBAL).find(GLOBAL_IDENTIFIERS.MENTION_COUNT) as Global; - return mentions?.value || ''; - } catch { - return ''; - } -}; diff --git a/app/queries/app/servers.ts b/app/queries/app/servers.ts index dfc864a50..b88b90ff8 100644 --- a/app/queries/app/servers.ts +++ b/app/queries/app/servers.ts @@ -5,24 +5,44 @@ import {Database, Q} from '@nozbe/watermelondb'; import {MM_TABLES} from '@constants/database'; -import type Servers from '@typings/database/models/app/servers'; +import type ServerModel from '@typings/database/models/app/servers'; const {APP: {SERVERS}} = MM_TABLES; export const queryServer = async (appDatabase: Database, serverUrl: string) => { - const servers = (await appDatabase.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch()) as Servers[]; + const servers = (await appDatabase.get(SERVERS).query(Q.where('url', serverUrl)).fetch()); return servers?.[0]; }; -export const queryAllServers = async (appDatabse: Database) => { - return (await appDatabse.collections.get(MM_TABLES.APP.SERVERS).query().fetch()) as Servers[]; +export const queryAllServers = async (appDatabase: Database) => { + return appDatabase.get(MM_TABLES.APP.SERVERS).query().fetch(); }; -export const queryActiveServer = async (appDatabse: Database) => { +export const queryActiveServer = async (appDatabase: Database) => { try { - const servers = await queryAllServers(appDatabse); - return servers.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a)); + const servers = await queryAllServers(appDatabase); + const server = servers?.filter((s) => s.identifier)?.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a)); + return server; } catch { return undefined; } }; + +export const queryServerByIdentifier = async (appDatabase: Database, identifier: string) => { + try { + const servers = (await appDatabase.get(SERVERS).query(Q.where('identifier', identifier)).fetch()); + return servers?.[0]; + } catch { + return undefined; + } +}; + +export const queryServerName = async (appDatabase: Database, serverUrl: string) => { + try { + const servers = (await appDatabase.get(SERVERS).query(Q.where('url', serverUrl)).fetch()); + return servers?.[0].displayName; + } catch { + return serverUrl; + } +}; + diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index a369a1c45..526807d7b 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -96,12 +96,16 @@ export const prepareDeleteChannel = async (channel: ChannelModel): Promise { - return database.get(CHANNEL).query(Q.where('team_id', teamId)).fetch() as Promise; + return database.get(CHANNEL).query(Q.where('team_id', teamId)).fetch(); +}; + +export const queryAllMyChannelIds = (database: Database) => { + return database.get(MY_CHANNEL).query().fetchIds(); }; export const queryMyChannel = async (database: Database, channelId: string) => { try { - const member = await database.get(MY_CHANNEL).find(channelId) as MyChannelModel; + const member = await database.get(MY_CHANNEL).find(channelId); return member; } catch { return undefined; diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index ac805907e..f26479491 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -8,7 +8,7 @@ import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type SystemModel from '@typings/database/models/servers/system'; -type PrepareCommonSystemValuesArgs = { +export type PrepareCommonSystemValuesArgs = { config?: ClientConfig; currentChannelId?: string; currentTeamId?: string; @@ -118,6 +118,14 @@ export const queryWebSocketLastDisconnected = async (serverDatabase: Database) = } }; +export const resetWebSocketLastDisconnected = (operator: ServerDataOperator) => { + return operator.handleSystem({systems: [{ + id: SYSTEM_IDENTIFIERS.WEBSOCKET, + value: 0, + }], + prepareRecordsOnly: false}); +}; + export const queryTeamHistory = async (serverDatabase: Database) => { try { const teamHistory = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.TEAM_HISTORY); @@ -127,7 +135,7 @@ export const queryTeamHistory = async (serverDatabase: Database) => { } }; -export const patchTeamHistory = async (operator: ServerDataOperator, value: string[], prepareRecordsOnly = false) => { +export const patchTeamHistory = (operator: ServerDataOperator, value: string[], prepareRecordsOnly = false) => { return operator.handleSystem({systems: [{ id: SYSTEM_IDENTIFIERS.TEAM_HISTORY, value: JSON.stringify(value), diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx index 07067416c..f4e4a6d0b 100644 --- a/app/screens/channel/index.tsx +++ b/app/screens/channel/index.tsx @@ -16,6 +16,7 @@ import StatusBar from '@components/status_bar'; import {Screens, Database} from '@constants'; import {useServerUrl} from '@context/server_url'; import {useTheme} from '@context/theme'; +import {useAppState} from '@hooks/device'; import {goToScreen} from '@screens/navigation'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -69,6 +70,7 @@ const Channel = ({currentChannelId, currentTeamId, time}: ChannelProps) => { const theme = useTheme(); const intl = useIntl(); const styles = getStyleSheet(theme); + const appState = useAppState(); const serverUrl = useServerUrl(); @@ -99,6 +101,7 @@ const Channel = ({currentChannelId, currentTeamId, time}: ChannelProps) => { { ); - }, [currentTeamId, currentChannelId, theme]); + }, [currentTeamId, currentChannelId, theme, appState]); return ( {showDateTimePicker && ( )} diff --git a/app/screens/custom_status_clear_after/components/date_time_selector.tsx b/app/screens/custom_status_clear_after/components/date_time_selector.tsx index c6c165ee7..1d6327b1d 100644 --- a/app/screens/custom_status_clear_after/components/date_time_selector.tsx +++ b/app/screens/custom_status_clear_after/components/date_time_selector.tsx @@ -17,14 +17,12 @@ import {MM_TABLES} from '@constants/database'; import {getPreferenceAsBool} from '@helpers/api/preference'; import {getCurrentMomentForTimezone, getRoundedTime, getUtcOffsetForTimeZone} from '@utils/helpers'; import {makeStyleSheetFromTheme} from '@utils/theme'; -import {getTimezone} from '@utils/user'; import type {WithDatabaseArgs} from '@typings/database/database'; import type PreferenceModel from '@typings/database/models/servers/preference'; -import type UserModel from '@typings/database/models/servers/user'; type Props = { - currentUser: UserModel; + timezone: string | null; isMilitaryTime: boolean; theme: Theme; handleChange: (currentDate: Moment) => void; @@ -49,9 +47,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -const DateTimeSelector = ({currentUser, handleChange, isMilitaryTime, theme}: Props) => { +const DateTimeSelector = ({timezone, handleChange, isMilitaryTime, theme}: Props) => { const styles = getStyleSheet(theme); - const timezone = getTimezone(currentUser.timezone); const currentTime = getCurrentMomentForTimezone(timezone); const timezoneOffSetInMinutes = timezone ? getUtcOffsetForTimeZone(timezone) : undefined; const minimumDate = getRoundedTime(currentTime); diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index 573258242..8d72eae68 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -3,11 +3,14 @@ import {createBottomTabNavigator, BottomTabBarProps} from '@react-navigation/bottom-tabs'; import {NavigationContainer} from '@react-navigation/native'; -import React from 'react'; -import {Platform} from 'react-native'; +import React, {useEffect} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter, Platform} from 'react-native'; import {enableScreens} from 'react-native-screens'; +import {Events} from '@constants'; import {useTheme} from '@context/theme'; +import {notificationError} from '@utils/notification'; import Account from './account'; import ChannelList from './channel_list'; @@ -30,6 +33,15 @@ const Tab = createBottomTabNavigator(); export default function HomeScreen(props: HomeProps) { const theme = useTheme(); + const intl = useIntl(); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.NOTIFICATION_ERROR, (value: 'Team' | 'Channel') => { + notificationError(intl, value); + }); + + return () => listener.remove(); + }, []); return ( event.remove(); }, []); + useEffect(() => { + const listner = DeviceEventEmitter.addListener(NavigationConstants.NAVIGATION_HOME, () => { + EphemeralStore.setVisibleTap(Screens.HOME); + navigation.navigate(Screens.HOME); + }); + + return () => listner.remove(); + }); + const transform = useAnimatedStyle(() => { const translateX = withTiming(state.index * tabWidth, {duration: 150}); return { @@ -130,6 +141,7 @@ function TabBar({state, descriptors, navigation, theme}: BottomTabBarProps & {th if (!isFocused && !event.defaultPrevented) { // The `merge: true` option makes sure that the params inside the tab screen are preserved navigation.navigate({params: {...route.params, direction}, name: route.name, merge: true}); + EphemeralStore.setVisibleTap(route.name); } }; diff --git a/app/screens/in_app_notification/icon.tsx b/app/screens/in_app_notification/icon.tsx new file mode 100644 index 000000000..d4a677010 --- /dev/null +++ b/app/screens/in_app_notification/icon.tsx @@ -0,0 +1,106 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import withObservables from '@nozbe/with-observables'; +import React from 'react'; +import {StyleSheet, View} from 'react-native'; +import FastImage, {Source} from 'react-native-fast-image'; +import {of as of$} from 'rxjs'; +import {catchError, switchMap} from 'rxjs/operators'; + +import CompassIcon from '@components/compass_icon'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import NetworkManager from '@init/network_manager'; +import {WithDatabaseArgs} from '@typings/database/database'; +import SystemModel from '@typings/database/models/servers/system'; + +import type {Client} from '@client/rest'; +import type UserModel from '@typings/database/models/servers/user'; + +interface NotificationIconProps { + author?: UserModel; + enablePostIconOverride: boolean; + fromWebhook: boolean; + overrideIconUrl?: string; + serverUrl: string; + useUserIcon: boolean; +} + +const {SERVER: {SYSTEM, USER}} = MM_TABLES; +const IMAGE_SIZE = 36; +const logo = require('@assets/images/icon.png'); + +const styles = StyleSheet.create({ + icon: { + borderRadius: (IMAGE_SIZE / 2), + height: IMAGE_SIZE, + width: IMAGE_SIZE, + }, +}); + +const NotificationIcon = ({author, enablePostIconOverride, fromWebhook, overrideIconUrl, serverUrl, useUserIcon}: NotificationIconProps) => { + let client: Client | undefined; + try { + client = NetworkManager.getClient(serverUrl); + } catch { + // do nothing, client is not set + } + + let icon; + if (client && fromWebhook && !useUserIcon && enablePostIconOverride) { + if (overrideIconUrl) { + const source: Source = {uri: client.getAbsoluteUrl(overrideIconUrl)}; + icon = ( + + ); + } else { + icon = ( + + ); + } + } else if (author && client) { + const pictureUrl = client.getProfilePictureUrl(author.id, author.lastPictureUpdate); + icon = ( + + ); + } else { + icon = ( + + ); + } + + return ( + + {icon} + + ); +}; + +const enhanced = withObservables([], ({database, senderId}: WithDatabaseArgs & {senderId: string}) => { + const config = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG); + const author = database.get(USER).findAndObserve(senderId).pipe( + catchError(() => of$(undefined)), + ); + + return { + author, + enablePostIconOverride: config.pipe( + switchMap(({value}) => of$((value as ClientConfig).EnablePostIconOverride === 'true')), + ), + }; +}); + +export default enhanced(NotificationIcon); diff --git a/app/screens/in_app_notification/index.tsx b/app/screens/in_app_notification/index.tsx new file mode 100644 index 000000000..2abd2a38a --- /dev/null +++ b/app/screens/in_app_notification/index.tsx @@ -0,0 +1,188 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useRef, useState} from 'react'; +import {DeviceEventEmitter, Platform, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import {PanGestureHandler} from 'react-native-gesture-handler'; +import {Navigation} from 'react-native-navigation'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {openNotification} from '@actions/remote/notifications'; +import {Navigation as NavigationTypes} from '@constants'; +import DatabaseManager from '@database/manager'; +import {useIsTablet} from '@hooks/device'; +import {dismissOverlay} from '@screens/navigation'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity} from '@utils/theme'; + +import Icon from './icon'; +import Server from './server'; +import Title from './title'; + +type InAppNotificationProps = { + componentId: string; + notification: NotificationWithData; + serverName?: string; + serverUrl: string; +} + +const AUTO_DISMISS_TIME_MILLIS = 5000; + +const styles = StyleSheet.create({ + container: { + alignSelf: 'center', + backgroundColor: changeOpacity('#000', 0.88), + borderRadius: 12, + flexDirection: 'row', + padding: 10, + width: '95%', + }, + tablet: { + width: 500, + }, + flex: { + width: '100%', + }, + message: { + color: '#FFFFFF', + fontSize: 13, + fontFamily: 'OpenSans', + }, + titleContainer: { + flex: 1, + flexDirection: 'column', + alignSelf: 'stretch', + alignItems: 'flex-start', + marginLeft: 10, + minHeight: 60, + }, + touchable: { + flexDirection: 'row', + }, +}); + +const InAppNotification = ({componentId, serverName, serverUrl, notification}: InAppNotificationProps) => { + const [animate, setAnimate] = useState(false); + const dismissTimerRef = useRef(null); + const initial = useSharedValue(-130); + const isTablet = useIsTablet(); + let insets = {top: 0}; + if (Platform.OS === 'ios') { + // on Android we disable the safe area provider as it conflicts with the gesture system + // eslint-disable-next-line react-hooks/rules-of-hooks + insets = useSafeAreaInsets(); + } + + const tapped = useRef(false); + + const animateDismissOverlay = () => { + cancelDismissTimer(); + setAnimate(true); + dismissTimerRef.current = setTimeout(dismiss, 1000); + }; + + const cancelDismissTimer = () => { + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + } + }; + + const dismiss = () => { + cancelDismissTimer(); + dismissOverlay(componentId); + }; + + const notificationTapped = preventDoubleTap(() => { + tapped.current = true; + dismiss(); + }); + + useEffect(() => { + initial.value = 0; + + dismissTimerRef.current = setTimeout(() => { + if (!tapped.current) { + animateDismissOverlay(); + } + }, AUTO_DISMISS_TIME_MILLIS); + + return cancelDismissTimer; + }, []); + + useEffect(() => { + const didDismissListener = Navigation.events().registerComponentDidDisappearListener(async ({componentId: screen}) => { + if (componentId === screen && tapped.current && serverUrl) { + const {channel_id} = notification.payload || {}; + if (channel_id) { + openNotification(serverUrl, notification); + } + } + }); + + return () => didDismissListener.remove(); + }, []); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(NavigationTypes.NAVIGATION_SHOW_OVERLAY, dismiss); + + return () => listener.remove(); + }, []); + + const animatedStyle = useAnimatedStyle(() => { + const translateY = animate ? withTiming(-130, {duration: 300}) : withTiming(initial.value, {duration: 300}); + + return { + + marginTop: insets.top, + transform: [{translateY}], + }; + }, [animate, insets.top]); + + const message = notification.payload?.body || notification.payload?.message; + + return ( + + + + + + + + <View style={styles.flex}> + <Text + numberOfLines={2} + ellipsizeMode='tail' + style={styles.message} + testID='in_app_notification.message' + > + {message} + </Text> + </View> + {Boolean(serverName) && <Server serverName={serverName!}/>} + </View> + </TouchableOpacity> + </View> + </Animated.View> + </PanGestureHandler> + ); +}; + +export default InAppNotification; diff --git a/app/screens/in_app_notification/server.tsx b/app/screens/in_app_notification/server.tsx new file mode 100644 index 000000000..66eb96017 --- /dev/null +++ b/app/screens/in_app_notification/server.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet, Text, View} from 'react-native'; + +interface NotificationServerProps { + serverName: string; +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'flex-start', + marginTop: 5, + }, + text: { + color: 'rgba(255, 255, 255, 0.64)', + fontFamily: 'OpenSans', + fontSize: 10, + }, +}); + +const Server = ({serverName}: NotificationServerProps) => { + return ( + <View style={styles.container}> + <Text + numberOfLines={1} + ellipsizeMode='tail' + style={styles.text} + testID='in_app_notification.title' + > + {serverName} + </Text> + </View> + ); +}; + +export default Server; diff --git a/app/screens/in_app_notification/title.tsx b/app/screens/in_app_notification/title.tsx new file mode 100644 index 000000000..d9b1c13ee --- /dev/null +++ b/app/screens/in_app_notification/title.tsx @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet, Text} from 'react-native'; + +interface NotificationTitleProps { + channelName: string; +} + +const Title = ({channelName}: NotificationTitleProps) => { + return ( + <Text + numberOfLines={1} + ellipsizeMode='tail' + style={styles.title} + testID='in_app_notification.title' + > + {channelName} + </Text> + ); +}; + +const styles = StyleSheet.create({ + title: { + color: '#FFFFFF', + fontSize: 14, + fontFamily: 'OpenSans-SemiBold', + }, +}); + +export default Title; diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 70699a290..213ec6d90 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -120,6 +120,14 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.FORGOT_PASSWORD: screen = withIntl(require('@screens/forgot_password').default); break; + case Screens.IN_APP_NOTIFICATION: { + const notificationScreen = require('@screens/in_app_notification').default; + Navigation.registerComponent(Screens.IN_APP_NOTIFICATION, () => Platform.select({ + android: notificationScreen, + ios: withSafeAreaInsets(notificationScreen), + })); + return; + } // case 'Gallery': // screen = require('@screens/gallery').default; // break; diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 276e9ee98..14b04af19 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ + import merge from 'deepmerge'; import {Appearance, DeviceEventEmitter, NativeModules, Platform} from 'react-native'; import {Navigation, Options, OptionsModalPresentationStyle} from 'react-native-navigation'; @@ -232,6 +234,37 @@ export async function dismissAllModalsAndPopToRoot() { DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_DISMISS_AND_POP_TO_ROOT); } +/** + * Dismisses All modals in the stack and pops the stack to the desired screen + * (if the screen is not in the stack, it will push a new one) + * @param screenId Screen to pop or display + * @param title Title to be shown in the top bar + * @param passProps Props to pass to the screen (Only if the screen does not exist in the stack) + * @param options Navigation options + */ +export async function dismissAllModalsAndPopToScreen(screenId: string, title: string, passProps = {}, options = {}) { + await dismissAllModals(); + if (EphemeralStore.getNavigationComponents().includes(screenId)) { + let mergeOptions = options; + if (title) { + mergeOptions = merge(mergeOptions, { + topBar: { + title: { + text: title, + }, + }, + }); + } + try { + await Navigation.popTo(screenId, mergeOptions); + } catch { + // catch in case there is nothing to pop + } + } else { + goToScreen(screenId, title, passProps, options); + } +} + export function showModal(name: string, title: string, passProps = {}, options = {}) { const theme = getThemeFromState(); const modalPresentationStyle: OptionsModalPresentationStyle = Platform.OS === 'ios' ? OptionsModalPresentationStyle.pageSheet : OptionsModalPresentationStyle.none; diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 694d25b3e..ec86d3b68 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -159,7 +159,7 @@ const Server: NavigationFunctionComponent = ({componentId, extra, launchType, la return; } - const data = await fetchConfigAndLicense(serverUrl); + const data = await fetchConfigAndLicense(serverUrl, true); if (data.error) { setError(data.error as ClientError); setConnecting(false); diff --git a/app/screens/sso/index.tsx b/app/screens/sso/index.tsx index 2cfd54c17..d61ac6b48 100644 --- a/app/screens/sso/index.tsx +++ b/app/screens/sso/index.tsx @@ -73,7 +73,7 @@ const SSO = ({config, extra, launchError, launchType, serverUrl, ssoType, theme} }; const doSSOLogin = async (bearerToken: string, csrfToken: string) => { - const result: LoginActionResponse = await ssoLogin(serverUrl!, bearerToken, csrfToken); + const result: LoginActionResponse = await ssoLogin(serverUrl!, config.DiagnosticId!, bearerToken, csrfToken); if (result?.error && result.failed) { onLoadEndError(result.error); return; diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index 18da2796e..879ddc95a 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -6,13 +6,30 @@ class EphemeralStore { navigationComponentIdStack: string[] = []; navigationModalStack: string[] = []; theme: Theme | undefined; + visibleTab = 'Home' - getNavigationTopComponentId = () => { - return this.navigationComponentIdStack[0]; + addNavigationComponentId = (componentId: string) => { + this.addToNavigationComponentIdStack(componentId); + this.addToAllNavigationComponentIds(componentId); + }; + + addToAllNavigationComponentIds = (componentId: string) => { + if (!this.allNavigationComponentIds.includes(componentId)) { + this.allNavigationComponentIds.unshift(componentId); + } } - getNavigationTopModalId = () => { - return this.navigationModalStack[0]; + addToNavigationComponentIdStack = (componentId: string) => { + const index = this.navigationComponentIdStack.indexOf(componentId); + if (index >= 0) { + this.navigationComponentIdStack = this.navigationComponentIdStack.slice(index, 1); + } + + this.navigationComponentIdStack.unshift(componentId); + } + + addNavigationModal = (componentId: string) => { + this.navigationModalStack.unshift(componentId); } clearNavigationComponents = () => { @@ -25,30 +42,20 @@ class EphemeralStore { this.navigationModalStack = []; } - addNavigationComponentId = (componentId: string) => { - this.addToNavigationComponentIdStack(componentId); - this.addToAllNavigationComponentIds(componentId); - }; - - addNavigationModal = (componentId: string) => { - this.navigationModalStack.unshift(componentId); + getNavigationTopComponentId = () => { + return this.navigationComponentIdStack[0]; } - addToNavigationComponentIdStack = (componentId: string) => { - const index = this.navigationComponentIdStack.indexOf(componentId); - if (index >= 0) { - this.navigationComponentIdStack = this.navigationComponentIdStack.slice(index, 1); - } - - this.navigationComponentIdStack.unshift(componentId); + getNavigationTopModalId = () => { + return this.navigationModalStack[0]; } - addToAllNavigationComponentIds = (componentId: string) => { - if (!this.allNavigationComponentIds.includes(componentId)) { - this.allNavigationComponentIds.unshift(componentId); - } + getNavigationComponents = () => { + return this.navigationComponentIdStack; } + getVisibleTab = () => this.visibleTab; + hasModalsOpened = () => this.navigationModalStack.length > 0; removeNavigationComponentId = (componentId: string) => { @@ -65,6 +72,44 @@ class EphemeralStore { this.navigationModalStack.splice(index, 1); } } + + setVisibleTap = (tab: string) => { + this.visibleTab = tab; + } + + /** + * Waits until a screen has been mounted and is part of the stack. + * Use this function only if you know what you are doing + * this function will run until the screen appears in the stack + * and can easily run forever if the screen is never prevesented. + * @param componentId string + */ + waitUntilScreenHasLoaded = async (componentId: string) => { + let found = false; + while (!found) { + // eslint-disable-next-line no-await-in-loop + await (new Promise((r) => requestAnimationFrame(r))); + + found = this.navigationComponentIdStack.includes(componentId); + } + } + + /** + * Waits until a screen has been removed as part of the stack. + * Use this function only if you know what you are doing + * this function will run until the screen disappears from the stack + * and can easily run forever if the screen is never removed. + * @param componentId string + */ + waitUntilScreensIsRemoved = async (componentId: string) => { + let found = false; + while (!found) { + // eslint-disable-next-line no-await-in-loop + await (new Promise((r) => requestAnimationFrame(r))); + + found = !this.navigationComponentIdStack.includes(componentId); + } + } } export default new EphemeralStore(); diff --git a/app/utils/helpers.ts b/app/utils/helpers.ts index d72a6258e..3e24cc50f 100644 --- a/app/utils/helpers.ts +++ b/app/utils/helpers.ts @@ -95,7 +95,7 @@ export function safeParseJSON(rawJson: string | Record<string, unknown> | unknow return data; } -export function getCurrentMomentForTimezone(timezone: string) { +export function getCurrentMomentForTimezone(timezone: string | null) { return timezone ? moment.tz(timezone) : moment(); } diff --git a/app/utils/notification/index.ts b/app/utils/notification/index.ts new file mode 100644 index 000000000..993d32d3b --- /dev/null +++ b/app/utils/notification/index.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment-timezone'; +import {createIntl, IntlShape} from 'react-intl'; +import {Alert, DeviceEventEmitter} from 'react-native'; + +import {Events} from '@constants'; +import {DEFAULT_LOCALE, getTranslations} from '@i18n'; +import PushNotifications from '@init/push_notifications'; +import {popToRoot} from '@screens/navigation'; +import {sortByNewest} from '@utils/general'; + +export const convertToNotificationData = (notification: Notification, tapped = true) => { + if (!notification.payload) { + return notification as unknown as NotificationWithData; + } + + const {payload} = notification; + const notificationData: NotificationWithData = { + ...notification, + payload: { + ack_id: payload.ack_id, + channel_id: payload.channel_id, + channel_name: payload.channel_name, + identifier: payload.identifier || notification.identifier, + from_webhook: payload.from_webhook, + message: ((payload.type === 'message') ? payload.message || notification.body : undefined), + override_icon_url: payload.override_icon_url, + override_username: payload.override_username, + post_id: payload.post_id, + root_id: payload.root_id, + sender_id: payload.sender_id, + sender_name: payload.sender_name, + server_id: payload.server_id, + server_url: payload.server_url, + team_id: payload.team_id, + type: payload.type, + use_user_icon: payload.use_user_icon, + version: payload.version, + }, + userInteraction: tapped, + foreground: false, + }; + + return notificationData; +}; + +export const notificationError = (intl: IntlShape, type: 'Team' | 'Channel') => { + const title = intl.formatMessage({id: 'notification.message_not_found', defaultMessage: 'Message not found'}); + let message; + switch (type) { + case 'Channel': + message = intl.formatMessage({ + id: 'notification.not_channel_member', + defaultMessage: 'This message belongs to a channel where you are not a member.', + }); + break; + case 'Team': + message = intl.formatMessage({ + id: 'notification.not_team_member', + defaultMessage: 'This message belongs to a team where you are not a member.', + }); + break; + } + + Alert.alert(title, message); + popToRoot(); +}; + +export const emitNotificationError = (type: 'Team' | 'Channel') => { + const req = setTimeout(() => { + DeviceEventEmitter.emit(Events.NOTIFICATION_ERROR, type); + clearTimeout(req); + }, 500); +}; + +export const scheduleExpiredNotification = async (sessions: Session[], siteName: string, locale = DEFAULT_LOCALE) => { + const session = sessions.sort(sortByNewest)[0]; + const expiresAt = session?.expires_at || 0; + const expiresInDays = Math.ceil(Math.abs(moment.duration(moment().diff(moment(expiresAt))).asDays())); + const intl = createIntl({locale, messages: getTranslations(locale)}); + const body = intl.formatMessage({ + id: 'mobile.session_expired', + defaultMessage: 'Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.', + }, {siteName, daysCount: expiresInDays}); + + if (expiresAt && body) { + //@ts-expect-error: Does not need to set all Notification properties + PushNotifications.scheduleNotification({ + fireDate: expiresAt, + body, + payload: { + userInfo: { + local: true, + }, + }, + }); + } +}; diff --git a/app/utils/post_list/index.ts b/app/utils/post_list/index.ts index 2305d7791..020926f48 100644 --- a/app/utils/post_list/index.ts +++ b/app/utils/post_list/index.ts @@ -4,7 +4,6 @@ import moment from 'moment-timezone'; import {Post} from '@constants'; -import {getTimezone} from '@utils/user'; import type PostModel from '@typings/database/models/servers/post'; @@ -171,7 +170,7 @@ function isJoinLeavePostForUsername(post: PostModel, currentUsername: string): b // are we going to do something with selectedPostId as in v1? function selectOrderedPosts( posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUsername: string, showJoinLeave: boolean, - timezoneEnabled: boolean, currentTimezone: UserTimezone | null, isThreadScreen = false) { + timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false) { if (posts.length === 0) { return []; } @@ -200,9 +199,8 @@ function selectOrderedPosts( const postDate = new Date(post.createAt); if (timezoneEnabled) { const currentOffset = postDate.getTimezoneOffset() * 60 * 1000; - const timezone = getTimezone(currentTimezone); - if (timezone) { - const zone = moment.tz.zone(timezone); + if (currentTimezone) { + const zone = moment.tz.zone(currentTimezone); if (zone) { const timezoneOffset = zone.utcOffset(post.createAt) * 60 * 1000; postDate.setTime(post.createAt + (currentOffset - timezoneOffset)); @@ -354,7 +352,7 @@ export function isStartOfNewMessages(item: string) { export function preparePostList( posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUsername: string, showJoinLeave: boolean, - timezoneEnabled: boolean, currentTimezone: UserTimezone | null, isThreadScreen = false) { + timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false) { const orderedPosts = selectOrderedPosts(posts, lastViewedAt, indicateNewMessages, currentUsername, showJoinLeave, timezoneEnabled, currentTimezone, isThreadScreen); return combineUserActivityPosts(orderedPosts); } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index eab692cb2..9d5461bb0 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -260,6 +260,9 @@ "modal.manual_status.auto_responder.message_dnd": "Would you like to switch your status to \"Do Not Disturb\" and disable Automatic Replies?", "modal.manual_status.auto_responder.message_offline": "Would you like to switch your status to \"Offline\" and disable Automatic Replies?", "modal.manual_status.auto_responder.message_online": "Would you like to switch your status to \"Online\" and disable Automatic Replies?", + "notification.message_not_found": "Message not found", + "notification.not_channel_member": "This message belongs to a channel where you are not a member.", + "notification.not_team_member": "This message belongs to a team where you are not a member.", "password_form.title": "Password Reset", "password_send.checkInbox": "Please check your inbox.", "password_send.description": "To reset your password, enter the email address you used to sign up", diff --git a/assets/base/images/icon.png b/assets/base/images/icon.png index 2e25ee9a7923289a71ff267b0f99052c6c323759..2c215c4b4ea2ab1c5c770c981ec3f88622317a71 100644 GIT binary patch literal 3465 zcma)9c{CJ`7G@YRLm10UTI`i=EQNmdsm79BXlRCE?CV%6HI`;<W1B)kwp2>UjK-R! zu@h0F>@+5_mOYa4yz~Bj=biV*cfWJLd(ORo+<(3=$;Qe=03-q8;NTE2Greqg$T|N8 zFVA6xwMj`I5+B~wDU5?dT>jtSG_#XiI}|y?>`X3lR1Tb2JZ!kV5taxJj;ed$eK+9Y zrc-8@5%v+B-wRxQ9Cwa&#{}V)$$S$FKIP%8A~&|>Iq3#6Mm1waP=(7fF7U?b_~fIT zwa_V=9A*3lR!5=12~wIDi(0`zf<+}P#*mNHfx5?X7y;koH9;j0o6^VaX!8WNLa^)m zys&}Q+l60N^EV@nMArv@&l3t`-p=2S_LrCtoKjD2>(EYpqV@!E6=h3Qs3>KKI@)7F zN{VXnVq!UM$sh|iB=9-ye~jm@8^7#*li0HJnR<LUT6_QY-)7*`j;Ct?|55@YU~Hf4 z3yQ7~?5O@bvzph%8rl7`S0gyNId9k$Jrw?3Qa#1IF735Wya+6A=x;=(Z&X06Z{Rd6 zSrIODZB>#|UJtS1CDz2bk7IS}v~#;G@8z6-z-5a-OS&9%oL%pI#lXJ&x#D+DH>OG@ zJ<B=i@6)e}m7=p&Sej$W^v@4KJ9UkpwYkyW17~}C(-;@xfL5u4k7h)Ik1O7J<K_?p z`P-No-0Z($^$pl1udEhxyOp0(sBXUnq|!ePwd^mmyv1_5@gT4iPWhyPZ>dqJa8kLc zhZUB0`gftPwmv2>A*J*LLhO@lFD%+~XCzuxar|3>1s}ho{~Gp|4S&rbsGOKHfQd9L zWx5Ii_ROCh`<f12je5VM#Qv6U!JFFWSMYmP?3})?_j7LmVv41J*iwJ9uFPs?2sJXA zZJbm@Ez(;Ha&kPWJ+ipj<VMPg6%8s6A9?i!$ORl=a(_VZL_~5&0mC_sdoCdiN`>(W z-P)FnVB(1Y4Q1RaxO=QZ6*vv5&?u=xIHNe&6355=FA(LsTK8wG>>}Q={G~_Di6?JY z^{wpx(!z0RAb9(buacN0)DHHmiDJ)g!Tp%hk=E_s3D*FZ)C{$@0!HCDE(0HOA2O2p zW#`%Uvi0@>&kvY;d(}hh2w62yNUNW~i2|V;iCK(SXL`eDt_*cqM^HZ}?%JBpWcD-( z!Q$(M{Pm&s(-=>O7W<_rkz6Q%E(S%*%;mb-{kZmW{`qk1Y-UfsV3gG3I$@~6<!rWd zZ3|&6cF*r!Im(aBk2zi$qNvuPtTo1FxOSUe_hsVW;-p*`1pUaCd9xyvEBSo=weJK% zEUg*>Z?$qUtD1YC0dG`POZB)4cx1&8=6o|K)72M*Vbip<dX_jQC1n#vhL9t6RbQ{W z$+H0|?hmWG4TZA(>wJFSwZR)86nKuo=c^o4I)Dk4<Z5*=<x-?j_Iob7ULEjAyt8Y@ zERXmI@>*gk10PXPJIABm&06sNDv^YHTpx##=A$HjAbymZOgw#>=2^#dW0FA4%T?;! z+i(iek5ftfRu(6_%D`;|ewtQ@34B=;saH}-iIIssx@4|efUbdyOMh2Ha`P;Eew8rM zBR5_kdE{vz<!LKs?pIIrm`ls3iOLSK%amj;KB-F~$vK{Kl2^{<UIARf77fd_-)Sg3 zxusC*n%0`W>m)zfe3>x_SDRo=%U9lMWRkFkL5@ipeTDF7r7lG^6t&vga6A~z^Aj(v z-|xzi2t+80YqM4bre*8IaAGhX5oW005-B9!el8EE1z}5U_9R#FDNpuhwT|{7gQ1$R z-W2oTTgE`Ip_Eysrq|(FK2#N5{Y5Ia^TM*bG9Xrr7ILq0$;Ao0<iBoAl9kK1!MDoL z9)8=h=5{>YZ~>CRyHq}dz6($l(jH9&eg*w>;vV*2M>gQ^O<o;UvUTmwYfY1<xuaVR z8W?MyWA(?NBJc9yZSrP!y6X3KdV#}LQRP*Lagy{zO%gQfbW&bN!&wu5&1S|a>n~3! z?6UJE`OZ`_olmdrXu^>+du#+Cz|Lh+$vME4ssiyQqRODz!(MxXBztVFLQD8hpXGtT z^wH1@F~9Dt81|)GW`O#=;=rcY)sEjP6kE%EC{nGf2^q9KlyCOXl_z%`9(;Zn;&oQm znQSPcL2RA8-q>?|-H`XXGe`!G*KFk}dj7u9Sq(I#IM$%3kNKM6&!>zkr;7J)AB}q^ z>X~-Ibc4kXD~{al;r5y!+PG{Gu4^2za|&aA!2P*&zWwao0i^TZ4;iNi<P8-=$ugC^ zQ(E@-`6`Ox*8ygBlI*t5UgU|=u0@XGlxGB6h^beFQpn#YRKQc+OgWXSqcRQMAoZfY z0jE#Tzr}(3LW)<e$_m3A_T{3u#<o%$`@nLYQ#RQvVI}|3oz0J-yWB#TNc0tl*D881 zIoDoWAc)~7vOPLdGJ5Oa<WnnW-mfi3iJ+oRrum1dzmFAGU4$Q9EEdmxAub0{`m*=Q zhQ(ex<`}?Qs80td8I=IPZ#?MKg)XvvFELc4HK0mH>A54`QzTpF^tb0G;LO>4s1Ejo zHPg=c**|W@7LiRNFq4TOG%eR_zC}0`dQ*{iDWtb4Mc>=Eje`8bZTu@fTqg);d}LwZ zPrKYL)iwy$``W=rK5z3aCU5FS>VtlgZLY%)3||Ouyl{Qrz!bGaQGvDkbv6EC0sfYm z&-k&=o$`;K3EZEm6`x{U;q2PQsQv*8IaYHEKI!h~Y*F*@{Jbg_BR(uhJr4hv*f%~@ ziGRFh>H)|DlTc;!4xcxjiK+^KwwI#%F0SFv*vlm=zDJTXx7MgUxhYM?yHB1C>yC4* zUqZDc_j!HWl$asNh`?@m-|e{5t%sur$A(7Ux4?c9h;*X4x4h0j8+hK=h{VR^0`TY8 zUW%C?$w;rBe?u-&kXE^B(=F@Q$#kRZ6zUMpyqdKhL~DFvBsVlwY5Gh3e7$((pH({J zg7aOs&Vb4%3XljPf{Kd4cC?T)xU0H=riq4h(}}9qj5}kZjp}okF$6g=*t`_-fcWB* z!!So(*=Ug8teVj5lqi}W9if473HouaHoMsvpZYN!oVp2uWoPI8=I@*nM&rU~0qqEd zdBlzS!ItahP+|KKy&~8_-OSyRnfnB}Q;rs~e?3ENF`Ih&=C1AlWkBWvQ74qv_(7(f z@B#wm2aHI8QWzQNE{{vL$b_BL9V?*sWdCsHLBa<kC4&W-PLEG)c8gBBCHAwxoqVa& zeako0vRL|d$zZ%7C*4ZSt;ZhJA?x<0u-#qxCUvF^QI?|w7*3hsrGYG%$(+?MQw8+% z8ltLd7+kSC9U-4tFBzJl*17aS<P~T2yNhc{xTpqvE?MQwy(|>mOaP}!Bz%1Pi<NN3 zMJx5O34ePvGcW83SEz;V`?vGa7%xa0p*<E?^<}^8bL9MlN7z5EA!l~imlkpvjgi=T zr=ij41&SMwiC!krAq03d<cNtCLHDsR5#>KvskMPBe^d^Z%q#j6)MLsBYGs&cXSWT{ zch?&Dp=s+ThWt|k1It2TtnFRyxSE~CGyNZmq64GHH*oE}1&n;Xm|e@NM9U5mXZR(H z7MIZ1Ton>>r*fJMI#CBnT$S`ieuzl*m}wv*mv?%nxUszhLgwR_f?qVA1=~KNTzvYe z%NtdG^6jL!R})hSQVE|tKe)eMzYwiqOXL(qMpZPKywC9d(jZD<+14DrWOJRQOxyf1 z&UuZX&5|h6jkMdh3J)RvnpO1YY%Mkf5t`fhelW(13nFd@txeBKjowe&iQ9pGuk;C= zUa*{j&dDs!-7-RBIb69fJ9aGN&{DaYj$b`EPrBc2{WxSi<NeQ%y%!<zbb&<Pijwku zq-<)VOZ3&qMZCP#H6NMNMh!+YoS*l8^#xJOLt37Sfu|!;TG`sHljxvpscY_h_{EaU zS&>d;mqS;C;h}f1J1B%w%6oX%%+QQH&2Netgh&zSnxD17<plk0iQO|tmYGo^P@%== zrXb3>VOU+ZhDUtoMj)LU)1Uuq<RJB4go4`Leh3tBUR(biGd%l<&E-GeH>rdvo5d*y zjjXt9jVqEjozHHG#R*Tnm0bTzUmqqR<LbODFlvH-v9El`^NZXl)vQg}-tPy;_PQVZ zTG>=hU5!vxTf4o6kbyVe{QBH?vbvI%LOj^~Ax00z_V?!>yzK>9y-^4-I%Di=-MIa_ zMwzo*bH&WlKp|HI#T}=$KR=}`i!mVTcACU)#9EIm$w|`zDGVQHSL?(bfmAc1uHM#o z#5a-rez3BP>czamTrkGXMS>$#md3l)bXB<{i(6g%iiVJ*BWC8xq3so>d7PZ6OVB+T zS>uP=PnbgY+}}K1L7oIvKr9}%$G5#{umH6oGWx(?cWw^knVUB##4!|!nz0|$wvrbm zty+<!i-*q=jfRs-6&3OsWr&Zp>y;m#=vJ!tkv&yHUKHM6%*r+p=$@2P<n^WCYc={r zU9jc0{*}Us>qxq(*+;E-hqWl#d#e#smc<^D|6hXmZvn#<uoL`HeX87uahRMq%#c=> JD=%V6{{lb3b{PNw literal 8285 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 b-dleJ!%T8Okgoue00000NkvXXu0mjfh$VEA diff --git a/index.ts b/index.ts index 46a21a166..373d56bbf 100644 --- a/index.ts +++ b/index.ts @@ -47,19 +47,24 @@ if (Platform.OS === 'android') { AppRegistry.registerComponent('MattermostShare', () => ShareExtension); } +let alreadyInitialized = false; Navigation.events().registerAppLaunchedListener(async () => { - GlobalEventHandler.init(); - ManagedApp.init(); - registerNavigationListeners(); - registerScreens(); + // See caution in the library doc https://wix.github.io/react-native-navigation/docs/app-launch#android + if (!alreadyInitialized) { + alreadyInitialized = true; + GlobalEventHandler.init(); + ManagedApp.init(); + registerNavigationListeners(); + registerScreens(); - const serverCredentials = await getAllServerCredentials(); - const serverUrls = serverCredentials.map((credential) => credential.serverUrl); + const serverCredentials = await getAllServerCredentials(); + const serverUrls = serverCredentials.map((credential) => credential.serverUrl); - await DatabaseManager.init(serverUrls); - await NetworkManager.init(serverCredentials); - await WebsocketManager.init(serverCredentials); - PushNotifications.init(); + await DatabaseManager.init(serverUrls); + await NetworkManager.init(serverCredentials); + await WebsocketManager.init(serverCredentials); + PushNotifications.init(); + } initialLaunch(); }); @@ -76,8 +81,10 @@ function componentWillAppear({componentId}: ComponentDidAppearEvent) { } } -function componentDidAppearListener({componentId}: ComponentDidAppearEvent) { - EphemeralStore.addNavigationComponentId(componentId); +function componentDidAppearListener({componentId, passProps}: ComponentDidAppearEvent) { + if (!(passProps as any)?.overlay) { + EphemeralStore.addNavigationComponentId(componentId); + } } function componentDidDisappearListener({componentId}: ComponentDidDisappearEvent) { diff --git a/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift b/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift index a5565aafa..19bc8caeb 100644 --- a/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift +++ b/ios/Gekidou/Sources/Gekidou/Networking/PushNotification.swift @@ -22,16 +22,15 @@ public struct AckNotification: Codable { case type = "type" case id = "ack_id" case postId = "post_id" - case serverUrl = "server_url" + case server_id = "server_id" case isIdLoaded = "id_loaded" case platform = "platform" } public enum AckNotificationRequestKeys: String, CodingKey { case type = "type" - case id = "ack_id" + case id = "id" case postId = "post_id" - case serverUrl = "server_url" case isIdLoaded = "is_id_loaded" case receivedAt = "received_at" case platform = "platform" @@ -42,11 +41,15 @@ public struct AckNotification: Codable { id = try container.decode(String.self, forKey: .id) type = try container.decode(String.self, forKey: .type) postId = try? container.decode(String.self, forKey: .postId) - isIdLoaded = (try? container.decode(Bool.self, forKey: .isIdLoaded)) == true + if container.contains(.isIdLoaded) { + isIdLoaded = (try? container.decode(Bool.self, forKey: .isIdLoaded)) == true + } else { + isIdLoaded = false + } receivedAt = Date().millisecondsSince1970 - if let decodedServerUrl = try? container.decode(String.self, forKey: .serverUrl) { - serverUrl = decodedServerUrl + if let decodedIdentifier = try? container.decode(String.self, forKey: .server_id) { + serverUrl = try Database.default.getServerUrlForServer(decodedIdentifier) } else { serverUrl = try Database.default.getOnlyServerUrl() } @@ -112,8 +115,8 @@ extension Network { let channelId = notification.userInfo["channel_id"] as! String let serverUrl = notification.userInfo["server_url"] as! String - let currentUserId = try! Database.default.queryCurrentUserId(serverUrl) let currentUser = try! Database.default.queryCurrentUser(serverUrl) + let currentUserId = currentUser?[Expression<String>("id")] let currentUsername = currentUser?[Expression<String>("username")] var postData: PostData? = nil @@ -122,7 +125,7 @@ extension Network { var users: Set<User> = Set() group.enter() - let since = try! Database.default.queryPostsSinceForChannel(withId: channelId, withServerUrl: serverUrl) + let since = try? Database.default.queryPostsSinceForChannel(withId: channelId, withServerUrl: serverUrl) self.fetchPostsForChannel(withId: channelId, withSince: since, withServerUrl: serverUrl) { data, response, error in if self.responseOK(response), let data = data { postData = try! JSONDecoder().decode(PostData.self, from: data) @@ -130,7 +133,7 @@ extension Network { var authorIds: Set<String> = Set() var usernames: Set<String> = Set() postData!.posts.forEach{post in - if (post.user_id != currentUserId) { + if (currentUserId != nil && post.user_id != currentUserId) { authorIds.insert(post.user_id) } self.matchUsername(in: post.message).forEach{ @@ -141,32 +144,34 @@ extension Network { } if (authorIds.count > 0) { - let existingIds = try! Database.default.queryUsers(byIds: authorIds, withServerUrl: serverUrl) - userIdsToLoad = authorIds.filter { !existingIds.contains($0) } - if (userIdsToLoad.count > 0) { - group.enter() - self.fetchUsers(byIds: Array(userIdsToLoad), withServerUrl: serverUrl) { data, response, error in - if self.responseOK(response), let data = data { - let usersData = try! JSONDecoder().decode([User].self, from: data) - usersData.forEach { users.insert($0) } + if let existingIds = try? Database.default.queryUsers(byIds: authorIds, withServerUrl: serverUrl) { + userIdsToLoad = authorIds.filter { !existingIds.contains($0) } + if (userIdsToLoad.count > 0) { + group.enter() + self.fetchUsers(byIds: Array(userIdsToLoad), withServerUrl: serverUrl) { data, response, error in + if self.responseOK(response), let data = data { + let usersData = try! JSONDecoder().decode([User].self, from: data) + usersData.forEach { users.insert($0) } + } + group.leave() } - group.leave() } } } if (usernames.count > 0) { - let existingUsernames = try! Database.default.queryUsers(byUsernames: usernames, withServerUrl: serverUrl) - usernamesToLoad = usernames.filter{ !existingUsernames.contains($0)} - if (usernamesToLoad.count > 0) { - group.enter() + if let existingUsernames = try? Database.default.queryUsers(byUsernames: usernames, withServerUrl: serverUrl) { + usernamesToLoad = usernames.filter{ !existingUsernames.contains($0)} + if (usernamesToLoad.count > 0) { + group.enter() - self.fetchUsers(byUsernames: Array(usernamesToLoad), withServerUrl: serverUrl) { data, response, error in - if self.responseOK(response), let data = data { - let usersData = try! JSONDecoder().decode([User].self, from: data) - usersData.forEach { users.insert($0) } + self.fetchUsers(byUsernames: Array(usernamesToLoad), withServerUrl: serverUrl) { data, response, error in + if self.responseOK(response), let data = data { + let usersData = try! JSONDecoder().decode([User].self, from: data) + usersData.forEach { users.insert($0) } + } + group.leave() } - group.leave() } } } @@ -179,12 +184,13 @@ extension Network { group.wait() group.enter() - if (postData != nil) { - let db = try! Database.default.getDatabaseForServer(serverUrl) - try! db.transaction { - try! Database.default.handlePostData(db, postData!, channelId, since != nil) - if (users.count > 0) { - try! Database.default.insertUsers(db, users) + if (postData != nil && postData?.posts != nil && postData!.posts.count > 0) { + if let db = try? Database.default.getDatabaseForServer(serverUrl) { + try? db.transaction { + try? Database.default.handlePostData(db, postData!, channelId, since != nil) + if (users.count > 0) { + try? Database.default.insertUsers(db, users) + } } } } diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift index 942ad17d3..5dbf7d13e 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database+Posts.swift @@ -98,9 +98,9 @@ extension Database { var earliest: Int64? var latest: Int64? - if let result = try db.pluck(earliestLatestQuery) { - earliest = try result.get(earliestCol) - latest = try result.get(latestCol) + if let result = try? db.pluck(earliestLatestQuery) { + earliest = try? result.get(earliestCol) + latest = try? result.get(latestCol) } else { return nil } @@ -146,22 +146,19 @@ extension Database { public func handlePostData(_ db: Connection, _ postData: PostData, _ channelId: String, _ usedSince: Bool = false) throws { let sortedChainedPosts = chainAndSortPosts(postData) try insertOrUpdatePosts(db, sortedChainedPosts, channelId) - try handlePostsInChannel(db, channelId, postData, usedSince) + let earliest = sortedChainedPosts.first!.create_at + let latest = sortedChainedPosts.last!.create_at + try handlePostsInChannel(db, channelId, earliest, latest, usedSince) try handlePostsInThread(db, postData.posts) } - private func handlePostsInChannel(_ db: Connection, _ channelId: String, _ postData: PostData, _ usedSince: Bool = false) throws { - let firstId = postData.order.first - let lastId = postData.order.last - let earliest = postData.posts.first(where: { $0.id == lastId})!.create_at - let latest = postData.posts.first(where: { $0.id == firstId})!.create_at - + private func handlePostsInChannel(_ db: Connection, _ channelId: String, _ earliest: Int64, _ latest: Int64, _ usedSince: Bool = false) throws { if usedSince { - try updatePostsInChannelLatestOnly(db, channelId, latest) + try? updatePostsInChannelLatestOnly(db, channelId, latest) } else { let updated = try updatePostsInChannelEarliestAndLatest(db, channelId, earliest, latest) if (!updated) { - try insertPostsInChannel(db, channelId, earliest, latest) + try? insertPostsInChannel(db, channelId, earliest, latest) } } } @@ -393,10 +390,10 @@ extension Database { fileSetter.append(ext <- f["extension"] as! String) fileSetter.append(size <- f["size"] as! Int64) fileSetter.append(mimeType <- f["mime_type"] as! String) - fileSetter.append(width <- f["width"] as! Int64) - fileSetter.append(height <- f["height"] as! Int64) + fileSetter.append(width <- (f["width"] as? Int64 ?? 0)) + fileSetter.append(height <- (f["height"] as? Int64 ?? 0)) fileSetter.append(localPath <- "") - fileSetter.append(imageThumbnail <- f["mini_preview"] as? String) + fileSetter.append(imageThumbnail <- (f["mini_preview"] as? String ?? "")) fileSetter.append(statusCol <- "created") fileSetters.append(fileSetter) diff --git a/ios/Gekidou/Sources/Gekidou/Storage/Database.swift b/ios/Gekidou/Sources/Gekidou/Storage/Database.swift index d9c71a067..2534d1974 100644 --- a/ios/Gekidou/Sources/Gekidou/Storage/Database.swift +++ b/ios/Gekidou/Sources/Gekidou/Storage/Database.swift @@ -79,13 +79,13 @@ public class Database: NSObject { } public func generateId() -> String { - let alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" + let alphabet = Array("0123456789abcdefghijklmnopqrstuvwxyz") let alphabetLenght = alphabet.count let idLenght = 16 var id = "" - + for _ in 1...(idLenght / 2) { - let random = floor(drand48() * Double(alphabetLenght) * Double(alphabetLenght)) + let random = floor(Double.random(in: 0..<1) * Double(alphabetLenght) * Double(alphabetLenght)) let firstIndex = Int(floor(random / Double(alphabetLenght))) let lastIndex = Int(random) % alphabetLenght id += String(alphabet[firstIndex]) @@ -98,8 +98,9 @@ public class Database: NSObject { public func getOnlyServerUrl() throws -> String { let db = try Connection(DEFAULT_DB_PATH) let url = Expression<String>("url") + let identifier = Expression<String>("identifier") let lastActiveAt = Expression<Int64>("last_active_at") - let query = serversTable.select(url).filter(lastActiveAt > 0) + let query = serversTable.select(url).filter(lastActiveAt > 0 && identifier != "") var serverUrl: String? for result in try db.prepare(query) { @@ -117,6 +118,22 @@ public class Database: NSObject { throw DatabaseError.NoResults(query.asSQL()) } + public func getServerUrlForServer(_ id: String) throws -> String { + let db = try Connection(DEFAULT_DB_PATH) + let url = Expression<String>("url") + let identifier = Expression<String>("identifier") + let query = serversTable.select(url).filter(identifier == id) + + if let server = try db.pluck(query) { + let serverUrl: String? = try server.get(url) + if (serverUrl != nil) { + return serverUrl! + } + } + + throw DatabaseError.NoResults(query.asSQL()) + } + internal func getDatabaseForServer(_ serverUrl: String) throws -> Connection { let db = try Connection(DEFAULT_DB_PATH) let url = Expression<String>("url") diff --git a/ios/Mattermost/AppDelegate.m b/ios/Mattermost/AppDelegate.m index 2cfddc478..69fbd2f9c 100644 --- a/ios/Mattermost/AppDelegate.m +++ b/ios/Mattermost/AppDelegate.m @@ -96,21 +96,19 @@ MattermostBucket* bucket = nil; UIApplicationState state = [UIApplication sharedApplication].applicationState; NSString* action = [userInfo objectForKey:@"type"]; NSString* channelId = [userInfo objectForKey:@"channel_id"]; + BOOL isClearAction = (action && [action isEqualToString: NOTIFICATION_CLEAR_ACTION]); - - RuntimeUtils *utils = [[RuntimeUtils alloc] init]; - - if ((action && [action isEqualToString: NOTIFICATION_CLEAR_ACTION]) || (state == UIApplicationStateInactive)) { + if (isClearAction) { // If received a notification that a channel was read, remove all notifications from that channel (only with app in foreground/background) [self cleanNotificationsFromChannel:channelId]; + [[Network default] postNotificationReceipt:userInfo]; } - - [[Network default] postNotificationReceipt:userInfo]; - - [utils delayWithSeconds:0.2 closure:^(void) { - // This is to notify the NotificationCenter that something has changed. + + if (state != UIApplicationStateActive || isClearAction) { + [RNNotifications didReceiveBackgroundNotification:userInfo withCompletionHandler:completionHandler]; + } else { completionHandler(UIBackgroundFetchResultNewData); - }]; + } } -(void)cleanNotificationsFromChannel:(NSString *)channelId { diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index fa6189b9a..18d06bccf 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -66,7 +66,7 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.title = channelName } - let userInfoKeys = ["channel_name", "team_id", "sender_id", "root_id", "override_username", "override_icon_url", "from_webhook"] + let userInfoKeys = ["channel_name", "team_id", "sender_id", "root_id", "override_username", "override_icon_url", "from_webhook", "message"] for key in userInfoKeys { if let value = json[key] as? String { bestAttemptContent.userInfo[key] = value diff --git a/package-lock.json b/package-lock.json index 6729cc976..4e53b19b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3469,7 +3469,7 @@ }, "node_modules/@mattermost/react-native-network-client": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/mattermost/react-native-network-client.git#7b73186df4b8dd4858544bb426e0f69c1cfb4e2d", + "resolved": "git+ssh://git@github.com/mattermost/react-native-network-client.git#43907501f3547f53434475d44bc969c0d02b4cf7", "license": "MIT", "dependencies": { "validator": "13.6.0", @@ -27632,7 +27632,7 @@ "requires": {} }, "@mattermost/react-native-network-client": { - "version": "git+ssh://git@github.com/mattermost/react-native-network-client.git#7b73186df4b8dd4858544bb426e0f69c1cfb4e2d", + "version": "git+ssh://git@github.com/mattermost/react-native-network-client.git#43907501f3547f53434475d44bc969c0d02b4cf7", "from": "@mattermost/react-native-network-client@github:mattermost/react-native-network-client", "requires": { "validator": "13.6.0", diff --git a/patches/@nozbe+watermelondb+0.23.0.patch b/patches/@nozbe+watermelondb+0.23.0.patch index b614c8a8c..6f8c47ec9 100644 --- a/patches/@nozbe+watermelondb+0.23.0.patch +++ b/patches/@nozbe+watermelondb+0.23.0.patch @@ -11,10 +11,18 @@ index bb8f49a..1036aca 100644 jsi?: boolean } diff --git a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/Database.kt b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/Database.kt -index 802f137..640b08c 100644 +index 802f137..785239e 100644 --- a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/Database.kt +++ b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/Database.kt -@@ -18,6 +18,18 @@ class Database(private val name: String, private val context: Context) { +@@ -11,17 +11,35 @@ import java.io.File + class Database(private val name: String, private val context: Context) { + + private val db: SQLiteDatabase by lazy { +- SQLiteDatabase.openOrCreateDatabase( ++ SQLiteDatabase.openDatabase( + // TODO: This SUCKS. Seems like Android doesn't like sqlite `?mode=memory&cache=shared` mode. To avoid random breakages, save the file to /tmp, but this is slow. + // NOTE: This is because Android system SQLite is not compiled with SQLITE_USE_URI=1 + // issue `PRAGMA cache=shared` query after connection when needed if (name == ":memory:" || name.contains("mode=memory")) { context.cacheDir.delete() File(context.cacheDir, name).path @@ -26,10 +34,21 @@ index 802f137..640b08c 100644 + val truePath = name.substringAfterLast("file://").substringBeforeLast("/") + + // Creates the directory -+ val fileObj = File(truePath, "databases") -+ fileObj.mkdir() ++ if (!truePath.contains("databases")) { ++ val fileObj = File(truePath, "databases") ++ fileObj.mkdir() + -+ File("${truePath}/databases", dbName).path ++ File("${truePath}/databases", dbName).path ++ } else { ++ File(truePath, dbName).path ++ } } else // On some systems there is some kind of lock on `/databases` folder ¯\_(ツ)_/¯ context.getDatabasePath("$name.db").path.replace("/databases", ""), +- null) ++ null, ++ SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING, ++ ) + } + + var userVersion: Int diff --git a/types/database/database.d.ts b/types/database/database.d.ts index b5428d335..3cbe92d41 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -12,8 +12,6 @@ import {DatabaseType} from './enums'; import type AppDataOperator from '@database/operator/app_data_operator'; import type ServerDataOperator from '@database/operator/server_data_operator'; -import type {Config} from '@typings/database/models/servers/config'; -import type {License} from '@typings/database/models/servers/license'; import type System from '@typings/database/models/servers/system'; export type WithDatabaseArgs = { database: Database } @@ -23,12 +21,14 @@ export type CreateServerDatabaseConfig = { dbType?: DatabaseType.DEFAULT | DatabaseType.SERVER; displayName?: string; serverUrl?: string; + identifier?: string; }; export type RegisterServerDatabaseArgs = { databaseFilePath: string; displayName: string; serverUrl: string; + identifier?: string; }; export type AppDatabase = { @@ -246,9 +246,9 @@ export type HandleDraftArgs = PrepareOnly & { }; export type LoginArgs = { - config: Partial<Config>; + config: Partial<ClientConfig>; ldapOnly?: boolean; - license: Partial<License>; + license: Partial<ClientLicense>; loginId: string; mfaToken?: string; password: string; diff --git a/types/database/models/app/servers.d.ts b/types/database/models/app/servers.d.ts index 9f2cea0be..dfc066baa 100644 --- a/types/database/models/app/servers.d.ts +++ b/types/database/models/app/servers.d.ts @@ -17,18 +17,12 @@ export default class ServersModel extends Model { /** display_name : The server display name */ displayName: string; - /** mention_count : The number of mention on this server */ - mentionCount: number; - - /** unread_count : The number of unread messages on this server */ - unreadCount: number; - /** url : The online address for the Mattermost server */ url: string; /** last_active_at: The last time this server was active */ lastActiveAt!: number; - /** is_secured: Determines if the protocol used for this server url is HTTP or HTTPS */ - isSecured!: boolean; + /** diagnostic_id: Determines the installation identifier of a server */ + identifier!: string; } diff --git a/types/database/models/servers/channel.d.ts b/types/database/models/servers/channel.d.ts index 8396b0f8b..4ce5ea9fd 100644 --- a/types/database/models/servers/channel.d.ts +++ b/types/database/models/servers/channel.d.ts @@ -73,4 +73,6 @@ export default class ChannelModel extends Model { /** settings: User specific settings/preferences for this channel */ settings: Relation<MyChannelSettingsModel>; + + toApi = () => Channel; } diff --git a/types/database/models/servers/my_channel.d.ts b/types/database/models/servers/my_channel.d.ts index 9b6ec95af..176933985 100644 --- a/types/database/models/servers/my_channel.d.ts +++ b/types/database/models/servers/my_channel.d.ts @@ -29,6 +29,9 @@ export default class MyChannelModel extends Model { /** roles : The user's privileges on this channel */ roles: string; + /** viewed_at : The timestamp showing when the user's last opened this channel (this is used for the new line message indicator) */ + viewedAt: number; + /** channel : The relation pointing to the CHANNEL table */ channel: Relation<ChannelModel>; } diff --git a/types/global/push_notifications.d.ts b/types/global/push_notifications.d.ts index 8e5805759..e8a49eff5 100644 --- a/types/global/push_notifications.d.ts +++ b/types/global/push_notifications.d.ts @@ -19,9 +19,11 @@ interface NotificationUserInfo { } interface NotificationData { + ack_id?: string; body?: string; channel_id: string; channel_name?: string; + identifier?: string; from_webhook?: string; message?: string; override_icon_url?: string; @@ -29,6 +31,8 @@ interface NotificationData { post_id: string; root_id?: string; sender_id?: string; + sender_name?: string; + server_id?: string; server_url?: string; team_id?: string; type: string;