From d1e0c99c3d263d3e355aaba4c10d6bac67c119e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Tue, 19 Oct 2021 19:16:10 +0200 Subject: [PATCH] Gekidou websockets (#5671) * Adapt websocket client * Add Websocket Manager * Address feedback * Start websockets on init and login, and invalidate on logout * Add temporal logging and bug fixing * Working WS * Add reconnect actions and periodic updates * Address feedback * Add missing change * Several improvements on websocket reconnect and channel handling * fix gekidou package-lock.json * update Podfile.lock * Address feedback * Address feedback * Address feedback * Fix update channel delete at * Catch errors on batchRecords * Update app/queries/servers/channel.ts Co-authored-by: Elias Nahum Co-authored-by: Elias Nahum --- app/actions/local/channel.ts | 63 ++++- app/actions/local/team.ts | 65 +++-- app/actions/local/user.ts | 43 +++- app/actions/remote/session.ts | 7 + app/actions/remote/team.ts | 29 +-- app/actions/remote/user.ts | 207 +++++----------- app/actions/websocket/channel.ts | 103 ++++++++ app/actions/websocket/index.ts | 283 +++++++++++++++++++++ app/actions/websocket/teams.ts | 51 ++++ app/client/rest/teams.ts | 2 +- app/client/websocket/index.ts | 409 +++++++++++++++++++------------ app/constants/database.ts | 1 + app/constants/events.ts | 4 + app/constants/websocket.ts | 4 + app/database/manager/index.ts | 2 +- app/init/global_event_handler.ts | 3 +- app/init/websocket_manager.ts | 184 ++++++++++++++ app/queries/servers/channel.ts | 29 ++- app/queries/servers/system.ts | 18 ++ app/queries/servers/team.ts | 116 ++++++++- index.ts | 2 + ios/Podfile | 2 +- ios/Podfile.lock | 8 +- types/api/config.d.ts | 1 + types/database/database.d.ts | 2 - types/database/raw_values.d.ts | 2 +- 26 files changed, 1277 insertions(+), 363 deletions(-) create mode 100644 app/actions/websocket/channel.ts create mode 100644 app/actions/websocket/index.ts create mode 100644 app/actions/websocket/teams.ts create mode 100644 app/init/websocket_manager.ts diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index 33351d9d06..c1f42f4480 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -10,9 +10,9 @@ import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from '@actions/remot import {General} from '@constants'; import DatabaseManager from '@database/manager'; import {privateChannelJoinPrompt} from '@helpers/api/channel'; -import {prepareMyChannelsForTeam, queryMyChannel} from '@queries/servers/channel'; -import {queryCommonSystemValues, setCurrentChannelId, setCurrentTeamAndChannelId} from '@queries/servers/system'; -import {addChannelToTeamHistory, prepareMyTeams, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team'; +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 type ChannelModel from '@typings/database/models/servers/channel'; @@ -221,3 +221,60 @@ export const switchToChannelByName = async (serverUrl: string, channelName: stri return {error}; } }; + +export const localRemoveUserFromChannel = async (serverUrl: string, channelId: string) => { + const serverDatabase = DatabaseManager.serverDatabases[serverUrl]; + if (!serverDatabase) { + return; + } + + const {operator, database} = serverDatabase; + + const myChannel = await queryMyChannel(database, channelId); + if (myChannel) { + const channel = await myChannel.channel.fetch() as ChannelModel; + const models = await prepareDeleteChannel(channel); + let teamId = channel.teamId; + if (teamId) { + teamId = await queryCurrentTeamId(database); + } + const system = await removeChannelFromTeamHistory(operator, teamId, channel.id, true); + if (system) { + models.push(...system); + } + if (models.length) { + try { + await operator.batchRecords(models); + } catch { + // eslint-disable-next-line no-console + console.log('FAILED TO BATCH CHANGES FOR REMOVE USER FROM CHANNEL'); + } + } + } +}; + +export const localSetChannelDeleteAt = async (serverUrl: string, channelId: string, deleteAt: number) => { + const serverDatabase = DatabaseManager.serverDatabases[serverUrl]; + if (!serverDatabase) { + return; + } + + const {operator, database} = serverDatabase; + + const channels = await queryChannelsById(database, [channelId]); + if (!channels?.length) { + return; + } + + const channel = channels[0]; + const model = channel.prepareUpdate((c) => { + c.deleteAt = deleteAt; + }); + + try { + await operator.batchRecords([model]); + } catch { + // eslint-disable-next-line no-console + console.log('FAILED TO BATCH CHANGES FOR CHANNEL DELETE AT'); + } +}; diff --git a/app/actions/local/team.ts b/app/actions/local/team.ts index 2a1090f57a..29dba19a98 100644 --- a/app/actions/local/team.ts +++ b/app/actions/local/team.ts @@ -1,16 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {NativeModules} from 'react-native'; +import {DeviceEventEmitter} from 'react-native'; -import {fetchPostsForChannel} from '@actions/remote/post'; -import {Device} from '@constants'; +import {fetchMyChannelsForTeam} from '@actions/remote/channel'; +import {fetchPostsForChannel, fetchPostsForUnreadChannels} from '@actions/remote/post'; +import {fetchAllTeams} from '@actions/remote/team'; +import Events from '@constants/events'; import DatabaseManager from '@database/manager'; import {queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system'; -import {queryLastChannelFromTeam} from '@queries/servers/team'; +import {prepareDeleteTeam, queryMyTeamById, removeTeamFromTeamHistory, queryLastChannelFromTeam, addTeamToTeamHistory} from '@queries/servers/team'; +import {isTablet} from '@utils/helpers'; -const {MattermostManaged} = NativeModules; -const isRunningInSplitView = MattermostManaged.isRunningInSplitView; +import type TeamModel from '@typings/database/models/servers/team'; export const handleTeamChange = async (serverUrl: string, teamId: string) => { const {operator, database} = DatabaseManager.serverDatabases[serverUrl]; @@ -21,15 +23,50 @@ export const handleTeamChange = async (serverUrl: string, teamId: string) => { } let channelId = ''; - if (Device.IS_TABLET) { - const {isSplitView} = await isRunningInSplitView(); - if (!isSplitView) { - channelId = await queryLastChannelFromTeam(database, teamId); - if (channelId) { - fetchPostsForChannel(serverUrl, channelId); - } + if (await isTablet()) { + channelId = await queryLastChannelFromTeam(database, teamId); + if (channelId) { + fetchPostsForChannel(serverUrl, channelId); } } - setCurrentTeamAndChannelId(operator, teamId, channelId); + addTeamToTeamHistory(operator, teamId); + + const {channels, memberships, error} = await fetchMyChannelsForTeam(serverUrl, teamId); + if (error) { + DeviceEventEmitter.emit(Events.TEAM_LOAD_ERROR, serverUrl, error); + } + + if (channels?.length && memberships?.length) { + fetchPostsForUnreadChannels(serverUrl, channels, memberships, channelId); + } +}; + +export const localRemoveUserFromTeam = async (serverUrl: string, teamId: string) => { + const serverDatabase = DatabaseManager.serverDatabases[serverUrl]; + if (!serverDatabase) { + return; + } + + const {operator, database} = serverDatabase; + + const myTeam = await queryMyTeamById(database, teamId); + if (myTeam) { + const team = await myTeam.team.fetch() as TeamModel; + const models = await prepareDeleteTeam(team); + const system = await removeTeamFromTeamHistory(operator, team.id, true); + if (system) { + models.push(...system); + } + if (models.length) { + try { + await operator.batchRecords(models); + } catch { + // eslint-disable-next-line no-console + console.log('FAILED TO BATCH CHANGES FOR REMOVE USER FROM TEAM'); + } + } + + fetchAllTeams(serverUrl); + } }; diff --git a/app/actions/local/user.ts b/app/actions/local/user.ts index 58d74564b2..4755782627 100644 --- a/app/actions/local/user.ts +++ b/app/actions/local/user.ts @@ -2,15 +2,41 @@ // See LICENSE.txt for license information. import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import General from '@constants/general'; import DatabaseManager from '@database/manager'; import {queryRecentCustomStatuses} from '@queries/servers/system'; -import {queryUserById} from '@queries/servers/user'; +import {queryCurrentUser, queryUserById} from '@queries/servers/user'; import {addRecentReaction} from './reactions'; import type Model from '@nozbe/watermelondb/Model'; import type UserModel from '@typings/database/models/servers/user'; +export const setCurrentUserStatusOffline = async (serverUrl: string) => { + const serverDatabase = DatabaseManager.serverDatabases[serverUrl]; + if (!serverDatabase) { + return {error: `No database present for ${serverUrl}`}; + } + + const {database, operator} = serverDatabase; + + const user = await queryCurrentUser(database); + if (!user) { + return {error: `No current user for ${serverUrl}`}; + } + + user.prepareStatus(General.OFFLINE); + + try { + await operator.batchRecords([user]); + } catch { + // eslint-disable-next-line no-console + console.log('FAILED TO BATCH CHANGES FOR SET CURRENT USER STATUS OFFLINE'); + } + + return null; +}; + export const updateLocalCustomStatus = async (serverUrl: string, user: UserModel, customStatus?: UserCustomStatus) => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { @@ -38,7 +64,13 @@ export const updateLocalCustomStatus = async (serverUrl: string, user: UserModel } } - await operator.batchRecords(models); + try { + await operator.batchRecords(models); + } catch { + // eslint-disable-next-line no-console + console.log('FAILED TO BATCH CHANGES FOR UPDATING CUSTOM STATUS'); + } + return {}; }; @@ -84,7 +116,12 @@ export const updateUserPresence = async (serverUrl: string, userStatus: UserStat user.prepareUpdate((record) => { record.status = userStatus.status; }); - await operator.batchRecords([user]); + try { + await operator.batchRecords([user]); + } catch { + // eslint-disable-next-line no-console + console.log('FAILED TO BATCH CHANGES FOR UPDATE USER PRESENCE'); + } } return {}; diff --git a/app/actions/remote/session.ts b/app/actions/remote/session.ts index a2d4583cf2..03510db862 100644 --- a/app/actions/remote/session.ts +++ b/app/actions/remote/session.ts @@ -6,7 +6,9 @@ import {DeviceEventEmitter} from 'react-native'; import {autoUpdateTimezone, getDeviceTimezone, isTimezoneEnabled} from '@actions/local/timezone'; import {General, Database} from '@constants'; import DatabaseManager from '@database/manager'; +import {getServerCredentials} from '@init/credentials'; import NetworkManager from '@init/network_manager'; +import WebsocketManager from '@init/websocket_manager'; import {queryDeviceToken} from '@queries/app/global'; import {queryCurrentUserId, queryCommonSystemValues} from '@queries/servers/system'; import {getCSRFFromCookie} from '@utils/security'; @@ -43,6 +45,11 @@ export const completeLogin = async (serverUrl: string, user: UserProfile) => { fetchDataRetentionPolicy(serverUrl); } + // Start websocket + const credentials = await getServerCredentials(serverUrl); + if (credentials?.token) { + WebsocketManager.createClient(serverUrl, credentials.token); + } return null; }; diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index f10cce512c..dcdfa3e5e3 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -3,11 +3,12 @@ import {Model} from '@nozbe/watermelondb'; +import {localRemoveUserFromTeam} from '@actions/local/team'; import DatabaseManager from '@database/manager'; import NetworkManager from '@init/network_manager'; import {prepareMyChannelsForTeam, queryDefaultChannelForTeam} from '@queries/servers/channel'; import {queryWebSocketLastDisconnected} from '@queries/servers/system'; -import {prepareMyTeams, queryMyTeamById} from '@queries/servers/team'; +import {prepareMyTeams, syncTeamTable} from '@queries/servers/team'; import {isTablet} from '@utils/helpers'; import {fetchMyChannelsForTeam} from './channel'; @@ -16,8 +17,6 @@ import {fetchRolesIfNeeded} from './role'; import {forceLogoutIfNecessary} from './session'; import type ClientError from '@client/rest/error'; -import type TeamModel from '@typings/database/models/servers/team'; -import type TeamMembershipModel from '@typings/database/models/servers/team_membership'; export type MyTeamsRequest = { teams?: Team[]; @@ -128,7 +127,7 @@ export const fetchAllTeams = async (serverUrl: string, fetchOnly = false): Promi if (!fetchOnly) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (operator) { - await operator.handleTeam({prepareRecordsOnly: false, teams}); + syncTeamTable(operator, teams); } } @@ -198,26 +197,8 @@ export const removeUserFromTeam = async (serverUrl: string, teamId: string, user try { await client.removeFromTeam(teamId, userId); - if (!fetchOnly && DatabaseManager.serverDatabases[serverUrl]) { - const {operator, database} = DatabaseManager.serverDatabases[serverUrl]; - const myTeam = await queryMyTeamById(database, teamId); - const models: Model[] = []; - if (myTeam) { - const team = await myTeam.team.fetch() as TeamModel; - const members: TeamMembershipModel[] = await team.members.fetch(); - const member = members.find((m) => m.userId === userId); - - myTeam.prepareDestroyPermanently(); - models.push(myTeam); - if (member) { - member.prepareDestroyPermanently(); - models.push(member); - } - - if (models.length) { - await operator.batchRecords(models); - } - } + if (!fetchOnly) { + localRemoveUserFromTeam(serverUrl, teamId); } return {error: undefined}; diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index f597be12a1..f0cd9af757 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -1,24 +1,21 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Q} from '@nozbe/watermelondb'; +import {Model, Q} from '@nozbe/watermelondb'; import {updateRecentCustomStatuses, updateUserPresence} from '@actions/local/user'; import {fetchRolesIfNeeded} from '@actions/remote/role'; import {Database, General} from '@constants'; import DatabaseManager from '@database/manager'; import {debounce} from '@helpers/api/general'; -import analytics from '@init/analytics'; import NetworkManager from '@init/network_manager'; -import {queryCurrentUserId} from '@queries/servers/system'; -import {prepareUsers, queryCurrentUser, queryUsersById, queryUsersByUsername} from '@queries/servers/user'; +import {queryCurrentUserId, queryWebSocketLastDisconnected} from '@queries/servers/system'; +import {prepareUsers, queryAllUsers, queryCurrentUser, queryUsersById, queryUsersByUsername} from '@queries/servers/user'; import {forceLogoutIfNecessary} from './session'; import type {Client} from '@client/rest'; import type ClientError from '@client/rest/error'; -import type {LoadMeArgs} from '@typings/database/database'; -import type RoleModel from '@typings/database/models/servers/role'; import type UserModel from '@typings/database/models/servers/user'; export type MyUserRequest = { @@ -123,140 +120,6 @@ export const fetchProfilesPerChannels = async (serverUrl: string, channelIds: st } }; -export const loadMe = async (serverUrl: string, {deviceToken, user}: LoadMeArgs) => { - let currentUser = user; - - const database = DatabaseManager.serverDatabases[serverUrl]?.database; - if (!database) { - return {error: `${serverUrl} database not found`}; - } - - let client; - try { - client = NetworkManager.getClient(serverUrl); - } catch (error) { - return {error}; - } - - try { - if (deviceToken) { - await client.attachDevice(deviceToken); - } - - if (!currentUser) { - currentUser = await client.getMe(); - } - } catch (e) { - await forceLogoutIfNecessary(serverUrl, e as ClientError); - return { - error: e, - currentUser: undefined, - }; - } - - try { - const analyticsClient = analytics.create(serverUrl); - analyticsClient.setUserId(currentUser.id); - analyticsClient.setUserRoles(currentUser.roles); - - //todo: Ask for a unified endpoint that will serve all those values in one go.( while ensuring backward-compatibility through fallbacks to previous code-path) - const teamsRequest = client.getMyTeams(); - - // Goes into myTeam table - const teamMembersRequest = client.getMyTeamMembers(); - - const preferencesRequest = client.getMyPreferences(); - const configRequest = client.getClientConfigOld(); - const licenseRequest = client.getClientLicenseOld(); - - const [ - teams, - teamMemberships, - preferences, - config, - license, - ] = await Promise.all([ - teamsRequest, - teamMembersRequest, - preferencesRequest, - configRequest, - licenseRequest, - ]); - - const operator = DatabaseManager.serverDatabases[serverUrl].operator; - const teamRecords = operator.handleTeam({prepareRecordsOnly: true, teams}); - const teamMembershipRecords = operator.handleTeamMemberships({prepareRecordsOnly: true, teamMemberships}); - - const myTeams = teamMemberships.map((tm) => { - return {id: tm.team_id, roles: tm.roles ?? ''}; - }); - - const myTeamRecords = operator.handleMyTeam({ - prepareRecordsOnly: true, - myTeams, - }); - - const systemRecords = operator.handleSystem({ - systems: [ - { - id: Database.SYSTEM_IDENTIFIERS.CONFIG, - value: JSON.stringify(config), - }, - { - id: Database.SYSTEM_IDENTIFIERS.LICENSE, - value: JSON.stringify(license), - }, - { - id: Database.SYSTEM_IDENTIFIERS.CURRENT_USER_ID, - value: currentUser.id, - }, - ], - prepareRecordsOnly: true, - }); - - const userRecords = operator.handleUsers({ - users: [currentUser], - prepareRecordsOnly: true, - }); - - const preferenceRecords = operator.handlePreferences({ - prepareRecordsOnly: true, - preferences, - }); - - let roles: string[] = []; - for (const role of currentUser.roles.split(' ')) { - roles = roles.concat(role); - } - - for (const teamMember of teamMemberships) { - roles = roles.concat(teamMember.roles.split(' ')); - } - - const rolesToLoad = new Set(roles); - - let rolesRecords: RoleModel[] = []; - if (rolesToLoad.size > 0) { - const rolesByName = await client.getRolesByNames(Array.from(rolesToLoad)); - - if (rolesByName?.length) { - rolesRecords = await operator.handleRole({prepareRecordsOnly: true, roles: rolesByName}); - } - } - - const models = await Promise.all([teamRecords, teamMembershipRecords, myTeamRecords, systemRecords, preferenceRecords, rolesRecords, userRecords]); - - const flattenedModels = models.flat(); - if (flattenedModels?.length > 0) { - await operator.batchRecords(flattenedModels); - } - } catch (e) { - return {error: e, currentUser: undefined}; - } - - return {currentUser, error: undefined}; -}; - export const updateMe = async (serverUrl: string, user: UserModel) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -446,6 +309,70 @@ export const fetchMissingProfilesByUsernames = async (serverUrl: string, usernam } }; +export const updateAllUsersSinceLastDisconnect = async (serverUrl: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + const lastDisconnectedAt = await queryWebSocketLastDisconnected(database.database); + + if (!lastDisconnectedAt) { + return {users: []}; + } + const currentUserId = await queryCurrentUserId(database.database); + const users = await queryAllUsers(database.database); + const userIds = users.map((u) => u.id).filter((id) => id !== currentUserId); + let userUpdates: UserProfile[] = []; + try { + userUpdates = await NetworkManager.getClient(serverUrl).getProfilesByIds(userIds, {since: lastDisconnectedAt}); + } catch { + // Do nothing + } + + if (userUpdates.length) { + database.operator.handleUsers({users: userUpdates, prepareRecordsOnly: false}); + } + + return {users: userUpdates}; +}; + +export const updateUsersNoLongerVisible = async (serverUrl: string): Promise<{error?: unknown}> => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + const serverDatabase = DatabaseManager.serverDatabases[serverUrl]; + if (!serverDatabase) { + return {error: `${serverUrl} database not found`}; + } + + try { + const knownUsers = new Set(await client.getKnownUsers()); + const currentUserId = await queryCurrentUserId(serverDatabase.database); + knownUsers.add(currentUserId); + + const models: Model[] = []; + const allUsers = await queryAllUsers(serverDatabase.database); + for (const user of allUsers) { + if (!knownUsers.has(user.id)) { + user.prepareDestroyPermanently(); + models.push(user); + } + } + if (models.length) { + serverDatabase.operator.batchRecords(models); + } + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientError); + return {error}; + } + + return {}; +}; export const setStatus = async (serverUrl: string, status: UserStatus) => { let client: Client; try { diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts new file mode 100644 index 0000000000..8c73ca5238 --- /dev/null +++ b/app/actions/websocket/channel.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DeviceEventEmitter} from 'react-native'; + +import {localRemoveUserFromChannel, localSetChannelDeleteAt, switchToChannel} from '@actions/local/channel'; +import {updateUsersNoLongerVisible} from '@actions/remote/user'; +import Events from '@constants/events'; +import DatabaseManager from '@database/manager'; +import {queryActiveServer} from '@queries/app/servers'; +import {deleteChannelMembership, queryCurrentChannel} from '@queries/servers/channel'; +import {queryConfig, setCurrentChannelId} from '@queries/servers/system'; +import {queryLastChannelFromTeam} from '@queries/servers/team'; +import {queryCurrentUser} from '@queries/servers/user'; +import {dismissAllModals, popToRoot} from '@screens/navigation'; +import {isTablet} from '@utils/helpers'; +import {isGuest} from '@utils/user'; + +export async function handleUserRemovedEvent(serverUrl: string, msg: any) { + const database = DatabaseManager.serverDatabases[serverUrl]; + if (!database) { + return; + } + + const channel = await queryCurrentChannel(database.database); + const user = await queryCurrentUser(database.database); + if (!user) { + return; + } + + if (user.id === msg.data.user_id) { + localRemoveUserFromChannel(serverUrl, msg.data.channel_id); + + if (isGuest(user.roles)) { + updateUsersNoLongerVisible(serverUrl); + } + + if (channel && channel.id === msg.data.channel_id) { + const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database); + + if (currentServer?.url === serverUrl) { + DeviceEventEmitter.emit(Events.LEAVE_CHANNEL); + await dismissAllModals(); + await popToRoot(); + + if (await isTablet()) { + const channelToJumpTo = await queryLastChannelFromTeam(database.database, channel?.teamId); + if (channelToJumpTo) { + switchToChannel(serverUrl, channelToJumpTo); + } // TODO else jump to "join a channel" screen + } else { + setCurrentChannelId(database.operator, ''); + } + } + } + } else { + deleteChannelMembership(database.operator, msg.data.user_id, msg.data.channel_id); + } +} + +export async function handleChannelDeletedEvent(serverUrl: string, msg: any) { + const database = DatabaseManager.serverDatabases[serverUrl]; + if (!database) { + return; + } + + const currentChannel = await queryCurrentChannel(database.database); + const user = await queryCurrentUser(database.database); + if (!user) { + return; + } + + const config = await queryConfig(database.database); + + await localSetChannelDeleteAt(serverUrl, msg.data.channel_id, msg.data.delete_at); + + if (isGuest(user.roles)) { + updateUsersNoLongerVisible(serverUrl); + } + + if (config?.ExperimentalViewArchivedChannels !== 'true') { + localRemoveUserFromChannel(serverUrl, msg.data.channel_id); + + if (currentChannel && currentChannel.id === msg.data.channel_id) { + const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database); + + if (currentServer?.url === serverUrl) { + DeviceEventEmitter.emit(Events.CHANNEL_DELETED); + await dismissAllModals(); + await popToRoot(); + + if (await isTablet()) { + const channelToJumpTo = await queryLastChannelFromTeam(database.database, currentChannel?.teamId); + if (channelToJumpTo) { + switchToChannel(serverUrl, channelToJumpTo); + } // TODO else jump to "join a channel" screen + } else { + setCurrentChannelId(database.operator, ''); + } + } + } + } +} diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts new file mode 100644 index 0000000000..3dacb1e6af --- /dev/null +++ b/app/actions/websocket/index.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DeviceEventEmitter} from 'react-native'; + +import {fetchMyChannelsForTeam} from '@actions/remote/channel'; +import {fetchPostsSince} from '@actions/remote/post'; +import {fetchMyPreferences} from '@actions/remote/preference'; +import {fetchRoles} from '@actions/remote/role'; +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 {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {queryCommonSystemValues, queryConfig, queryWebSocketLastDisconnected} from '@queries/servers/system'; + +import {handleChannelDeletedEvent, handleUserRemovedEvent} from './channel'; +import {handleLeaveTeamEvent} from './teams'; + +export async function handleFirstConnect(serverUrl: string) { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return; + } + const config = await queryConfig(database); + const lastDisconnect = await queryWebSocketLastDisconnected(database); + if (lastDisconnect && config.EnableReliableWebSockets !== 'true') { + doReconnect(serverUrl); + return; + } + + doFirstConnect(serverUrl); +} + +export function handleReconnect(serverUrl: string) { + doReconnect(serverUrl); +} + +export async function handleClose(serverUrl: string, lastDisconnect: number) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return; + } + await operator.handleSystem({ + systems: [ + { + id: SYSTEM_IDENTIFIERS.WEBSOCKET, + value: lastDisconnect.toString(10), + }, + ], + prepareRecordsOnly: false, + }); +} + +function doFirstConnect(serverUrl: string) { + updateAllUsersSinceLastDisconnect(serverUrl); +} + +async function doReconnect(serverUrl: string) { + const database = DatabaseManager.serverDatabases[serverUrl]; + if (!database) { + return; + } + + const {currentUserId, currentTeamId, currentChannelId} = await queryCommonSystemValues(database.database); + const lastDisconnectedAt = await queryWebSocketLastDisconnected(database.database); + + // TODO consider fetch only and batch all the results. + fetchMe(serverUrl); + fetchMyPreferences(serverUrl); + const {config} = await fetchConfigAndLicense(serverUrl); + const {memberships: teamMemberships, error: teamMembershipsError} = await fetchMyTeams(serverUrl); + + 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); + if (error) { + DeviceEventEmitter.emit(Events.TEAM_LOAD_ERROR, serverUrl, error); + return; + } + channelMemberships = memberships; + + if (currentChannelId) { + const stillMemberOfCurrentChannel = memberships?.find((cm) => cm.channel_id === currentChannelId); + const channelStillExist = channels?.find((c) => c.id === currentChannelId); + const viewArchivedChannels = config?.ExperimentalViewArchivedChannels === 'true'; + + if (!stillMemberOfCurrentChannel) { + handleUserRemovedEvent(serverUrl, {data: {user_id: currentUserId, channel_id: currentChannelId}}); + } else if (!channelStillExist || + (!viewArchivedChannels && channelStillExist.delete_at !== 0) + ) { + handleChannelDeletedEvent(serverUrl, {data: {user_id: currentUserId, channel_id: currentChannelId}}); + } else { + // TODO Differentiate between post and thread, to fetch the thread posts + fetchPostsSince(serverUrl, currentChannelId, lastDisconnectedAt); + } + } + + // TODO Consider global thread screen to update global threads + } else if (!teamMembershipsError) { + handleLeaveTeamEvent(serverUrl, {data: {user_id: currentUserId, team_id: currentTeamId}}); + } + + fetchRoles(serverUrl, teamMemberships, channelMemberships); + fetchAllTeams(serverUrl); + + // TODO Fetch App bindings? + + updateAllUsersSinceLastDisconnect(serverUrl); +} + +export async function handleEvent(serverUrl: string, msg: any) { + switch (msg.event) { + case WebsocketEvents.POSTED: + case WebsocketEvents.EPHEMERAL_MESSAGE: + break; + + //return dispatch(handleNewPostEvent(msg)); + case WebsocketEvents.POST_EDITED: + break; + + //return dispatch(handlePostEdited(msg)); + case WebsocketEvents.POST_DELETED: + break; + + // return dispatch(handlePostDeleted(msg)); + case WebsocketEvents.POST_UNREAD: + break; + + // return dispatch(handlePostUnread(msg)); + case WebsocketEvents.LEAVE_TEAM: + handleLeaveTeamEvent(serverUrl, msg); + break; + case WebsocketEvents.UPDATE_TEAM: + break; + + // return dispatch(handleUpdateTeamEvent(msg)); + case WebsocketEvents.ADDED_TO_TEAM: + break; + + // return dispatch(handleTeamAddedEvent(msg)); + case WebsocketEvents.USER_ADDED: + break; + + // return dispatch(handleUserAddedEvent(msg)); + case WebsocketEvents.USER_REMOVED: + handleUserRemovedEvent(serverUrl, msg); + break; + case WebsocketEvents.USER_UPDATED: + break; + + // return dispatch(handleUserUpdatedEvent(msg)); + case WebsocketEvents.ROLE_ADDED: + break; + + // return dispatch(handleRoleAddedEvent(msg)); + case WebsocketEvents.ROLE_REMOVED: + break; + + // return dispatch(handleRoleRemovedEvent(msg)); + case WebsocketEvents.ROLE_UPDATED: + break; + + // return dispatch(handleRoleUpdatedEvent(msg)); + case WebsocketEvents.USER_ROLE_UPDATED: + break; + + // return dispatch(handleUserRoleUpdated(msg)); + case WebsocketEvents.MEMBERROLE_UPDATED: + break; + + // return dispatch(handleUpdateMemberRoleEvent(msg)); + case WebsocketEvents.CHANNEL_CREATED: + break; + + // return dispatch(handleChannelCreatedEvent(msg)); + case WebsocketEvents.CHANNEL_DELETED: + handleChannelDeletedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_UNARCHIVED: + break; + + // return dispatch(handleChannelUnarchiveEvent(msg)); + case WebsocketEvents.CHANNEL_UPDATED: + break; + + // return dispatch(handleChannelUpdatedEvent(msg)); + case WebsocketEvents.CHANNEL_CONVERTED: + break; + + // return dispatch(handleChannelConvertedEvent(msg)); + case WebsocketEvents.CHANNEL_VIEWED: + break; + + // return dispatch(handleChannelViewedEvent(msg)); + case WebsocketEvents.CHANNEL_MEMBER_UPDATED: + break; + + // return dispatch(handleChannelMemberUpdatedEvent(msg)); + case WebsocketEvents.CHANNEL_SCHEME_UPDATED: + break; + + // return dispatch(handleChannelSchemeUpdatedEvent(msg)); + case WebsocketEvents.DIRECT_ADDED: + break; + + // return dispatch(handleDirectAddedEvent(msg)); + case WebsocketEvents.PREFERENCE_CHANGED: + break; + + // return dispatch(handlePreferenceChangedEvent(msg)); + case WebsocketEvents.PREFERENCES_CHANGED: + break; + + // return dispatch(handlePreferencesChangedEvent(msg)); + case WebsocketEvents.PREFERENCES_DELETED: + break; + + // return dispatch(handlePreferencesDeletedEvent(msg)); + case WebsocketEvents.STATUS_CHANGED: + break; + + // return dispatch(handleStatusChangedEvent(msg)); + case WebsocketEvents.TYPING: + break; + + // return dispatch(handleUserTypingEvent(msg)); + case WebsocketEvents.HELLO: + break; + + // handleHelloEvent(msg); + // break; + case WebsocketEvents.REACTION_ADDED: + break; + + // return dispatch(handleReactionAddedEvent(msg)); + case WebsocketEvents.REACTION_REMOVED: + break; + + // return dispatch(handleReactionRemovedEvent(msg)); + case WebsocketEvents.EMOJI_ADDED: + break; + + // return dispatch(handleAddEmoji(msg)); + case WebsocketEvents.LICENSE_CHANGED: + break; + + // return dispatch(handleLicenseChangedEvent(msg)); + case WebsocketEvents.CONFIG_CHANGED: + break; + + // return dispatch(handleConfigChangedEvent(msg)); + case WebsocketEvents.OPEN_DIALOG: + break; + + // return dispatch(handleOpenDialogEvent(msg)); + case WebsocketEvents.RECEIVED_GROUP: + break; + + // return dispatch(handleGroupUpdatedEvent(msg)); + case WebsocketEvents.THREAD_UPDATED: + break; + + // return dispatch(handleThreadUpdated(msg)); + case WebsocketEvents.THREAD_READ_CHANGED: + break; + + // return dispatch(handleThreadReadChanged(msg)); + case WebsocketEvents.THREAD_FOLLOW_CHANGED: + break; + + // return dispatch(handleThreadFollowChanged(msg)); + case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: + break; + + // return dispatch(handleRefreshAppsBindings()); + } +} diff --git a/app/actions/websocket/teams.ts b/app/actions/websocket/teams.ts new file mode 100644 index 0000000000..e64ed652bc --- /dev/null +++ b/app/actions/websocket/teams.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DeviceEventEmitter} from 'react-native'; + +import {handleTeamChange, localRemoveUserFromTeam} from '@actions/local/team'; +import {updateUsersNoLongerVisible} from '@actions/remote/user'; +import Events from '@constants/events'; +import DatabaseManager from '@database/manager'; +import {queryActiveServer} from '@queries/app/servers'; +import {queryCurrentTeamId} from '@queries/servers/system'; +import {queryLastTeam} from '@queries/servers/team'; +import {queryCurrentUser} from '@queries/servers/user'; +import {dismissAllModals, popToRoot} from '@screens/navigation'; +import {isGuest} from '@utils/user'; + +export async function handleLeaveTeamEvent(serverUrl: string, msg: any) { + const database = DatabaseManager.serverDatabases[serverUrl]; + if (!database) { + return; + } + + const currentTeamId = await queryCurrentTeamId(database.database); + const user = await queryCurrentUser(database.database); + if (!user) { + return; + } + + if (user.id === msg.data.user_id) { + localRemoveUserFromTeam(serverUrl, msg.data.team_id); + + if (isGuest(user.roles)) { + updateUsersNoLongerVisible(serverUrl); + } + + if (currentTeamId === msg.data.team_id) { + const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database); + + if (currentServer?.url === serverUrl) { + DeviceEventEmitter.emit(Events.LEAVE_TEAM); + await dismissAllModals(); + await popToRoot(); + } + + const teamToJumpTo = await queryLastTeam(database.database); + if (teamToJumpTo) { + handleTeamChange(serverUrl, teamToJumpTo); + } // TODO else jump to "join a team" screen + } + } +} diff --git a/app/client/rest/teams.ts b/app/client/rest/teams.ts index 53a6662988..9c53c0350b 100644 --- a/app/client/rest/teams.ts +++ b/app/client/rest/teams.ts @@ -10,7 +10,7 @@ export interface ClientTeamsMix { deleteTeam: (teamId: string) => Promise; updateTeam: (team: Team) => Promise; patchTeam: (team: Partial & {id: string}) => Promise; - getTeams: (page?: number, perPage?: number, includeTotalCount?: boolean) => Promise; + getTeams: (page?: number, perPage?: number, includeTotalCount?: boolean) => Promise; getTeam: (teamId: string) => Promise; getTeamByName: (teamName: string) => Promise; getMyTeams: () => Promise; diff --git a/app/client/websocket/index.ts b/app/client/websocket/index.ts index b8ddb3ce1d..39c8f0611f 100644 --- a/app/client/websocket/index.ts +++ b/app/client/websocket/index.ts @@ -1,220 +1,319 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Platform} from 'react-native'; +import {getOrCreateWebSocketClient, WebSocketClientInterface} from '@mattermost/react-native-network-client'; + +import {WebsocketEvents} from '@constants'; +import DatabaseManager from '@database/manager'; +import {queryCommonSystemValues} from '@queries/servers/system'; const MAX_WEBSOCKET_FAILS = 7; const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins -class WebSocketClient { - conn?: WebSocket; - connectionUrl: string; - token: string|null; - sequence: number; - connectFailCount: number; - eventCallback?: Function; - firstConnectCallback?: Function; - reconnectCallback?: Function; - errorCallback?: Function; - closeCallback?: Function; - connectingCallback?: Function; - stop: boolean; - connectionTimeout: any; +export default class WebSocketClient { + private conn?: WebSocketClientInterface; + private connectionTimeout: any; + private connectionId: string; + private token: string; - constructor() { - this.connectionUrl = ''; - this.token = null; - this.sequence = 1; + // responseSequence is the number to track a response sent + // via the websocket. A response will always have the same sequence number + // as the request. + private responseSequence: number; + + // serverSequence is the incrementing sequence number from the + // server-sent event stream. + private serverSequence: number; + private connectFailCount: number; + private eventCallback?: Function; + private firstConnectCallback?: () => void; + private missedEventsCallback?: () => void; + private reconnectCallback?: () => void; + private errorCallback?: Function; + private closeCallback?: (connectFailCount: number, lastDisconnect: number) => void; + private connectingCallback?: () => void; + private stop: boolean; + private lastConnect: number; + private lastDisconnect: number; + + private serverUrl: string; + + constructor(serverUrl: string, token: string, lastDisconnect = 0) { + this.connectionId = ''; + this.token = token; + this.responseSequence = 1; + this.serverSequence = 0; this.connectFailCount = 0; this.stop = false; + this.serverUrl = serverUrl; + this.lastConnect = 0; + this.lastDisconnect = lastDisconnect; } - initialize(token: string|null, opts = {}) { + public async initialize(opts = {}) { const defaults = { forceConnection: true, - connectionUrl: this.connectionUrl, }; - const {connectionUrl, forceConnection, ...additionalOptions} = Object.assign({}, defaults, opts); + const {forceConnection} = Object.assign({}, defaults, opts); if (forceConnection) { this.stop = false; } - return new Promise((resolve, reject) => { - if (this.conn) { - resolve(null); - return; + if (this.conn) { + return; + } + + const database = DatabaseManager.serverDatabases[this.serverUrl]?.database; + if (!database) { + return; + } + + const system = await queryCommonSystemValues(database); + const connectionUrl = (system.config.WebsocketURL || this.serverUrl) + '/api/v4/websocket'; + + if (this.connectingCallback) { + this.connectingCallback(); + } + + const regex = /^(?:https?|wss?):(?:\/\/)?[^/]*/; + const captured = (regex).exec(connectionUrl); + + let origin; + if (captured) { + origin = captured[0]; + } else { + // If we're unable to set the origin header, the websocket won't connect, but the URL is likely malformed anyway + const errorMessage = 'websocket failed to parse origin from ' + connectionUrl; + console.warn(errorMessage); // eslint-disable-line no-console + return; + } + + let url = connectionUrl; + + const reliableWebSockets = system.config.EnableReliableWebSockets === 'true'; + if (reliableWebSockets) { + // Add connection id, and last_sequence_number to the query param. + // We cannot also send it as part of the auth_challenge, because the session cookie is already sent with the request. + url = `${connectionUrl}?connection_id=${this.connectionId}&sequence_number=${this.serverSequence}`; + } + + // Manually changing protocol since getOrCreateWebsocketClient does not accept http/s + if (url.startsWith('https:')) { + url = 'wss:' + url.substr('https:'.length); + } + + if (url.startsWith('http:')) { + url = 'ws:' + url.substr('http:'.length); + } + + if (this.connectFailCount === 0) { + console.log('websocket connecting to ' + url); //eslint-disable-line no-console + } + + try { + const {client} = await getOrCreateWebSocketClient(url, {headers: {origin}}); + this.conn = client; + } catch (error) { + return; + } + + this.conn!.onOpen(() => { + this.lastConnect = Date.now(); + + // No need to reset sequence number here. + if (!reliableWebSockets) { + this.serverSequence = 0; } - if (connectionUrl == null) { - console.log('websocket must have connection url'); //eslint-disable-line no-console - reject(new Error('websocket must have connection url')); - return; + if (this.token) { + // we check for the platform as a workaround until we fix on the server that further authentications + // are ignored + this.sendMessage('authentication_challenge', {token: this.token}); } + if (this.connectFailCount > 0) { + console.log('websocket re-established connection'); //eslint-disable-line no-console + if (!reliableWebSockets && this.reconnectCallback) { + this.reconnectCallback(); + } else if (reliableWebSockets && this.serverSequence && this.missedEventsCallback) { + this.missedEventsCallback(); + } + } else if (this.firstConnectCallback) { + this.firstConnectCallback(); + } + + this.connectFailCount = 0; + }); + + this.conn!.onClose(() => { + const now = Date.now(); + if (this.lastDisconnect < this.lastConnect) { + this.lastDisconnect = now; + } + + this.conn = undefined; + this.responseSequence = 1; + if (this.connectFailCount === 0) { - console.log('websocket connecting to ' + connectionUrl); //eslint-disable-line no-console + console.log('websocket closed'); //eslint-disable-line no-console } - if (this.connectingCallback) { - this.connectingCallback(); + this.connectFailCount++; + + if (this.closeCallback) { + this.closeCallback(this.connectFailCount, this.lastDisconnect); } - const regex = /^(?:https?|wss?):(?:\/\/)?[^/]*/; - const captured = (regex).exec(connectionUrl); - - let origin; - if (captured) { - origin = captured[0]; - - if (Platform.OS === 'android') { - // this is done cause for android having the port 80 or 443 will fail the connection - // the websocket will append them - const split = origin.split(':'); - const port = split[2]; - if (port === '80' || port === '443') { - origin = `${split[0]}:${split[1]}`; - } - } - } else { - // If we're unable to set the origin header, the websocket won't connect, but the URL is likely malformed anyway - const errorMessage = 'websocket failed to parse origin from ' + connectionUrl; - console.warn(errorMessage); // eslint-disable-line no-console - reject(new Error(errorMessage)); + if (this.stop) { return; } - this.conn = new WebSocket(connectionUrl, [], {headers: {origin}, ...(additionalOptions || {})}); - this.connectionUrl = connectionUrl; - this.token = token; + let retryTime = MIN_WEBSOCKET_RETRY_TIME; - this.conn!.onopen = () => { - if (token) { - // we check for the platform as a workaround until we fix on the server that further authentications - // are ignored - this.sendMessage('authentication_challenge', {token}); + // If we've failed a bunch of connections then start backing off + if (this.connectFailCount > MAX_WEBSOCKET_FAILS) { + retryTime = MIN_WEBSOCKET_RETRY_TIME * this.connectFailCount; + if (retryTime > MAX_WEBSOCKET_RETRY_TIME) { + retryTime = MAX_WEBSOCKET_RETRY_TIME; } + } - if (this.connectFailCount > 0) { - console.log('websocket re-established connection'); //eslint-disable-line no-console - if (this.reconnectCallback) { - this.reconnectCallback(); + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + } + + this.connectionTimeout = setTimeout( + () => { + if (this.stop) { + clearTimeout(this.connectionTimeout); + return; } - } else if (this.firstConnectCallback) { - this.firstConnectCallback(); - } - - this.connectFailCount = 0; - resolve(null); - }; - - this.conn!.onclose = () => { - this.conn = undefined; - this.sequence = 1; - - if (this.connectFailCount === 0) { - console.log('websocket closed'); //eslint-disable-line no-console - } - - this.connectFailCount++; - - if (this.closeCallback) { - this.closeCallback(this.connectFailCount); - } - - let retryTime = MIN_WEBSOCKET_RETRY_TIME; - - // If we've failed a bunch of connections then start backing off - if (this.connectFailCount > MAX_WEBSOCKET_FAILS) { - retryTime = MIN_WEBSOCKET_RETRY_TIME * this.connectFailCount; - if (retryTime > MAX_WEBSOCKET_RETRY_TIME) { - retryTime = MAX_WEBSOCKET_RETRY_TIME; - } - } - - if (this.connectionTimeout) { - clearTimeout(this.connectionTimeout); - } - - this.connectionTimeout = setTimeout( - () => { - if (this.stop) { - clearTimeout(this.connectionTimeout); - return; - } - this.initialize(token, opts); - }, - retryTime, - ); - }; - - this.conn!.onerror = (evt: any) => { - if (this.connectFailCount <= 1) { - console.log('websocket error'); //eslint-disable-line no-console - console.log(evt); //eslint-disable-line no-console - } - - if (this.errorCallback) { - this.errorCallback(evt); - } - }; - - this.conn!.onmessage = (evt: any) => { - const msg = JSON.parse(evt.data); - if (msg.seq_reply) { - if (msg.error) { - console.warn(msg); //eslint-disable-line no-console - } - } else if (this.eventCallback) { - this.eventCallback(msg); - } - }; + this.initialize(opts); + }, + retryTime, + ); }); + + this.conn!.onError((evt: any) => { + if (this.connectFailCount <= 1) { + console.log('websocket error'); //eslint-disable-line no-console + console.log(evt); //eslint-disable-line no-console + } + + if (this.errorCallback) { + this.errorCallback(evt); + } + }); + + this.conn!.onMessage((evt: any) => { + const msg = evt.message; + + // This indicates a reply to a websocket request. + // We ignore sequence number validation of message responses + // and only focus on the purely server side event stream. + if (msg.seq_reply) { + if (msg.error) { + console.warn(msg); //eslint-disable-line no-console + } + } else if (this.eventCallback) { + if (reliableWebSockets) { + // 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); + + // 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.'); + this.reconnectCallback(); + this.serverSequence = 0; + } + + // If it's a fresh connection, we have to set the connectionId regardless. + // And if it's an existing connection, setting it again is harmless, and keeps the code simple. + this.connectionId = msg.data.connection_id; + } + + // Now we check for sequence number, and if it does not match, + // 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); + + // We are not calling this.close() because we need to auto-restart. + this.connectFailCount = 0; + this.responseSequence = 1; + this.conn?.close(); // Will auto-reconnect after MIN_WEBSOCKET_RETRY_TIME. + return; + } + } 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); + this.reconnectCallback(); + } + + this.serverSequence = msg.seq + 1; + this.eventCallback(msg); + } + }); + + this.conn.open(); } - setConnectingCallback(callback: Function) { + public setConnectingCallback(callback: () => void) { this.connectingCallback = callback; } - setEventCallback(callback: Function) { + public setEventCallback(callback: Function) { this.eventCallback = callback; } - setFirstConnectCallback(callback: Function) { + public setFirstConnectCallback(callback: () => void) { this.firstConnectCallback = callback; } - setReconnectCallback(callback: Function) { + public setMissedEventsCallback(callback: () => void) { + this.missedEventsCallback = callback; + } + + public setReconnectCallback(callback: () => void) { this.reconnectCallback = callback; } - setErrorCallback(callback: Function) { + public setErrorCallback(callback: Function) { this.errorCallback = callback; } - setCloseCallback(callback: Function) { + public setCloseCallback(callback: (connectFailCount: number, lastDisconnect: number) => void) { this.closeCallback = callback; } - close(stop = false) { + public close(stop = false) { this.stop = stop; this.connectFailCount = 0; - this.sequence = 1; + this.responseSequence = 1; + if (this.conn && this.conn.readyState === WebSocket.OPEN) { - this.conn.onclose = () => {}; //eslint-disable-line @typescript-eslint/no-empty-function this.conn.close(); - this.conn = undefined; - console.log('websocket closed'); //eslint-disable-line no-console } } - sendMessage(action: string, data: any) { + public invalidate() { + this.conn?.invalidate(); + this.conn = undefined; + } + + private sendMessage(action: string, data: any) { const msg = { action, - seq: this.sequence++, + seq: this.responseSequence++, data, }; @@ -226,22 +325,14 @@ class WebSocketClient { } } - userTyping(channelId: string, parentId: string) { + public sendUserTypingEvent(channelId: string, parentId: string) { this.sendMessage('user_typing', { channel_id: channelId, parent_id: parentId, }); } - getStatuses() { - this.sendMessage('get_statuses', null); - } - - getStatusesByIds(userIds: string[]) { - this.sendMessage('get_statuses_by_ids', { - user_ids: userIds, - }); + public isConnected(): boolean { + return this.conn?.readyState === WebSocket.OPEN; //|| (!this.stop && this.connectFailCount <= 2); } } - -export default new WebSocketClient(); diff --git a/app/constants/database.ts b/app/constants/database.ts index b7f53dc619..50ef81520c 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -57,6 +57,7 @@ export const SYSTEM_IDENTIFIERS = { INTEGRATION_TRIGGER_ID: 'IntegreationTriggerId', LICENSE: 'license', WEBSOCKET: 'WebSocket', + TEAM_HISTORY: 'teamHistory', RECENT_CUSTOM_STATUS: 'recentCustomStatus', }; diff --git a/app/constants/events.ts b/app/constants/events.ts index 5ef48c9ed9..c9777d3193 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -5,4 +5,8 @@ import keyMirror from '@utils/key_mirror'; export default keyMirror({ ACCOUNT_SELECT_TABLET_VIEW: null, + LEAVE_CHANNEL: null, + LEAVE_TEAM: null, + TEAM_LOAD_ERROR: null, + CHANNEL_DELETED: null, }); diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index bc84f88d9c..d3944294a6 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -43,5 +43,9 @@ const WebsocketEvents = { INCREASE_POST_VISIBILITY_BY_ONE: 'increase_post_visibility_by_one', MEMBERROLE_UPDATED: 'memberrole_updated', RECEIVED_GROUP: 'received_group', + THREAD_UPDATED: 'thread_updated', + THREAD_FOLLOW_CHANGED: 'thread_follow_changed', + THREAD_READ_CHANGED: 'thread_read_changed', + APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings', }; export default WebsocketEvents; diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index 91745d3179..388a66d8cb 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -241,7 +241,7 @@ class DatabaseManager { if (database) { const server = await queryActiveServer(database); if (server?.url) { - return this.serverDatabases[server.url].database; + return this.serverDatabases[server.url]?.database; } } diff --git a/app/init/global_event_handler.ts b/app/init/global_event_handler.ts index 508cd95980..14d4867a18 100644 --- a/app/init/global_event_handler.ts +++ b/app/init/global_event_handler.ts @@ -15,6 +15,7 @@ import {getServerCredentials, removeServerCredentials} from '@init/credentials'; import {getLaunchPropsFromDeepLink, relaunchApp} from '@init/launch'; import NetworkManager from '@init/network_manager'; import PushNotifications from '@init/push_notifications'; +import WebsocketManager from '@init/websocket_manager'; import {queryCurrentUser} from '@queries/servers/user'; import {LaunchType} from '@typings/launch'; import {deleteFileCache} from '@utils/file'; @@ -84,8 +85,8 @@ class GlobalEventHandler { onLogout = async (serverUrl: string) => { await removeServerCredentials(serverUrl); - // TODO WebSocket: invalidate WebSocket client NetworkManager.invalidateClient(serverUrl); + WebsocketManager.invalidateClient(serverUrl); await DatabaseManager.deleteServerDatabase(serverUrl); const analyticsClient = analytics.get(serverUrl); diff --git a/app/init/websocket_manager.ts b/app/init/websocket_manager.ts new file mode 100644 index 0000000000..71550ac4bb --- /dev/null +++ b/app/init/websocket_manager.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NetInfo, {NetInfoState} from '@react-native-community/netinfo'; +import {AppState, AppStateStatus} from 'react-native'; + +import {setCurrentUserStatusOffline} from '@actions/local/user'; +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 {queryAllUsers} from '@app/queries/servers/user'; +import DatabaseManager from '@database/manager'; + +import type {ServerCredential} from '@typings/credentials'; + +class WebsocketManager { + private clients: Record = {}; + private statusUpdatesIntervalIDs: Record = {}; + private previousAppState: AppStateStatus; + private netConnected = false; + + constructor() { + this.previousAppState = AppState.currentState; + } + + public init = async (serverCredentials: ServerCredential[]) => { + this.netConnected = Boolean((await NetInfo.fetch()).isConnected); + await Promise.all( + serverCredentials.map( + async ({serverUrl, token}) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return; + } + const lastDisconnect = await queryWebSocketLastDisconnected(database); + try { + this.createClient(serverUrl, token, lastDisconnect); + } catch (error) { + console.log('WebsocketManager init error', error); //eslint-disable-line no-console + } + }, + ), + ); + + AppState.addEventListener('change', this.onAppStateChange); + NetInfo.addEventListener(this.onNetStateChange); + } + + public invalidateClient = (serverUrl: string) => { + this.clients[serverUrl]?.close(); + this.clients[serverUrl]?.invalidate(); + delete this.clients[serverUrl]; + } + + public createClient = (serverUrl: string, bearerToken: string, storedLastDisconnect = 0) => { + const client = new WebSocketClient(serverUrl, bearerToken, storedLastDisconnect); + + client.setFirstConnectCallback(() => this.onFirstConnect(serverUrl)); + client.setEventCallback((evt: any) => handleEvent(serverUrl, evt)); + + //client.setMissedEventsCallback(() => {}) Nothing to do on missedEvents callback + client.setReconnectCallback(() => this.onReconnect(serverUrl)); + client.setCloseCallback((connectFailCount: number, lastDisconnect: number) => this.onWebsocketClose(serverUrl, connectFailCount, lastDisconnect)); + + if (this.netConnected) { + client.initialize(); + } + this.clients[serverUrl] = client; + + return this.clients[serverUrl]; + } + + public closeAll = () => { + for (const client of Object.values(this.clients)) { + client.close(true); + } + } + + public openAll = () => { + for (const client of Object.values(this.clients)) { + if (!client.isConnected()) { + client.initialize(); + } + } + } + + public isConnected = (serverUrl: string): boolean => { + return this.clients[serverUrl]?.isConnected(); + } + + private onFirstConnect = (serverUrl: string) => { + this.startPeriodicStatusUpdates(serverUrl); + handleFirstConnect(serverUrl); + } + + private onReconnect = (serverUrl: string) => { + this.startPeriodicStatusUpdates(serverUrl); + handleReconnect(serverUrl); + } + + private onWebsocketClose = async (serverUrl: string, connectFailCount: number, lastDisconnect: number) => { + if (connectFailCount <= 1) { // First fail + await setCurrentUserStatusOffline(serverUrl); + await handleClose(serverUrl, lastDisconnect); + + this.stopPeriodicStatusUpdates(serverUrl); + } + } + + private startPeriodicStatusUpdates(serverUrl: string) { + let currentId = this.statusUpdatesIntervalIDs[serverUrl]; + if (currentId != null) { + clearInterval(currentId); + } + + const getStatusForUsers = async () => { + const database = DatabaseManager.serverDatabases[serverUrl]; + if (!database) { + return; + } + + const users = await queryAllUsers(database.database); + + const userIds = users.map((u) => u.id); + if (!userIds.length) { + return; + } + + fetchStatusByIds(serverUrl, userIds); + }; + + currentId = setInterval(getStatusForUsers, General.STATUS_INTERVAL); + this.statusUpdatesIntervalIDs[serverUrl] = currentId; + } + + private stopPeriodicStatusUpdates(serverUrl: string) { + const currentId = this.statusUpdatesIntervalIDs[serverUrl]; + if (currentId != null) { + clearInterval(currentId); + } + + delete this.statusUpdatesIntervalIDs[serverUrl]; + } + + private onAppStateChange = async (appState: AppStateStatus) => { + if (appState === this.previousAppState) { + return; + } + + if (appState !== 'active') { + this.closeAll(); + this.previousAppState = appState; + return; + } + + if (appState === 'active' && this.netConnected) { // Reopen the websockets only if there is connection + this.openAll(); + this.previousAppState = appState; + return; + } + + this.previousAppState = appState; + } + + private onNetStateChange = async (netState: NetInfoState) => { + const newState = Boolean(netState.isConnected); + if (this.netConnected === newState) { + return; + } + + this.netConnected = newState; + + if (this.netConnected && this.previousAppState === 'active') { // Reopen the websockets only if the app is active + this.openAll(); + return; + } + + this.closeAll(); + } +} + +export default new WebsocketManager(); diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 540558211f..a369a1c451 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -9,6 +9,7 @@ import {hasPermission} from '@utils/role'; import {prepareDeletePost} from './post'; import {queryRoles} from './role'; +import {queryCurrentChannelId} from './system'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type ChannelModel from '@typings/database/models/servers/channel'; @@ -16,7 +17,7 @@ import type ChannelInfoModel from '@typings/database/models/servers/channel_info import type MyChannelModel from '@typings/database/models/servers/my_channel'; import type PostModel from '@typings/database/models/servers/post'; -const {SERVER: {CHANNEL, MY_CHANNEL}} = MM_TABLES; +const {SERVER: {CHANNEL, MY_CHANNEL, CHANNEL_MEMBERSHIP}} = MM_TABLES; export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, teamId: string, channels: Channel[], channelMembers: ChannelMembership[]) => { const allChannelsForTeam = await queryAllChannelsForTeam(operator.database, teamId); @@ -159,3 +160,29 @@ export const queryDefaultChannelForTeam = async (database: Database, teamId: str return channel; }; + +export const queryCurrentChannel = async (database: Database) => { + const currentChannelId = await queryCurrentChannelId(database); + if (currentChannelId) { + const channels = await queryChannelsById(database, [currentChannelId]); + if (channels?.length) { + return channels[0]; + } + } + + return undefined; +}; + +export const deleteChannelMembership = async (operator: ServerDataOperator, userId: string, channelId: string) => { + try { + const channelMembership = await operator.database.get(CHANNEL_MEMBERSHIP).query(Q.where('user_id', Q.eq(userId)), Q.where('channel_id', Q.eq(channelId))).fetch(); + const models: Model[] = []; + for (const membership of channelMembership) { + models.push(membership.prepareDestroyPermanently()); + } + await operator.batchRecords(models); + return {}; + } catch (error) { + return {error}; + } +}; diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index 398c2c4c72..ac805907ef 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -14,6 +14,7 @@ type PrepareCommonSystemValuesArgs = { currentTeamId?: string; currentUserId?: string; license?: ClientLicense; + teamHistory?: string; } const {SERVER: {SYSTEM}} = MM_TABLES; @@ -117,6 +118,23 @@ export const queryWebSocketLastDisconnected = async (serverDatabase: Database) = } }; +export const queryTeamHistory = async (serverDatabase: Database) => { + try { + const teamHistory = await serverDatabase.get(SYSTEM).find(SYSTEM_IDENTIFIERS.TEAM_HISTORY); + return (teamHistory.value) as string[]; + } catch { + return []; + } +}; + +export const patchTeamHistory = async (operator: ServerDataOperator, value: string[], prepareRecordsOnly = false) => { + return operator.handleSystem({systems: [{ + id: SYSTEM_IDENTIFIERS.TEAM_HISTORY, + value: JSON.stringify(value), + }], + prepareRecordsOnly}); +}; + export const prepareCommonSystemValues = ( operator: ServerDataOperator, values: PrepareCommonSystemValuesArgs) => { try { diff --git a/app/queries/servers/team.ts b/app/queries/servers/team.ts index 462df90ac6..e514183644 100644 --- a/app/queries/servers/team.ts +++ b/app/queries/servers/team.ts @@ -6,10 +6,11 @@ import {Database, Model, Q, Query, Relation} from '@nozbe/watermelondb'; import {Database as DatabaseConstants, Preferences} from '@constants'; import {getPreferenceValue} from '@helpers/api/preference'; import {selectDefaultTeam} from '@helpers/api/team'; +import {DEFAULT_LOCALE} from '@i18n'; import {prepareDeleteChannel, queryDefaultChannelForTeam} from './channel'; import {queryPreferencesByCategoryAndName} from './preference'; -import {queryConfig} from './system'; +import {patchTeamHistory, queryConfig, queryTeamHistory} from './system'; import {queryCurrentUser} from './user'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -18,12 +19,16 @@ import type MyTeamModel from '@typings/database/models/servers/my_team'; import type TeamModel from '@typings/database/models/servers/team'; import type TeamChannelHistoryModel from '@typings/database/models/servers/team_channel_history'; -const {MY_TEAM, TEAM, TEAM_CHANNEL_HISTORY} = DatabaseConstants.MM_TABLES.SERVER; +const {MY_TEAM, TEAM, TEAM_CHANNEL_HISTORY, MY_CHANNEL} = DatabaseConstants.MM_TABLES.SERVER; export const addChannelToTeamHistory = async (operator: ServerDataOperator, teamId: string, channelId: string, prepareRecordsOnly = false) => { let tch: TeamChannelHistory|undefined; try { + const myChannel = (await operator.database.get(MY_CHANNEL).find(channelId)); + if (!myChannel) { + return []; + } const teamChannelHistory = (await operator.database.get(TEAM_CHANNEL_HISTORY).find(teamId)) as TeamChannelHistoryModel; const channelIdSet = new Set(teamChannelHistory.channelIds); if (channelIdSet.has(channelId)) { @@ -65,6 +70,96 @@ export const queryLastChannelFromTeam = async (database: Database, teamId: strin return channelId; }; +export const removeChannelFromTeamHistory = async (operator: ServerDataOperator, teamId: string, channelId: string, prepareRecordsOnly = false) => { + let tch: TeamChannelHistory; + + try { + const teamChannelHistory = (await operator.database.get(TEAM_CHANNEL_HISTORY).find(teamId)) as TeamChannelHistoryModel; + const channelIdSet = new Set(teamChannelHistory.channelIds); + if (channelIdSet.has(channelId)) { + channelIdSet.delete(channelId); + } else { + return []; + } + + const channelIds = Array.from(channelIdSet); + tch = { + id: teamId, + channel_ids: channelIds, + }; + } catch { + return []; + } + + return operator.handleTeamChannelHistory({teamChannelHistories: [tch], prepareRecordsOnly}); +}; + +export const addTeamToTeamHistory = async (operator: ServerDataOperator, teamId: string, prepareRecordsOnly = false) => { + const teamHistory = (await queryTeamHistory(operator.database)); + const teamHistorySet = new Set(teamHistory); + if (teamHistorySet.has(teamId)) { + teamHistorySet.delete(teamId); + } + + const teamIds = Array.from(teamHistorySet); + teamIds.unshift(teamId); + return patchTeamHistory(operator, teamIds, prepareRecordsOnly); +}; + +export const removeTeamFromTeamHistory = async (operator: ServerDataOperator, teamId: string, prepareRecordsOnly = false) => { + const teamHistory = (await queryTeamHistory(operator.database)); + const teamHistorySet = new Set(teamHistory); + if (!teamHistorySet.has(teamId)) { + return undefined; + } + + teamHistorySet.delete(teamId); + const teamIds = Array.from(teamHistorySet).slice(0, 5); + + return patchTeamHistory(operator, teamIds, prepareRecordsOnly); +}; + +export const queryLastTeam = async (database: Database) => { + const teamHistory = (await queryTeamHistory(database)); + if (teamHistory.length > 0) { + return teamHistory[0]; + } + + return queryDefaultTeam(database); +}; + +export const syncTeamTable = async (operator: ServerDataOperator, teams: Team[]) => { + try { + const notAvailable = await operator.database.get(TEAM).query(Q.where('id', Q.notIn(teams.map((t) => t.id)))).fetch(); + const models = []; + const deletions = await Promise.all(notAvailable.map((t) => prepareDeleteTeam(t))); + for (const d of deletions) { + models.push(...d); + } + models.push(...await operator.handleTeam({teams, prepareRecordsOnly: true})); + await operator.batchRecords(models); + return {}; + } catch (error) { + return {error}; + } +}; + +export const queryDefaultTeam = async (database: Database) => { + const user = await queryCurrentUser(database); + const config = await queryConfig(database); + const teamOrderPreferences = await queryPreferencesByCategoryAndName(database, Preferences.TEAMS_ORDER, ''); + let teamOrderPreference = ''; + if (teamOrderPreferences.length) { + teamOrderPreference = teamOrderPreferences[0].value; + } + + const teamModels = await database.get(TEAM).query(Q.on(MY_TEAM, Q.where('id', Q.notEq('')))).fetch(); + const teams = teamModels.map((t) => ({id: t.id, display_name: t.displayName, name: t.name} as Team)); + + const defaultTeam = selectDefaultTeam(teams, user?.locale || DEFAULT_LOCALE, teamOrderPreference, config.ExperimentalPrimaryTeam); + return defaultTeam?.id; +}; + export const prepareMyTeams = (operator: ServerDataOperator, teams: Team[], memberships: TeamMembership[]) => { try { const teamRecords = operator.handleTeam({prepareRecordsOnly: true, teams}); @@ -85,13 +180,18 @@ export const prepareMyTeams = (operator: ServerDataOperator, teams: Team[], memb }; export const deleteMyTeams = async (operator: ServerDataOperator, teams: TeamModel[]) => { - const preparedModels: Model[] = []; - for await (const team of teams) { - const myTeam = await team.myTeam.fetch() as MyTeamModel; - preparedModels.push(myTeam.prepareDestroyPermanently()); - } + try { + const preparedModels: Model[] = []; + for await (const team of teams) { + const myTeam = await team.myTeam.fetch() as MyTeamModel; + preparedModels.push(myTeam.prepareDestroyPermanently()); + } - await operator.batchRecords(preparedModels); + await operator.batchRecords(preparedModels); + return {}; + } catch (error) { + return {error}; + } }; export const prepareDeleteTeam = async (team: TeamModel): Promise => { diff --git a/index.ts b/index.ts index 8029156b92..46a21a1661 100644 --- a/index.ts +++ b/index.ts @@ -13,6 +13,7 @@ import {initialLaunch} from './app/init/launch'; import ManagedApp from './app/init/managed_app'; import NetworkManager from './app/init/network_manager'; import PushNotifications from './app/init/push_notifications'; +import WebsocketManager from './app/init/websocket_manager'; import {registerScreens} from './app/screens'; import EphemeralStore from './app/store/ephemeral_store'; import setFontFamily from './app/utils/font_family'; @@ -57,6 +58,7 @@ Navigation.events().registerAppLaunchedListener(async () => { await DatabaseManager.init(serverUrls); await NetworkManager.init(serverCredentials); + await WebsocketManager.init(serverCredentials); PushNotifications.init(); initialLaunch(); diff --git a/ios/Podfile b/ios/Podfile index 1739978573..629a896e7f 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -29,7 +29,7 @@ target 'Mattermost' do pod 'Swime', '3.0.6' # TODO: Remove this once upstream PR https://github.com/daltoniam/Starscream/pull/871 is merged - pod 'Starscream', :git => 'https://github.com/mattermost/Starscream.git', :commit => '1b4b93708fb63d2665625a11e57461772a65364a' + pod 'Starscream', :git => 'https://github.com/mattermost/Starscream.git', :commit => 'cb83dd247339ff6c155f0e749d6fe2cc145f5283' end # Enables Flipper. diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b6bf57611b..c9df979d3d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -536,7 +536,7 @@ DEPENDENCIES: - RNSVG (from `../node_modules/react-native-svg`) - RNVectorIcons (from `../node_modules/react-native-vector-icons`) - "simdjson (from `../node_modules/@nozbe/simdjson`)" - - Starscream (from `https://github.com/mattermost/Starscream.git`, commit `1b4b93708fb63d2665625a11e57461772a65364a`) + - Starscream (from `https://github.com/mattermost/Starscream.git`, commit `cb83dd247339ff6c155f0e749d6fe2cc145f5283`) - Swime (= 3.0.6) - UMAppLoader (from `../node_modules/unimodules-app-loader/ios`) - "UMCore (from `../node_modules/@unimodules/core/ios`)" @@ -710,7 +710,7 @@ EXTERNAL SOURCES: simdjson: :path: "../node_modules/@nozbe/simdjson" Starscream: - :commit: 1b4b93708fb63d2665625a11e57461772a65364a + :commit: cb83dd247339ff6c155f0e749d6fe2cc145f5283 :git: https://github.com/mattermost/Starscream.git UMAppLoader: :path: "../node_modules/unimodules-app-loader/ios" @@ -727,7 +727,7 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: Starscream: - :commit: 1b4b93708fb63d2665625a11e57461772a65364a + :commit: cb83dd247339ff6c155f0e749d6fe2cc145f5283 :git: https://github.com/mattermost/Starscream.git SPEC CHECKSUMS: @@ -824,6 +824,6 @@ SPEC CHECKSUMS: Yoga: c11abbf5809216c91fcd62f5571078b83d9b6720 YoutubePlayer-in-WKWebView: cfbf46da51d7370662a695a8f351e5fa1d3e1008 -PODFILE CHECKSUM: d62332bb35b296f3ba5b63691519bc5b4dd1c289 +PODFILE CHECKSUM: b7ae70eab69dbc195f8f145338e347c9afbeb102 COCOAPODS: 1.10.2 diff --git a/types/api/config.d.ts b/types/api/config.d.ts index b9fa5d2cca..25b03de223 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -76,6 +76,7 @@ interface ClientConfig { EnablePreviewFeatures: string; EnablePreviewModeBanner: string; EnablePublicLink: string; + EnableReliableWebSockets: string; EnableSVGs: string; EnableSaml: string; EnableSignInWithEmail: string; diff --git a/types/database/database.d.ts b/types/database/database.d.ts index 2f51bb74df..b5428d335b 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -254,8 +254,6 @@ export type LoginArgs = { password: string; }; -export type LoadMeArgs = { user?: UserProfile; deviceToken?: string }; - export type ServerUrlChangedArgs = { configRecord: System; licenseRecord: System; diff --git a/types/database/raw_values.d.ts b/types/database/raw_values.d.ts index 42a8e07398..19566a7203 100644 --- a/types/database/raw_values.d.ts +++ b/types/database/raw_values.d.ts @@ -70,7 +70,7 @@ type ReactionsPerPost = { type IdValue = { id: string; - value: string; + value: unknown; }; type TeamChannelHistory = {