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 <nahumhbl@gmail.com>

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Daniel Espino García
2021-10-19 19:16:10 +02:00
committed by GitHub
parent e6f9b0e258
commit d1e0c99c3d
26 changed files with 1277 additions and 363 deletions

View File

@@ -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');
}
};

View File

@@ -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);
}
};

View File

@@ -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 {};

View File

@@ -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;
};

View File

@@ -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};

View File

@@ -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<string>(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 {

View File

@@ -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, '');
}
}
}
}
}

View File

@@ -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());
}
}

View File

@@ -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
}
}
}

View File

@@ -10,7 +10,7 @@ export interface ClientTeamsMix {
deleteTeam: (teamId: string) => Promise<any>;
updateTeam: (team: Team) => Promise<Team>;
patchTeam: (team: Partial<Team> & {id: string}) => Promise<Team>;
getTeams: (page?: number, perPage?: number, includeTotalCount?: boolean) => Promise<any>;
getTeams: (page?: number, perPage?: number, includeTotalCount?: boolean) => Promise<Team[]>;
getTeam: (teamId: string) => Promise<Team>;
getTeamByName: (teamName: string) => Promise<Team>;
getMyTeams: () => Promise<Team[]>;

View File

@@ -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();

View File

@@ -57,6 +57,7 @@ export const SYSTEM_IDENTIFIERS = {
INTEGRATION_TRIGGER_ID: 'IntegreationTriggerId',
LICENSE: 'license',
WEBSOCKET: 'WebSocket',
TEAM_HISTORY: 'teamHistory',
RECENT_CUSTOM_STATUS: 'recentCustomStatus',
};

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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<string, WebSocketClient> = {};
private statusUpdatesIntervalIDs: Record<string, NodeJS.Timer> = {};
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();

View File

@@ -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};
}
};

View File

@@ -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<SystemModel>(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 {

View File

@@ -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<TeamModel>(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<TeamModel>(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<Model[]> => {

View File

@@ -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();

View File

@@ -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.

View File

@@ -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

View File

@@ -76,6 +76,7 @@ interface ClientConfig {
EnablePreviewFeatures: string;
EnablePreviewModeBanner: string;
EnablePublicLink: string;
EnableReliableWebSockets: string;
EnableSVGs: string;
EnableSaml: string;
EnableSignInWithEmail: string;

View File

@@ -254,8 +254,6 @@ export type LoginArgs = {
password: string;
};
export type LoadMeArgs = { user?: UserProfile; deviceToken?: string };
export type ServerUrlChangedArgs = {
configRecord: System;
licenseRecord: System;

View File

@@ -70,7 +70,7 @@ type ReactionsPerPost = {
type IdValue = {
id: string;
value: string;
value: unknown;
};
type TeamChannelHistory = {