[Gekidou] push notifications (#5779)

* Push notifications entry point

* Process android notification natively only if RN is not initialized

* Database changes to store local channel viewed_at

* EphemeralStore wait until screen removed

* Move schedule session notification to utility

* Fix channel remote & local actions + added actions for markChannelAsViewed & fetchMyChannel

* Add fetchMyTeam remote action

* Add dismissAllModalsAndPopToScreen to navigation

* Improve post list component & add app state to re-trigger queries

* Improve WS implementation

* Handle push notification events

* Fix postsInChannel since handler

* Handle in-app notifications

* Post list to listen to column changes

* Track selected bottom tab in ephemeral store

* add useIsTablet hook

* in-app notifications on tablets
This commit is contained in:
Elias Nahum
2021-10-27 17:53:11 -03:00
committed by GitHub
parent c01bcb7559
commit 790b1beb22
87 changed files with 2923 additions and 1550 deletions

View File

@@ -10,6 +10,7 @@ import com.facebook.react.bridge.ReadableMap
import com.nozbe.watermelondb.Database
import com.nozbe.watermelondb.mapCursor
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.lang.Exception
import java.util.*
@@ -20,7 +21,7 @@ class DatabaseHelper {
val onlyServerUrl: String?
get() {
val query = "SELECT url FROM Servers WHERE last_active_at != 0"
val query = "SELECT url FROM Servers WHERE last_active_at != 0 AND identifier != ''"
val cursor = defaultDatabase!!.rawQuery(query)
if (cursor.count == 1) {
cursor.moveToFirst()
@@ -37,6 +38,19 @@ class DatabaseHelper {
}
}
fun getServerUrlForIdentifier(identifier: String): String? {
val args: Array<Any?> = arrayOf(identifier)
val query = "SELECT url FROM Servers WHERE identifier=?"
val cursor = defaultDatabase!!.rawQuery(query, args)
if (cursor.count == 1) {
cursor.moveToFirst()
val url = cursor.getString(0)
cursor.close()
return url
}
return null
}
fun find(db: Database, tableName: String, id: String?): ReadableMap? {
val args: Array<Any?> = arrayOf(id)
try {
@@ -363,19 +377,22 @@ class DatabaseHelper {
private fun insertFiles(db: Database, files: JSONArray) {
for (i in 0 until files.length()) {
val file = files.getJSONObject(i)
val miniPreview = try { file.getString("mini_preview") } catch (e: JSONException) { "" };
val height = try { file.getInt("height") } catch (e: JSONException) { 0 };
val width = try { file.getInt("width") } catch (e: JSONException) { 0 };
db.execute(
"insert into File (id, extension, height, image_thumbnail, local_path, mime_type, name, post_id, size, width, _status) " +
"values (?, ?, ?, ?, '', ?, ?, ?, ?, ?, 'created')",
arrayOf(
file.getString("id"),
file.getString("extension"),
file.getInt("height"),
file.getString("mini_preview"),
height,
miniPreview,
file.getString("mime_type"),
file.getString("name"),
file.getString("post_id"),
file.getDouble("size"),
file.getInt("width")
width
)
)
}

View File

@@ -9,6 +9,7 @@ import android.content.pm.PackageManager;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.Nullable;
@@ -149,6 +150,7 @@ public class CustomPushNotification extends PushNotification {
}
String serverUrl = addServerUrlToBundle(initialData);
boolean isReactInit = mAppLifecycleFacade.isReactInitialized();
if (ackId != null && serverUrl != null) {
notificationReceiptDelivery(ackId, serverUrl, postId, type, isIdLoaded, new ResolvePromise() {
@@ -157,7 +159,8 @@ public class CustomPushNotification extends PushNotification {
if (isIdLoaded) {
Bundle response = (Bundle) value;
if (value != null) {
addServerUrlToBundle(response);
response.putString("server_url", serverUrl);
mNotificationProps = createProps(response);
}
}
}
@@ -176,7 +179,12 @@ public class CustomPushNotification extends PushNotification {
if (!mAppLifecycleFacade.isAppVisible()) {
if (type.equals(PUSH_TYPE_MESSAGE)) {
if (channelId != null) {
if (serverUrl != null) {
if (serverUrl != null && !isReactInit) {
// We will only fetch the data related to the notification on the native side
// as updating the data directly to the db removes the wal & shm files needed
// by watermelonDB, if the DB is updated while WDB is running it causes WDB to
// detect the database as malformed, thus the app stop working and a restart is required.
// Data will be fetch from within the JS context instead.
dataHelper.fetchAndStoreDataForPushNotification(mNotificationProps.asBundle());
}
@@ -202,8 +210,6 @@ public class CustomPushNotification extends PushNotification {
}
buildNotification(notificationId, createSummary);
} else {
notifyReceivedToJS();
}
break;
case PUSH_TYPE_CLEAR:
@@ -211,7 +217,7 @@ public class CustomPushNotification extends PushNotification {
break;
}
if (mAppLifecycleFacade.isReactInitialized()) {
if (isReactInit) {
notifyReceivedToJS();
}
}
@@ -268,9 +274,15 @@ public class CustomPushNotification extends PushNotification {
}
private String addServerUrlToBundle(Bundle bundle) {
String serverUrl = bundle.getString("server_url");
if (serverUrl == null) {
String serverId = bundle.getString("server_id");
String serverUrl;
if (serverId == null) {
serverUrl = Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).getOnlyServerUrl();
} else {
serverUrl = Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).getServerUrlForIdentifier(serverId);
}
if (!TextUtils.isEmpty(serverUrl)) {
bundle.putString("server_url", serverUrl);
mNotificationProps = createProps(bundle);
}

View File

@@ -2,26 +2,19 @@
// See LICENSE.txt for license information.
import {Model} from '@nozbe/watermelondb';
import {DeviceEventEmitter} from 'react-native';
import {fetchChannelByName, fetchMyChannelsForTeam, joinChannel, markChannelAsViewed} from '@actions/remote/channel';
import {fetchPostsForChannel} from '@actions/remote/post';
import {fetchRolesIfNeeded} from '@actions/remote/role';
import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from '@actions/remote/team';
import {General} from '@constants';
import {Navigation as NavigationConstants, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {privateChannelJoinPrompt} from '@helpers/api/channel';
import {prepareDeleteChannel, prepareMyChannelsForTeam, queryChannelsById, queryMyChannel} from '@queries/servers/channel';
import {queryCommonSystemValues, queryCurrentTeamId, setCurrentChannelId, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {addChannelToTeamHistory, prepareMyTeams, queryMyTeamById, queryTeamById, queryTeamByName, removeChannelFromTeamHistory} from '@queries/servers/team';
import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url';
import {prepareDeleteChannel, queryAllMyChannelIds, queryChannelsById, queryMyChannel} from '@queries/servers/channel';
import {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, queryCommonSystemValues, queryCurrentTeamId} from '@queries/servers/system';
import {addChannelToTeamHistory, addTeamToTeamHistory, removeChannelFromTeamHistory} from '@queries/servers/team';
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
import {isTablet} from '@utils/helpers';
import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type MyTeamModel from '@typings/database/models/servers/my_team';
import type TeamModel from '@typings/database/models/servers/team';
import type {IntlShape} from 'react-intl';
export const switchToChannel = async (serverUrl: string, channelId: string) => {
export const switchToChannel = async (serverUrl: string, channelId: string, teamId?: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
@@ -29,26 +22,49 @@ export const switchToChannel = async (serverUrl: string, channelId: string) => {
try {
const dt = Date.now();
const isTabletDevice = await isTablet();
const system = await queryCommonSystemValues(database);
const member = await queryMyChannel(database, channelId);
if (member) {
fetchPostsForChannel(serverUrl, channelId);
const channel: ChannelModel = await member.channel.fetch();
const {operator} = DatabaseManager.serverDatabases[serverUrl];
const result = await setCurrentChannelId(operator, channelId);
const models = [];
const commonValues: PrepareCommonSystemValuesArgs = {currentChannelId: channelId};
if (teamId && system.currentTeamId !== teamId) {
commonValues.currentTeamId = teamId;
const history = await addTeamToTeamHistory(operator, teamId, true);
models.push(...history);
}
const common = await prepareCommonSystemValues(operator, commonValues);
if (common) {
models.push(...common);
}
let previousChannelId: string | undefined;
if (system.currentChannelId !== channelId) {
previousChannelId = system.currentChannelId;
await addChannelToTeamHistory(operator, system.currentTeamId, channelId, false);
const history = await addChannelToTeamHistory(operator, system.currentTeamId, channelId, true);
models.push(...history);
}
await markChannelAsViewed(serverUrl, channelId, previousChannelId, true);
if (!result.error) {
console.log('channel switch to', channel?.displayName, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
const viewedAt = await markChannelAsViewed(serverUrl, channelId, true);
if (viewedAt instanceof Model) {
models.push(viewedAt);
}
if (models.length) {
await operator.batchRecords(models);
}
if (isTabletDevice) {
dismissAllModalsAndPopToRoot();
DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_HOME);
} else {
dismissAllModalsAndPopToScreen(Screens.CHANNEL, '', undefined, {topBar: {visible: false}});
}
console.log('channel switch to', channel?.displayName, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
}
} catch (error) {
return {error};
@@ -57,171 +73,6 @@ export const switchToChannel = async (serverUrl: string, channelId: string) => {
return {error: undefined};
};
export const switchToChannelByName = async (serverUrl: string, channelName: string, teamName: string, errorHandler: (intl: IntlShape) => void, intl: IntlShape) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
let myChannel: MyChannelModel | ChannelMembership | undefined;
let team: TeamModel | Team | undefined;
let myTeam: MyTeamModel | TeamMembership | undefined;
let name = teamName;
const roles: string [] = [];
const system = await queryCommonSystemValues(database);
const currentTeam = await queryTeamById(database, system.currentTeamId);
if (name === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
name = currentTeam!.name;
} else {
team = await queryTeamByName(database, teamName);
}
if (!team) {
const fetchTeam = await fetchTeamByName(serverUrl, name, true);
if (fetchTeam.error) {
errorHandler(intl);
return {error: fetchTeam.error};
}
team = fetchTeam.team!;
}
let joinedNewTeam = false;
myTeam = await queryMyTeamById(database, team.id);
if (!myTeam) {
const added = await addUserToTeam(serverUrl, team.id, system.currentUserId, true);
if (added.error) {
errorHandler(intl);
return {error: added.error};
}
myTeam = added.member!;
roles.push(...myTeam.roles.split(' '));
joinedNewTeam = true;
}
if (!myTeam) {
errorHandler(intl);
return {error: 'Could not fetch team member'};
}
let isArchived = false;
const chReq = await fetchChannelByName(serverUrl, team.id, channelName);
if (chReq.error) {
errorHandler(intl);
return {error: chReq.error};
}
const channel = chReq.channel;
if (!channel) {
errorHandler(intl);
return {error: 'Could not fetch channel'};
}
isArchived = channel.delete_at > 0;
if (isArchived && system.config.ExperimentalViewArchivedChannels !== 'true') {
errorHandler(intl);
return {error: 'Channel is archived'};
}
myChannel = await queryMyChannel(database, channel.id);
if (!myChannel) {
if (channel.type === General.PRIVATE_CHANNEL) {
const displayName = channel.display_name;
const {join} = await privateChannelJoinPrompt(displayName, intl);
if (!join) {
if (joinedNewTeam) {
await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true);
}
errorHandler(intl);
return {error: 'Refused to join Private channel'};
}
console.log('joining channel', displayName, channel.id); //eslint-disable-line
const result = await joinChannel(serverUrl, system.currentUserId, team.id, channel.id, undefined, true);
if (result.error || !result.channel) {
if (joinedNewTeam) {
await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true);
}
errorHandler(intl);
return {error: result.error};
}
myChannel = result.member!;
roles.push(...myChannel.roles.split(' '));
}
}
if (!myChannel) {
errorHandler(intl);
return {error: 'could not fetch channel member'};
}
const modelPromises: Array<Promise<Model[]>> = [];
const {operator} = DatabaseManager.serverDatabases[serverUrl];
if (!(team instanceof Model)) {
const prepT = prepareMyTeams(operator, [team], [(myTeam as TeamMembership)]);
if (prepT) {
modelPromises.push(...prepT);
}
} else if (!(myTeam instanceof Model)) {
const mt: MyTeam[] = [{
id: myTeam.team_id,
roles: myTeam.roles,
}];
modelPromises.push(
operator.handleMyTeam({myTeams: mt, prepareRecordsOnly: true}),
operator.handleTeamMemberships({teamMemberships: [myTeam], prepareRecordsOnly: true}),
);
}
if (!(myChannel instanceof Model)) {
const prepCh = await prepareMyChannelsForTeam(operator, team.id, [channel], [myChannel]);
if (prepCh) {
modelPromises.push(...prepCh);
}
}
let teamId;
if (team.id !== system.currentTeamId) {
teamId = team.id;
}
let channelId;
if (channel.id !== system.currentChannelId) {
channelId = channel.id;
}
if (modelPromises.length) {
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
}
if (channelId) {
fetchPostsForChannel(serverUrl, channelId);
}
if (teamId) {
fetchMyChannelsForTeam(serverUrl, teamId, true, 0, false, true);
}
await setCurrentTeamAndChannelId(operator, teamId, channelId);
if (teamId && channelId) {
await addChannelToTeamHistory(operator, teamId, channelId, false);
}
if (roles.length) {
fetchRolesIfNeeded(serverUrl, roles);
}
return {error: undefined};
} catch (error) {
errorHandler(intl);
return {error};
}
};
export const localRemoveUserFromChannel = async (serverUrl: string, channelId: string) => {
const serverDatabase = DatabaseManager.serverDatabases[serverUrl];
if (!serverDatabase) {
@@ -278,3 +129,42 @@ export const localSetChannelDeleteAt = async (serverUrl: string, channelId: stri
console.log('FAILED TO BATCH CHANGES FOR CHANNEL DELETE AT');
}
};
export const selectAllMyChannelIds = async (serverUrl: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return [];
}
return queryAllMyChannelIds(database);
};
export const markChannelAsViewed = async (serverUrl: string, channelId: string, prepareRecordsOnly = false) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const member = await queryMyChannel(database, channelId);
if (!member) {
return {error: 'not a member'};
}
member.prepareUpdate((m) => {
m.messageCount = 0;
m.mentionsCount = 0;
m.manuallyUnread = false;
m.viewedAt = member.lastViewedAt;
});
try {
if (!prepareRecordsOnly) {
const {operator} = DatabaseManager.serverDatabases[serverUrl];
await operator.batchRecords([member]);
}
return member;
} catch (error) {
return {error};
}
};

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {queryPostsInChannel} from '@queries/servers/post';
import type PostModel from '@typings/database/models/servers/post';
export const updatePostSinceCache = async (serverUrl: string, notification: NotificationWithData) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
if (notification.payload?.channel_id) {
const {database} = operator;
const chunks = await queryPostsInChannel(database, notification.payload.channel_id);
if (chunks.length) {
const recent = chunks[0];
const lastPost = await database.get<PostModel>(MM_TABLES.SERVER.POST).find(notification.payload.post_id);
await operator.database.write(async () => {
await recent.update(() => {
recent.latest = lastPost.createAt;
});
});
}
}
return {};
} catch (error) {
return {error};
}
};

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {createIntl} from 'react-intl';
import {getSessions} from '@actions/remote/session';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import PushNotifications from '@init/push_notifications';
import {sortByNewest} from '@utils/general';
export const scheduleExpiredNotification = async (serverUrl: string, config: Partial<ClientConfig>, userId: string, locale = DEFAULT_LOCALE) => {
if (config.ExtendSessionLengthWithActivity === 'true') {
PushNotifications.cancelAllLocalNotifications();
return null;
}
const timeOut = setTimeout(async () => {
clearTimeout(timeOut);
let sessions: Session[]|undefined;
try {
sessions = await getSessions(serverUrl, userId);
} catch (e) {
// eslint-disable-next-line no-console
console.warn('Failed to get user sessions', e);
return;
}
if (!Array.isArray(sessions)) {
return;
}
const session = sessions.sort(sortByNewest)[0];
const expiresAt = session?.expires_at || 0;
const expiresInDays = Math.ceil(Math.abs(moment.duration(moment().diff(moment(expiresAt))).asDays()));
const intl = createIntl({locale, messages: getTranslations(locale)});
const body = intl.formatMessage({
id: 'mobile.session_expired',
defaultMessage: 'Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.',
}, {siteName: config.SiteName, daysCount: expiresInDays});
if (expiresAt && body) {
//@ts-expect-error: Does not need to set all Notification properties
PushNotifications.scheduleNotification({
fireDate: expiresAt,
body,
payload: {
userInfo: {
local: true,
},
},
});
}
}, 1000);
return null;
};

View File

@@ -8,7 +8,7 @@ import {fetchPostsForChannel, fetchPostsForUnreadChannels} from '@actions/remote
import {fetchAllTeams} from '@actions/remote/team';
import Events from '@constants/events';
import DatabaseManager from '@database/manager';
import {queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {prepareCommonSystemValues, queryCurrentTeamId} from '@queries/servers/system';
import {prepareDeleteTeam, queryMyTeamById, removeTeamFromTeamHistory, queryLastChannelFromTeam, addTeamToTeamHistory} from '@queries/servers/team';
import {isTablet} from '@utils/helpers';
@@ -29,8 +29,19 @@ export const handleTeamChange = async (serverUrl: string, teamId: string) => {
fetchPostsForChannel(serverUrl, channelId);
}
}
setCurrentTeamAndChannelId(operator, teamId, channelId);
addTeamToTeamHistory(operator, teamId);
const models = [];
const system = await prepareCommonSystemValues(operator, {currentChannelId: channelId, currentTeamId: teamId});
if (system?.length) {
models.push(...system);
}
const history = await addTeamToTeamHistory(operator, teamId, true);
if (history.length) {
models.push(...history);
}
if (models.length) {
operator.batchRecords(models);
}
const {channels, memberships, error} = await fetchMyChannelsForTeam(serverUrl, teamId);
if (error) {

View File

@@ -1,18 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Model} from '@nozbe/watermelondb';
import {IntlShape} from 'react-intl';
import {switchToChannel} from '@actions/local/channel';
import {General} from '@constants';
import DatabaseManager from '@database/manager';
import {privateChannelJoinPrompt} from '@helpers/api/channel';
import NetworkManager from '@init/network_manager';
import {prepareMyChannelsForTeam, queryMyChannel} from '@queries/servers/channel';
import {queryCommonSystemValues} from '@queries/servers/system';
import {prepareMyTeams, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team';
import MyChannelModel from '@typings/database/models/servers/my_channel';
import MyTeamModel from '@typings/database/models/servers/my_team';
import TeamModel from '@typings/database/models/servers/team';
import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url';
import {displayGroupMessageName, displayUsername} from '@utils/user';
import {fetchRolesIfNeeded} from './role';
import {forceLogoutIfNecessary} from './session';
import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from './team';
import {fetchProfilesPerChannels, fetchUsersByIds} from './user';
import type {Client} from '@client/rest';
import type {Model} from '@nozbe/watermelondb';
export type MyChannelsRequest = {
channels?: Channel[];
@@ -132,11 +143,58 @@ export const fetchMyChannelsForTeam = async (serverUrl: string, teamId: string,
}
};
export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels: Channel[], locale?: string, teammateDisplayNameSetting?: string, exludeUserId?: string) => {
export const fetchMyChannel = async (serverUrl: string, teamId: string, channelId: string, fetchOnly = false): Promise<MyChannelsRequest> => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const [channel, member] = await Promise.all([
client.getChannel(channelId),
client.getChannelMember(channelId, 'me'),
]);
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
const modelPromises: Array<Promise<Model[]>> = [];
if (operator) {
const prepare = await prepareMyChannelsForTeam(operator, teamId, [channel], [member]);
if (prepare) {
modelPromises.push(...prepare);
}
if (modelPromises.length) {
const models = await Promise.all(modelPromises);
const flattenedModels = models.flat() as Model[];
if (flattenedModels?.length > 0) {
try {
await operator.batchRecords(flattenedModels);
} catch {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANNELS');
}
}
}
}
}
return {
channels: [channel],
memberships: [member],
};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels: Channel[], locale?: string, teammateDisplayNameSetting?: string, exludeUserId?: string, fetchOnly = false) => {
const channelIds = directChannels.map((dc) => dc.id);
const result = await fetchProfilesPerChannels(serverUrl, channelIds, exludeUserId, false);
if (result.error) {
return;
return {error: result.error};
}
const displayNameByChannel: Record<string, string> = {};
@@ -160,10 +218,14 @@ export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels:
}
});
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
operator.handleChannel({channels: directChannels, prepareRecordsOnly: false});
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
await operator.handleChannel({channels: directChannels, prepareRecordsOnly: false});
}
}
return {directChannels};
};
export const joinChannel = async (serverUrl: string, userId: string, teamId: string, channelId?: string, channelName?: string, fetchOnly = false) => {
@@ -227,57 +289,173 @@ export const joinChannel = async (serverUrl: string, userId: string, teamId: str
return {channel, member};
};
export const markChannelAsViewed = async (serverUrl: string, channelId: string, previousChannelId = '', markOnServer = true) => {
export const markChannelAsRead = async (serverUrl: string, channelId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.viewMyChannel(channelId);
return {};
} catch (error) {
return {error};
}
};
export const switchToChannelByName = async (serverUrl: string, channelName: string, teamName: string, errorHandler: (intl: IntlShape) => void, intl: IntlShape) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const member = await queryMyChannel(database, channelId);
const prevMember = await queryMyChannel(database, previousChannelId);
if (markOnServer) {
try {
const client = NetworkManager.getClient(serverUrl);
client.viewMyChannel(channelId, prevMember?.manuallyUnread ? '' : previousChannelId).catch(() => {
// do nothing just adding the handler to avoid the warning
});
} catch (error) {
return {error};
}
}
const models = [];
const lastViewedAt = Date.now();
if (member) {
member.prepareUpdate((m) => {
m.messageCount = 0;
m.mentionsCount = 0;
m.manuallyUnread = false;
m.lastViewedAt = lastViewedAt;
});
models.push(member);
}
if (prevMember && !prevMember.manuallyUnread) {
prevMember.prepareUpdate((m) => {
m.messageCount = 0;
m.mentionsCount = 0;
m.manuallyUnread = false;
m.lastViewedAt = lastViewedAt;
});
models.push(prevMember);
}
try {
if (models.length) {
const {operator} = DatabaseManager.serverDatabases[serverUrl];
await operator.batchRecords(models);
let myChannel: MyChannelModel | ChannelMembership | undefined;
let team: TeamModel | Team | undefined;
let myTeam: MyTeamModel | TeamMembership | undefined;
let name = teamName;
const roles: string [] = [];
const system = await queryCommonSystemValues(database);
const currentTeam = await queryTeamById(database, system.currentTeamId);
if (name === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
name = currentTeam!.name;
} else {
team = await queryTeamByName(database, teamName);
}
return {data: true};
if (!team) {
const fetchTeam = await fetchTeamByName(serverUrl, name, true);
if (fetchTeam.error) {
errorHandler(intl);
return {error: fetchTeam.error};
}
team = fetchTeam.team!;
}
let joinedNewTeam = false;
myTeam = await queryMyTeamById(database, team.id);
if (!myTeam) {
const added = await addUserToTeam(serverUrl, team.id, system.currentUserId, true);
if (added.error) {
errorHandler(intl);
return {error: added.error};
}
myTeam = added.member!;
roles.push(...myTeam.roles.split(' '));
joinedNewTeam = true;
}
if (!myTeam) {
errorHandler(intl);
return {error: 'Could not fetch team member'};
}
let isArchived = false;
const chReq = await fetchChannelByName(serverUrl, team.id, channelName);
if (chReq.error) {
errorHandler(intl);
return {error: chReq.error};
}
const channel = chReq.channel;
if (!channel) {
errorHandler(intl);
return {error: 'Could not fetch channel'};
}
isArchived = channel.delete_at > 0;
if (isArchived && system.config.ExperimentalViewArchivedChannels !== 'true') {
errorHandler(intl);
return {error: 'Channel is archived'};
}
myChannel = await queryMyChannel(database, channel.id);
if (!myChannel) {
if (channel.type === General.PRIVATE_CHANNEL) {
const displayName = channel.display_name;
const {join} = await privateChannelJoinPrompt(displayName, intl);
if (!join) {
if (joinedNewTeam) {
await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true);
}
errorHandler(intl);
return {error: 'Refused to join Private channel'};
}
console.log('joining channel', displayName, channel.id); //eslint-disable-line
const result = await joinChannel(serverUrl, system.currentUserId, team.id, channel.id, undefined, true);
if (result.error || !result.channel) {
if (joinedNewTeam) {
await removeUserFromTeam(serverUrl, team.id, system.currentUserId, true);
}
errorHandler(intl);
return {error: result.error};
}
myChannel = result.member!;
roles.push(...myChannel.roles.split(' '));
}
}
if (!myChannel) {
errorHandler(intl);
return {error: 'could not fetch channel member'};
}
const modelPromises: Array<Promise<Model[]>> = [];
const {operator} = DatabaseManager.serverDatabases[serverUrl];
if (!(team instanceof Model)) {
const prepT = prepareMyTeams(operator, [team], [(myTeam as TeamMembership)]);
if (prepT) {
modelPromises.push(...prepT);
}
} else if (!(myTeam instanceof Model)) {
const mt: MyTeam[] = [{
id: myTeam.team_id,
roles: myTeam.roles,
}];
modelPromises.push(
operator.handleMyTeam({myTeams: mt, prepareRecordsOnly: true}),
operator.handleTeamMemberships({teamMemberships: [myTeam], prepareRecordsOnly: true}),
);
}
if (!(myChannel instanceof Model)) {
const prepCh = await prepareMyChannelsForTeam(operator, team.id, [channel], [myChannel]);
if (prepCh) {
modelPromises.push(...prepCh);
}
}
let teamId;
if (team.id !== system.currentTeamId) {
teamId = team.id;
}
let channelId;
if (channel.id !== system.currentChannelId) {
channelId = channel.id;
}
if (modelPromises.length) {
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
}
if (teamId) {
fetchMyChannelsForTeam(serverUrl, teamId, true, 0, false, true);
}
if (teamId && channelId) {
await switchToChannel(serverUrl, channelId, teamId);
}
if (roles.length) {
fetchRolesIfNeeded(serverUrl, roles);
}
return {error: undefined};
} catch (error) {
errorHandler(intl);
return {error};
}
};

View File

@@ -1,415 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Model} from '@nozbe/watermelondb';
import {scheduleExpiredNotification} from '@actions/local/push_notification';
import {General, Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreferenceValue, getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {selectDefaultTeam} from '@helpers/api/team';
import {DEFAULT_LOCALE} from '@i18n';
import NetworkManager from '@init/network_manager';
import {queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel';
import {prepareModels} from '@queries/servers/entry';
import {prepareCommonSystemValues, queryCommonSystemValues, queryConfig, queryCurrentTeamId, queryWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {addChannelToTeamHistory, deleteMyTeams, queryAvailableTeamIds, queryMyTeams, queryTeamsById} from '@queries/servers/team';
import {queryCurrentUser} from '@queries/servers/user';
import {selectDefaultChannelForTeam} from '@utils/channel';
import {deleteV1Data} from '@utils/file';
import {fetchMissingSidebarInfo, fetchMyChannelsForTeam, MyChannelsRequest} from './channel';
import {fetchGroupsForTeam} from './group';
import {fetchPostsForChannel, fetchPostsForUnreadChannels} from './post';
import {MyPreferencesRequest, fetchMyPreferences} from './preference';
import {fetchRoles, fetchRolesIfNeeded} from './role';
import {ConfigAndLicenseRequest, fetchConfigAndLicense} from './systems';
import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, MyTeamsRequest} from './team';
import {fetchMe, MyUserRequest} from './user';
import type {Client} from '@client/rest';
import type ClientError from '@client/rest/error';
type AfterLoginArgs = {
serverUrl: string;
user: UserProfile;
deviceToken?: string;
}
type AppEntryData = {
initialTeamId: string;
teamData: MyTeamsRequest;
chData?: MyChannelsRequest;
prefData: MyPreferencesRequest;
meData: MyUserRequest;
removeTeamIds?: string[];
removeChannelIds?: string[];
}
type AppEntryError = {
error?: Error | ClientError | string;
}
export const appEntry = async (serverUrl: string) => {
const dt = Date.now();
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
const currentTeamId = await queryCurrentTeamId(database);
const fetchedData = await fetchAppEntryData(serverUrl, currentTeamId);
const fetchedError = (fetchedData as AppEntryError).error;
if (fetchedError) {
return {error: fetchedError, time: Date.now() - dt};
}
const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData;
if (initialTeamId !== currentTeamId) {
// Immediately set the new team as the current team in the database so that the UI
// renders the correct team.
setCurrentTeamAndChannelId(operator, initialTeamId, '');
}
let removeTeams;
if (removeTeamIds?.length) {
// Immediately delete myTeams so that the UI renders only teams the user is a member of.
removeTeams = await queryTeamsById(database, removeTeamIds);
await deleteMyTeams(operator, removeTeams!);
}
fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user);
let removeChannels;
if (removeChannelIds?.length) {
removeChannels = await queryChannelsById(database, removeChannelIds);
}
const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData});
const models = await Promise.all(modelPromises);
if (models.length) {
await operator.batchRecords(models.flat() as Model[]);
}
const {id: currentUserId, locale: currentUserLocale} = meData.user || (await queryCurrentUser(database))!;
const {config, license} = await queryCommonSystemValues(database);
deferredAppEntryActions(serverUrl, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId);
const error = teamData.error || chData?.error || prefData.error || meData.error;
return {error, time: Date.now() - dt, userId: meData?.user?.id};
};
export const loginEntry = async ({serverUrl, user, deviceToken}: AfterLoginArgs) => {
const dt = Date.now();
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
if (deviceToken) {
try {
client.attachDevice(deviceToken);
} catch {
// do nothing, the token could've failed to attach to the session but is not a blocker
}
}
try {
let initialTeam: Team|undefined;
let initialChannel: Channel|undefined;
let myTeams: Team[]|undefined;
// Fetch in parallel server config & license / user preferences / teams / team membership
const promises: [Promise<ConfigAndLicenseRequest>, Promise<MyPreferencesRequest>, Promise<MyTeamsRequest>] = [
fetchConfigAndLicense(serverUrl, true),
fetchMyPreferences(serverUrl, true),
fetchMyTeams(serverUrl, true),
];
const [clData, prefData, teamData] = await Promise.all(promises);
let chData: MyChannelsRequest|undefined;
// schedule local push notification if needed
if (clData.config) {
scheduleExpiredNotification(serverUrl, clData.config, user.id, user.locale);
}
// select initial team
if (!clData.error && !prefData.error && !teamData.error) {
const teamOrderPreference = getPreferenceValue(prefData.preferences!, Preferences.TEAMS_ORDER, '', '') as string;
const teamRoles: string[] = [];
const teamMembers: string[] = [];
teamData.memberships?.forEach((tm) => {
teamRoles.push(...tm.roles.split(' '));
teamMembers.push(tm.team_id);
});
myTeams = teamData.teams!.filter((t) => teamMembers?.includes(t.id));
initialTeam = selectDefaultTeam(myTeams, user.locale, teamOrderPreference, clData.config?.ExperimentalPrimaryTeam);
if (initialTeam) {
const rolesToFetch = new Set<string>([...user.roles.split(' '), ...teamRoles]);
// fetch channels / channel membership for initial team
chData = await fetchMyChannelsForTeam(serverUrl, initialTeam.id, false, 0, true);
if (chData.channels?.length && chData.memberships?.length) {
const {channels, memberships} = chData;
const channelIds = new Set(channels?.map((c) => c.id));
for (let i = 0; i < memberships!.length; i++) {
const member = memberships[i];
if (channelIds.has(member.channel_id)) {
member.roles.split(' ').forEach(rolesToFetch.add, rolesToFetch);
}
}
// fetch user roles
const rData = await fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch));
// select initial channel
initialChannel = selectDefaultChannelForTeam(channels!, memberships!, initialTeam!.id, rData.roles, user.locale);
}
}
}
const modelPromises = await prepareModels({operator, teamData, chData, prefData, initialTeamId: initialTeam?.id});
const systemModels = prepareCommonSystemValues(
operator,
{
config: clData.config || ({} as ClientConfig),
license: clData.license || ({} as ClientLicense),
currentTeamId: initialTeam?.id || '',
currentChannelId: initialChannel?.id || '',
},
);
if (systemModels) {
modelPromises.push(systemModels);
}
if (initialTeam && initialChannel) {
try {
const tch = addChannelToTeamHistory(operator, initialTeam.id, initialChannel.id, true);
modelPromises.push(tch);
} catch {
// do nothing
}
}
const models = await Promise.all(modelPromises);
if (models.length) {
await operator.batchRecords(models.flat() as Model[]);
}
const config = clData.config || {} as ClientConfig;
const license = clData.license || {} as ClientLicense;
deferredAppEntryActions(serverUrl, user.id, user.locale, prefData.preferences, config, license, teamData, chData, initialTeam?.id, initialChannel?.id);
const error = clData.error || prefData.error || teamData.error || chData?.error;
return {error, time: Date.now() - dt, hasTeams: Boolean((myTeams?.length || 0) > 0 && !teamData.error)};
} catch (error) {
const systemModels = await prepareCommonSystemValues(operator, {
config: ({} as ClientConfig),
license: ({} as ClientLicense),
currentTeamId: '',
currentChannelId: '',
});
if (systemModels) {
await operator.batchRecords(systemModels);
}
return {error};
}
};
export const upgradeEntry = async (serverUrl: string) => {
const dt = Date.now();
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const configAndLicense = await fetchConfigAndLicense(serverUrl, false);
const entry = await appEntry(serverUrl);
const error = configAndLicense.error || entry.error;
if (!error) {
const models = await prepareCommonSystemValues(operator, {currentUserId: entry.userId});
if (models?.length) {
await operator.batchRecords(models);
}
DatabaseManager.setActiveServerDatabase(serverUrl);
deleteV1Data();
}
return {error, time: Date.now() - dt};
} catch (e) {
return {error: e, time: Date.now() - dt};
}
};
const fetchAppEntryData = async (serverUrl: string, initialTeamId: string): Promise<AppEntryData | AppEntryError> => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const lastDisconnected = await queryWebSocketLastDisconnected(database);
const includeDeletedChannels = true;
const fetchOnly = true;
// Fetch in parallel teams / team membership / channels for current team / user preferences / user
const promises: [Promise<MyTeamsRequest>, Promise<MyChannelsRequest | undefined>, Promise<MyPreferencesRequest>, Promise<MyUserRequest>] = [
fetchMyTeams(serverUrl, fetchOnly),
initialTeamId ? fetchMyChannelsForTeam(serverUrl, initialTeamId, includeDeletedChannels, lastDisconnected, fetchOnly) : Promise.resolve(undefined),
fetchMyPreferences(serverUrl, fetchOnly),
fetchMe(serverUrl, fetchOnly),
];
const removeTeamIds: string[] = [];
const resolution = await Promise.all(promises);
const [teamData, , prefData, meData] = resolution;
let [, chData] = resolution;
if (!initialTeamId && teamData.teams?.length && teamData.memberships?.length) {
// If no initial team was set in the database but got teams in the response
const config = await queryConfig(database);
const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.TEAMS_ORDER, '', '') as string;
const teamMembers = teamData.memberships.filter((m) => m.delete_at === 0).map((m) => m.team_id);
const myTeams = teamData.teams!.filter((t) => teamMembers?.includes(t.id));
const defaultTeam = selectDefaultTeam(myTeams, meData.user?.locale || DEFAULT_LOCALE, teamOrderPreference, config.ExperimentalPrimaryTeam);
if (defaultTeam?.id) {
chData = await fetchMyChannelsForTeam(serverUrl, defaultTeam.id, includeDeletedChannels, lastDisconnected, fetchOnly);
}
}
const removedFromTeam = teamData.memberships?.filter((m) => m.delete_at > 0);
if (removedFromTeam?.length) {
removeTeamIds.push(...removedFromTeam.map((m) => m.team_id));
}
let data: AppEntryData = {
initialTeamId,
teamData,
chData,
prefData,
meData,
removeTeamIds,
};
if (teamData.teams?.length === 0) {
// User is no longer a member of any team
const myTeams = await queryMyTeams(database);
removeTeamIds.push(...(myTeams?.map((myTeam) => myTeam.id) || []));
return {
...data,
initialTeamId: '',
removeTeamIds,
};
}
const inTeam = teamData.teams?.find((t) => t.id === initialTeamId);
const chError = chData?.error as ClientError | undefined;
if (!inTeam || chError?.status_code === 403) {
// User is no longer a member of the current team
if (!removeTeamIds.includes(initialTeamId)) {
removeTeamIds.push(initialTeamId);
}
const availableTeamIds = await queryAvailableTeamIds(database, initialTeamId, teamData.teams, prefData.preferences, meData.user?.locale);
const alternateTeamData = await fetchAlternateTeamData(serverUrl, availableTeamIds, removeTeamIds, includeDeletedChannels, lastDisconnected, fetchOnly);
data = {
...data,
...alternateTeamData,
};
}
if (data.chData?.channels) {
const removeChannelIds: string[] = [];
const fetchedChannelIds = data.chData.channels.map((channel) => channel.id);
const channels = await queryAllChannelsForTeam(database, initialTeamId);
for (const channel of channels) {
if (!fetchedChannelIds.includes(channel.id)) {
removeChannelIds.push(channel.id);
}
}
data = {
...data,
removeChannelIds,
};
}
return data;
};
const fetchAlternateTeamData = async (serverUrl: string, availableTeamIds: string[], removeTeamIds: string[], includeDeleted = true, since = 0, fetchOnly = false) => {
let initialTeamId = '';
let chData;
for (const teamId of availableTeamIds) {
// eslint-disable-next-line no-await-in-loop
chData = await fetchMyChannelsForTeam(serverUrl, teamId, includeDeleted, since, fetchOnly);
const chError = chData.error as ClientError | undefined;
if (chError?.status_code === 403) {
removeTeamIds.push(teamId);
} else {
initialTeamId = teamId;
break;
}
}
if (chData) {
return {initialTeamId, chData, removeTeamIds};
}
return {initialTeamId, removeTeamIds};
};
const deferredAppEntryActions = async (
serverUrl: string, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined, config: ClientConfig, license: ClientLicense, teamData: MyTeamsRequest,
chData: MyChannelsRequest | undefined, initialTeamId?: string, initialChannelId?: string) => {
// defer fetching posts for initial channel
if (initialChannelId) {
fetchPostsForChannel(serverUrl, initialChannelId);
}
// defer sidebar DM & GM profiles
if (chData?.channels?.length && chData.memberships?.length) {
const directChannels = chData.channels.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license);
await fetchMissingSidebarInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
}
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
// defer groups for team
if (initialTeamId) {
await fetchGroupsForTeam(serverUrl, initialTeamId);
}
// defer fetch channels and unread posts for other teams
if (teamData.teams?.length && teamData.memberships?.length) {
fetchTeamsChannelsAndUnreadPosts(serverUrl, teamData.teams, teamData.memberships, initialTeamId);
}
};

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchRoles} from '@actions/remote/role';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager';
import {queryChannelsById, queryDefaultChannelForTeam} from '@queries/servers/channel';
import {prepareModels} from '@queries/servers/entry';
import {prepareCommonSystemValues, queryCommonSystemValues, queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {deleteMyTeams, queryTeamsById} from '@queries/servers/team';
import {queryCurrentUser} from '@queries/servers/user';
import {deleteV1Data} from '@utils/file';
import {isTablet} from '@utils/helpers';
import {AppEntryData, AppEntryError, deferredAppEntryActions, fetchAppEntryData} from './common';
export const appEntry = async (serverUrl: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
const {database} = operator;
const currentTeamId = await queryCurrentTeamId(database);
const fetchedData = await fetchAppEntryData(serverUrl, currentTeamId);
const fetchedError = (fetchedData as AppEntryError).error;
if (fetchedError) {
return {error: fetchedError};
}
const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData;
if (initialTeamId !== currentTeamId) {
// Immediately set the new team as the current team in the database so that the UI
// renders the correct team.
let channelId = '';
if ((await isTablet())) {
const channel = await queryDefaultChannelForTeam(database, initialTeamId);
channelId = channel?.id || '';
}
setCurrentTeamAndChannelId(operator, initialTeamId, channelId);
}
let removeTeams;
if (removeTeamIds?.length) {
// Immediately delete myTeams so that the UI renders only teams the user is a member of.
removeTeams = await queryTeamsById(database, removeTeamIds);
await deleteMyTeams(operator, removeTeams!);
}
fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user);
let removeChannels;
if (removeChannelIds?.length) {
removeChannels = await queryChannelsById(database, removeChannelIds);
}
const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData});
const models = await Promise.all(modelPromises);
if (models.length) {
await operator.batchRecords(models.flat());
}
const {id: currentUserId, locale: currentUserLocale} = meData.user || (await queryCurrentUser(database))!;
const {config, license} = await queryCommonSystemValues(database);
deferredAppEntryActions(serverUrl, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId);
const error = teamData.error || chData?.error || prefData.error || meData.error;
return {error, userId: meData?.user?.id};
};
export const upgradeEntry = async (serverUrl: string) => {
const dt = Date.now();
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const configAndLicense = await fetchConfigAndLicense(serverUrl, false);
const entry = await appEntry(serverUrl);
const error = configAndLicense.error || entry.error;
if (!error) {
const models = await prepareCommonSystemValues(operator, {currentUserId: entry.userId});
if (models?.length) {
await operator.batchRecords(models);
}
DatabaseManager.updateServerIdentifier(serverUrl, configAndLicense.config!.DiagnosticId);
DatabaseManager.setActiveServerDatabase(serverUrl);
deleteV1Data();
}
return {error, time: Date.now() - dt};
} catch (e) {
return {error: e, time: Date.now() - dt};
}
};

View File

@@ -0,0 +1,189 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchMissingSidebarInfo, fetchMyChannelsForTeam, MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForTeam} from '@actions/remote/group';
import {fetchPostsForChannel, fetchPostsForUnreadChannels} from '@actions/remote/post';
import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, MyTeamsRequest} from '@actions/remote/team';
import {fetchMe, MyUserRequest} from '@actions/remote/user';
import {General, Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreferenceValue, getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {selectDefaultTeam} from '@helpers/api/team';
import {DEFAULT_LOCALE} from '@i18n';
import {queryAllChannelsForTeam} from '@queries/servers/channel';
import {queryConfig, queryWebSocketLastDisconnected} from '@queries/servers/system';
import {queryAvailableTeamIds, queryMyTeams} from '@queries/servers/team';
import type ClientError from '@client/rest/error';
export type AppEntryData = {
initialTeamId: string;
teamData: MyTeamsRequest;
chData?: MyChannelsRequest;
prefData: MyPreferencesRequest;
meData: MyUserRequest;
removeTeamIds?: string[];
removeChannelIds?: string[];
}
export type AppEntryError = {
error?: Error | ClientError | string;
}
export const fetchAppEntryData = async (serverUrl: string, initialTeamId: string): Promise<AppEntryData | AppEntryError> => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
const lastDisconnected = await queryWebSocketLastDisconnected(database);
const includeDeletedChannels = true;
const fetchOnly = true;
// Fetch in parallel teams / team membership / channels for current team / user preferences / user
const promises: [Promise<MyTeamsRequest>, Promise<MyChannelsRequest | undefined>, Promise<MyPreferencesRequest>, Promise<MyUserRequest>] = [
fetchMyTeams(serverUrl, fetchOnly),
initialTeamId ? fetchMyChannelsForTeam(serverUrl, initialTeamId, includeDeletedChannels, lastDisconnected, fetchOnly) : Promise.resolve(undefined),
fetchMyPreferences(serverUrl, fetchOnly),
fetchMe(serverUrl, fetchOnly),
];
const removeTeamIds: string[] = [];
const resolution = await Promise.all(promises);
const [teamData, , prefData, meData] = resolution;
let [, chData] = resolution;
if (!initialTeamId && teamData.teams?.length && teamData.memberships?.length) {
// If no initial team was set in the database but got teams in the response
const config = await queryConfig(database);
const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.TEAMS_ORDER, '', '') as string;
const teamMembers = teamData.memberships.filter((m) => m.delete_at === 0).map((m) => m.team_id);
const myTeams = teamData.teams!.filter((t) => teamMembers?.includes(t.id));
const defaultTeam = selectDefaultTeam(myTeams, meData.user?.locale || DEFAULT_LOCALE, teamOrderPreference, config.ExperimentalPrimaryTeam);
if (defaultTeam?.id) {
chData = await fetchMyChannelsForTeam(serverUrl, defaultTeam.id, includeDeletedChannels, lastDisconnected, fetchOnly);
}
}
const removedFromTeam = teamData.memberships?.filter((m) => m.delete_at > 0);
if (removedFromTeam?.length) {
removeTeamIds.push(...removedFromTeam.map((m) => m.team_id));
}
let data: AppEntryData = {
initialTeamId,
teamData,
chData,
prefData,
meData,
removeTeamIds,
};
if (teamData.teams?.length === 0) {
// User is no longer a member of any team
const myTeams = await queryMyTeams(database);
removeTeamIds.push(...(myTeams?.map((myTeam) => myTeam.id) || []));
return {
...data,
initialTeamId: '',
removeTeamIds,
};
}
const inTeam = teamData.teams?.find((t) => t.id === initialTeamId);
const chError = chData?.error as ClientError | undefined;
if (!inTeam || chError?.status_code === 403) {
// User is no longer a member of the current team
if (!removeTeamIds.includes(initialTeamId)) {
removeTeamIds.push(initialTeamId);
}
const availableTeamIds = await queryAvailableTeamIds(database, initialTeamId, teamData.teams, prefData.preferences, meData.user?.locale);
const alternateTeamData = await fetchAlternateTeamData(serverUrl, availableTeamIds, removeTeamIds, includeDeletedChannels, lastDisconnected, fetchOnly);
data = {
...data,
...alternateTeamData,
};
}
if (data.chData?.channels) {
const removeChannelIds: string[] = [];
const fetchedChannelIds = data.chData.channels.map((channel) => channel.id);
const channels = await queryAllChannelsForTeam(database, initialTeamId);
for (const channel of channels) {
if (!fetchedChannelIds.includes(channel.id)) {
removeChannelIds.push(channel.id);
}
}
data = {
...data,
removeChannelIds,
};
}
return data;
};
export const fetchAlternateTeamData = async (
serverUrl: string, availableTeamIds: string[], removeTeamIds: string[],
includeDeleted = true, since = 0, fetchOnly = false) => {
let initialTeamId = '';
let chData;
for (const teamId of availableTeamIds) {
// eslint-disable-next-line no-await-in-loop
chData = await fetchMyChannelsForTeam(serverUrl, teamId, includeDeleted, since, fetchOnly);
const chError = chData.error as ClientError | undefined;
if (chError?.status_code === 403) {
removeTeamIds.push(teamId);
} else {
initialTeamId = teamId;
break;
}
}
if (chData) {
return {initialTeamId, chData, removeTeamIds};
}
return {initialTeamId, removeTeamIds};
};
export const deferredAppEntryActions = async (
serverUrl: string, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined,
config: ClientConfig, license: ClientLicense, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined,
initialTeamId?: string, initialChannelId?: string) => {
// defer fetching posts for initial channel
if (initialChannelId) {
fetchPostsForChannel(serverUrl, initialChannelId);
}
// defer sidebar DM & GM profiles
if (chData?.channels?.length && chData.memberships?.length) {
const directChannels = chData.channels.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license);
await fetchMissingSidebarInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
}
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
// defer groups for team
if (initialTeamId) {
await fetchGroupsForTeam(serverUrl, initialTeamId);
}
// defer fetch channels and unread posts for other teams
if (teamData.teams?.length && teamData.memberships?.length) {
fetchTeamsChannelsAndUnreadPosts(serverUrl, teamData.teams, teamData.memberships, initialTeamId);
}
};

View File

@@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {appEntry, upgradeEntry} from './app';
export {loginEntry} from './login';
export {pushNotificationEntry} from './notification';

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Model} from '@nozbe/watermelondb';
import {fetchMyChannelsForTeam, MyChannelsRequest} from '@actions/remote/channel';
import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {fetchRolesIfNeeded} from '@actions/remote/role';
import {getSessions} from '@actions/remote/session';
import {ConfigAndLicenseRequest, fetchConfigAndLicense} from '@actions/remote/systems';
import {fetchMyTeams, MyTeamsRequest} from '@actions/remote/team';
import {Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreferenceValue} from '@helpers/api/preference';
import {selectDefaultTeam} from '@helpers/api/team';
import NetworkManager from '@init/network_manager';
import {prepareModels} from '@queries/servers/entry';
import {prepareCommonSystemValues} from '@queries/servers/system';
import {addChannelToTeamHistory} from '@queries/servers/team';
import {selectDefaultChannelForTeam} from '@utils/channel';
import {scheduleExpiredNotification} from '@utils/notification';
import {deferredAppEntryActions} from './common';
import type {Client} from '@client/rest';
type AfterLoginArgs = {
serverUrl: string;
user: UserProfile;
deviceToken?: string;
}
export const loginEntry = async ({serverUrl, user, deviceToken}: AfterLoginArgs) => {
const dt = Date.now();
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
if (deviceToken) {
try {
client.attachDevice(deviceToken);
} catch {
// do nothing, the token could've failed to attach to the session but is not a blocker
}
}
try {
let initialTeam: Team|undefined;
let initialChannel: Channel|undefined;
let myTeams: Team[]|undefined;
// Fetch in parallel server config & license / user preferences / teams / team membership
const promises: [Promise<ConfigAndLicenseRequest>, Promise<MyPreferencesRequest>, Promise<MyTeamsRequest>] = [
fetchConfigAndLicense(serverUrl, true),
fetchMyPreferences(serverUrl, true),
fetchMyTeams(serverUrl, true),
];
const [clData, prefData, teamData] = await Promise.all(promises);
let chData: MyChannelsRequest|undefined;
// schedule local push notification if needed
if (clData.config) {
if (clData.config.ExtendSessionLengthWithActivity !== 'true') {
const timeOut = setTimeout(async () => {
clearTimeout(timeOut);
let sessions: Session[]|undefined;
try {
sessions = await getSessions(serverUrl, 'me');
} catch (e) {
// eslint-disable-next-line no-console
console.warn('Failed to get user sessions', e);
return;
}
if (sessions && Array.isArray(sessions)) {
scheduleExpiredNotification(sessions, clData.config?.SiteName || serverUrl, user.locale);
}
}, 500);
}
}
// select initial team
if (!clData.error && !prefData.error && !teamData.error) {
const teamOrderPreference = getPreferenceValue(prefData.preferences!, Preferences.TEAMS_ORDER, '', '') as string;
const teamRoles: string[] = [];
const teamMembers: string[] = [];
teamData.memberships?.forEach((tm) => {
teamRoles.push(...tm.roles.split(' '));
teamMembers.push(tm.team_id);
});
myTeams = teamData.teams!.filter((t) => teamMembers?.includes(t.id));
initialTeam = selectDefaultTeam(myTeams, user.locale, teamOrderPreference, clData.config?.ExperimentalPrimaryTeam);
if (initialTeam) {
const rolesToFetch = new Set<string>([...user.roles.split(' '), ...teamRoles]);
// fetch channels / channel membership for initial team
chData = await fetchMyChannelsForTeam(serverUrl, initialTeam.id, false, 0, true);
if (chData.channels?.length && chData.memberships?.length) {
const {channels, memberships} = chData;
const channelIds = new Set(channels?.map((c) => c.id));
for (let i = 0; i < memberships!.length; i++) {
const member = memberships[i];
if (channelIds.has(member.channel_id)) {
member.roles.split(' ').forEach(rolesToFetch.add, rolesToFetch);
}
}
// fetch user roles
const rData = await fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch));
// select initial channel
initialChannel = selectDefaultChannelForTeam(channels!, memberships!, initialTeam!.id, rData.roles, user.locale);
}
}
}
const modelPromises = await prepareModels({operator, teamData, chData, prefData, initialTeamId: initialTeam?.id});
const systemModels = prepareCommonSystemValues(
operator,
{
config: clData.config || ({} as ClientConfig),
license: clData.license || ({} as ClientLicense),
currentTeamId: initialTeam?.id || '',
currentChannelId: initialChannel?.id || '',
},
);
if (systemModels) {
modelPromises.push(systemModels);
}
if (initialTeam && initialChannel) {
try {
const tch = addChannelToTeamHistory(operator, initialTeam.id, initialChannel.id, true);
modelPromises.push(tch);
} catch {
// do nothing
}
}
const models = await Promise.all(modelPromises);
if (models.length) {
await operator.batchRecords(models.flat() as Model[]);
}
const config = clData.config || {} as ClientConfig;
const license = clData.license || {} as ClientLicense;
deferredAppEntryActions(serverUrl, user.id, user.locale, prefData.preferences, config, license, teamData, chData, initialTeam?.id, initialChannel?.id);
const error = clData.error || prefData.error || teamData.error || chData?.error;
return {error, time: Date.now() - dt, hasTeams: Boolean((myTeams?.length || 0) > 0 && !teamData.error)};
} catch (error) {
const systemModels = await prepareCommonSystemValues(operator, {
config: ({} as ClientConfig),
license: ({} as ClientLicense),
currentTeamId: '',
currentChannelId: '',
});
if (systemModels) {
await operator.batchRecords(systemModels);
}
return {error};
}
};

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Model} from '@nozbe/watermelondb';
import {switchToChannel} from '@actions/local/channel';
import {markChannelAsRead} from '@actions/remote/channel';
import {fetchRoles} from '@actions/remote/role';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {queryChannelsById, queryDefaultChannelForTeam, queryMyChannel} from '@queries/servers/channel';
import {prepareModels} from '@queries/servers/entry';
import {queryCommonSystemValues, queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {deleteMyTeams, queryMyTeamById, queryTeamsById} from '@queries/servers/team';
import {queryCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import {isTablet} from '@utils/helpers';
import {emitNotificationError} from '@utils/notification';
import {AppEntryData, AppEntryError, deferredAppEntryActions, fetchAppEntryData} from './common';
export const pushNotificationEntry = async (serverUrl: string, notification: NotificationWithData) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
// We only reach this point if we have a channel Id in the notification payload
const channelId = notification.payload!.channel_id!;
const isTabletDevice = await isTablet();
const {database} = operator;
const currentTeamId = await queryCurrentTeamId(database);
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
let isDirectChannel = false;
let teamId = notification.payload?.team_id;
if (!teamId) {
// If the notification payload does not have a teamId we assume is a DM/GM
isDirectChannel = true;
teamId = currentTeamId;
}
if (currentServerUrl !== serverUrl) {
await DatabaseManager.setActiveServerDatabase(serverUrl);
}
// To make the switch faster we determine if we already have the team & channel
const myChannel = await queryMyChannel(database, channelId);
const myTeam = await queryMyTeamById(database, teamId);
let switchedToTeamAndChanel = false;
if (myChannel && myTeam) {
switchedToTeamAndChanel = true;
await EphemeralStore.waitUntilScreenHasLoaded(Screens.HOME);
markChannelAsRead(serverUrl, channelId);
await switchToChannel(serverUrl, channelId, teamId);
}
const fetchedData = await fetchAppEntryData(serverUrl, teamId);
const fetchedError = (fetchedData as AppEntryError).error;
if (fetchedError) {
return {error: fetchedError};
}
const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData;
// There is a chance that after the above request returns
// the user is no longer part of the team or channel
// that triggered the notification (rare but possible)
let selectedTeamId = teamId;
let selectedChannelId = channelId;
if (initialTeamId !== teamId) {
// We are no longer a part of the team that the notification belongs to
// Immediately set the new team as the current team in the database so that the UI
// renders the correct team.
selectedTeamId = initialTeamId;
if (!isDirectChannel) {
if (isTabletDevice) {
const channel = await queryDefaultChannelForTeam(operator.database, selectedTeamId);
selectedChannelId = channel?.id || '';
} else {
selectedChannelId = '';
}
}
}
if (removeChannelIds?.includes(channelId)) {
// We are no longer a part of the channel that the notification belongs to
// Immediately set the new channel as the current channel in the database so that the UI
// renders the correct channel.
if (isTabletDevice) {
const channel = await queryDefaultChannelForTeam(operator.database, selectedTeamId);
selectedChannelId = channel?.id || '';
} else {
selectedChannelId = '';
}
}
// If in the end the selected team or channel is different than the one from the notification
// we switch again
if (selectedTeamId !== teamId || selectedChannelId !== channelId) {
setCurrentTeamAndChannelId(operator, selectedTeamId, selectedChannelId);
}
if (selectedTeamId !== teamId) {
emitNotificationError('Team');
} else if (selectedChannelId === channelId) {
if (!switchedToTeamAndChanel) {
markChannelAsRead(serverUrl, channelId);
switchToChannel(serverUrl, channelId, teamId);
}
} else {
emitNotificationError('Channel');
}
let removeTeams;
if (removeTeamIds?.length) {
// Immediately delete myTeams so that the UI renders only teams the user is a member of.
removeTeams = await queryTeamsById(operator.database, removeTeamIds);
await deleteMyTeams(operator, removeTeams!);
}
fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user);
let removeChannels;
if (removeChannelIds?.length) {
removeChannels = await queryChannelsById(operator.database, removeChannelIds);
}
const modelPromises = await prepareModels({operator, initialTeamId, removeTeams, removeChannels, teamData, chData, prefData, meData});
const models = await Promise.all(modelPromises);
if (models.length) {
await operator.batchRecords(models.flat() as Model[]);
}
const {id: currentUserId, locale: currentUserLocale} = meData.user || (await queryCurrentUser(operator.database))!;
const {config, license} = await queryCommonSystemValues(operator.database);
deferredAppEntryActions(serverUrl, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, selectedTeamId, selectedChannelId);
const error = teamData.error || chData?.error || prefData.error || meData.error;
return {error, userId: meData?.user?.id};
};

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
import {switchToChannel} from '@actions/local/channel';
import {updatePostSinceCache} from '@actions/local/notification';
import {fetchMissingSidebarInfo, fetchMyChannel, markChannelAsRead} from '@actions/remote/channel';
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {fetchMyTeam} from '@actions/remote/team';
import {Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {queryChannelsById, queryMyChannel} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {queryCommonSystemValues} from '@queries/servers/system';
import {queryMyTeamById} from '@queries/servers/team';
import {queryCurrentUser} from '@queries/servers/user';
import {emitNotificationError} from '@utils/notification';
import {fetchPostsForChannel} from './post';
const fetchNotificationData = async (serverUrl: string, notification: NotificationWithData, skipEvents = false) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const channelId = notification.payload!.channel_id!;
const {database} = operator;
const system = await queryCommonSystemValues(database);
let teamId = notification.payload?.team_id;
let isDirectChannel = false;
if (!teamId) {
// If the notification payload does not have a teamId we assume is a DM/GM
isDirectChannel = true;
teamId = system.currentTeamId;
}
// To make the switch faster we determine if we already have the team & channel
const myChannel = await queryMyChannel(database, channelId);
const myTeam = await queryMyTeamById(database, teamId);
if (!myTeam) {
const teamsReq = await fetchMyTeam(serverUrl, teamId, false);
if (teamsReq.error || !teamsReq.memberships?.length) {
if (!skipEvents) {
emitNotificationError('Team');
}
return {error: teamsReq.error || 'Team'};
}
}
if (!myChannel) {
// We only fetch the channel that the notification belongs to
const channelReq = await fetchMyChannel(serverUrl, teamId, channelId);
if (channelReq.error ||
!channelReq.channels?.find((c) => c.id === channelId && c.delete_at === 0) ||
!channelReq.memberships?.find((m) => m.channel_id === channelId)) {
if (!skipEvents) {
emitNotificationError('Channel');
}
return {error: channelReq.error || 'Channel'};
}
if (isDirectChannel) {
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT);
const currentUser = await queryCurrentUser(database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, system.license);
const channel = await queryChannelsById(database, [channelId]);
if (channel?.length) {
fetchMissingSidebarInfo(serverUrl, [channel[0].toApi()], currentUser?.locale, teammateDisplayNameSetting, currentUser?.id);
}
}
}
fetchPostsForChannel(serverUrl, channelId);
return {};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const backgroundNotification = async (serverUrl: string, notification: NotificationWithData) => {
if (Platform.OS === 'ios') {
updatePostSinceCache(serverUrl, notification);
}
await fetchNotificationData(serverUrl, notification, true);
};
export const openNotification = async (serverUrl: string, notification: NotificationWithData) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const channelId = notification.payload!.channel_id!;
await markChannelAsRead(serverUrl, channelId);
const {database} = operator;
const system = await queryCommonSystemValues(database);
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
let teamId = notification.payload?.team_id;
if (!teamId) {
// If the notification payload does not have a teamId we assume is a DM/GM
teamId = system.currentTeamId;
}
if (currentServerUrl !== serverUrl) {
await DatabaseManager.setActiveServerDatabase(serverUrl);
}
// To make the switch faster we determine if we already have the team & channel
const myChannel = await queryMyChannel(database, channelId);
const myTeam = await queryMyTeamById(database, teamId);
if (myChannel && myTeam) {
fetchPostsForChannel(serverUrl, channelId);
switchToChannel(serverUrl, channelId, teamId);
return {};
}
const result = await fetchNotificationData(serverUrl, notification);
if (result.error) {
return {error: result.error};
}
return switchToChannel(serverUrl, channelId, teamId);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};

View File

@@ -86,7 +86,7 @@ export const getSessions = async (serverUrl: string, currentUserId: string) => {
return undefined;
};
export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaToken, password}: LoginArgs): Promise<LoginActionResponse> => {
export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaToken, password, config}: LoginArgs): Promise<LoginActionResponse> => {
let deviceToken;
let user: UserProfile;
@@ -116,6 +116,7 @@ export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaTo
config: {
dbName: serverUrl,
serverUrl,
identifier: config.DiagnosticId,
},
});
await DatabaseManager.setActiveServerDatabase(serverUrl);
@@ -179,7 +180,7 @@ export const sendPasswordResetEmail = async (serverUrl: string, email: string) =
};
};
export const ssoLogin = async (serverUrl: string, bearerToken: string, csrfToken: string): Promise<LoginActionResponse> => {
export const ssoLogin = async (serverUrl: string, serverIdentifier: string, bearerToken: string, csrfToken: string): Promise<LoginActionResponse> => {
let deviceToken;
let user;
@@ -204,6 +205,7 @@ export const ssoLogin = async (serverUrl: string, bearerToken: string, csrfToken
config: {
dbName: serverUrl,
serverUrl,
identifier: serverIdentifier,
},
});
await DatabaseManager.setActiveServerDatabase(serverUrl);

View File

@@ -126,6 +126,44 @@ export const fetchMyTeams = async (serverUrl: string, fetchOnly = false): Promis
}
};
export const fetchMyTeam = async (serverUrl: string, teamId: string, fetchOnly = false): Promise<MyTeamsRequest> => {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const [team, membership] = await Promise.all([
client.getTeam(teamId),
client.getTeamMember(teamId, 'me'),
]);
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
const modelPromises: Array<Promise<Model[]>> = [];
if (operator) {
const prepare = prepareMyTeams(operator, [team], [membership]);
if (prepare) {
modelPromises.push(...prepare);
}
if (modelPromises.length) {
const models = await Promise.all(modelPromises);
const flattenedModels = models.flat() as Model[];
if (flattenedModels?.length > 0) {
await operator.batchRecords(flattenedModels);
}
}
}
}
return {teams: [team], memberships: [membership]};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
};
export const fetchAllTeams = async (serverUrl: string, fetchOnly = false): Promise<MyTeamsRequest> => {
let client;
try {

View File

@@ -3,7 +3,7 @@
import {DeviceEventEmitter} from 'react-native';
import {fetchMyChannelsForTeam} from '@actions/remote/channel';
import {fetchMissingSidebarInfo, fetchMyChannelsForTeam} from '@actions/remote/channel';
import {fetchPostsSince} from '@actions/remote/post';
import {fetchMyPreferences} from '@actions/remote/preference';
import {fetchRoles} from '@actions/remote/role';
@@ -11,14 +11,19 @@ import {fetchConfigAndLicense} from '@actions/remote/systems';
import {fetchAllTeams, fetchMyTeams} from '@actions/remote/team';
import {fetchMe, updateAllUsersSinceLastDisconnect} from '@actions/remote/user';
import Events from '@app/constants/events';
import {WebsocketEvents} from '@constants';
import {General, WebsocketEvents} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {prepareMyChannelsForTeam} from '@queries/servers/channel';
import {queryCommonSystemValues, queryConfig, queryWebSocketLastDisconnected} from '@queries/servers/system';
import {queryCurrentUser} from '@queries/servers/user';
import {handleChannelDeletedEvent, handleUserRemovedEvent} from './channel';
import {handleLeaveTeamEvent} from './teams';
import type {Model} from '@nozbe/watermelondb';
export async function handleFirstConnect(serverUrl: string) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
@@ -64,7 +69,7 @@ async function doReconnect(serverUrl: string) {
return;
}
const {currentUserId, currentTeamId, currentChannelId} = await queryCommonSystemValues(database.database);
const system = await queryCommonSystemValues(database.database);
const lastDisconnectedAt = await queryWebSocketLastDisconnected(database.database);
// TODO consider fetch only and batch all the results.
@@ -72,16 +77,42 @@ async function doReconnect(serverUrl: string) {
fetchMyPreferences(serverUrl);
const {config} = await fetchConfigAndLicense(serverUrl);
const {memberships: teamMemberships, error: teamMembershipsError} = await fetchMyTeams(serverUrl);
const {currentChannelId, currentUserId, currentTeamId, license} = system;
const currentTeamMembership = teamMemberships?.find((tm) => tm.team_id === currentTeamId && tm.delete_at === 0);
let channelMemberships: ChannelMembership[] | undefined;
if (currentTeamMembership) {
const {memberships, channels, error} = await fetchMyChannelsForTeam(serverUrl, currentTeamMembership.team_id, false, lastDisconnectedAt);
const {memberships, channels, error} = await fetchMyChannelsForTeam(serverUrl, currentTeamMembership.team_id, true, lastDisconnectedAt, true);
if (error) {
DeviceEventEmitter.emit(Events.TEAM_LOAD_ERROR, serverUrl, error);
return;
}
const currentUser = await queryCurrentUser(database.database);
const preferences = currentUser ? (await currentUser.preferences.fetch()) : [];
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, license);
const directChannels = channels?.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
if (directChannels?.length) {
await fetchMissingSidebarInfo(serverUrl, directChannels, currentUser?.locale, teammateDisplayNameSetting, currentUserId, true);
}
const modelPromises: Array<Promise<Model[]>> = [];
const prepare = await prepareMyChannelsForTeam(database.operator, currentTeamMembership.team_id, channels!, memberships!);
if (prepare) {
modelPromises.push(...prepare);
}
if (modelPromises.length) {
const models = await Promise.all(modelPromises);
const flattenedModels = models.flat();
if (flattenedModels?.length > 0) {
try {
await database.operator.batchRecords(flattenedModels);
} catch {
// eslint-disable-next-line no-console
console.log('FAILED TO BATCH CHANNELS');
}
}
}
channelMemberships = memberships;
if (currentChannelId) {

View File

@@ -44,6 +44,13 @@ export default class ClientBase {
}
}
getAbsoluteUrl(baseUrl?: string) {
if (typeof baseUrl !== 'string' || !baseUrl.startsWith('/')) {
return baseUrl;
}
return this.apiClient.baseUrl + baseUrl;
}
getRequestHeaders(requestMethod: string) {
const headers = {...this.requestHeaders};

View File

@@ -136,13 +136,14 @@ export default class WebSocketClient {
}
if (this.connectFailCount > 0) {
console.log('websocket re-established connection'); //eslint-disable-line no-console
console.log('websocket re-established connection to', url); //eslint-disable-line no-console
if (!reliableWebSockets && this.reconnectCallback) {
this.reconnectCallback();
} else if (reliableWebSockets && this.serverSequence && this.missedEventsCallback) {
this.missedEventsCallback();
}
} else if (this.firstConnectCallback) {
console.log('websocket connected to', url); //eslint-disable-line no-console
this.firstConnectCallback();
}
@@ -159,7 +160,7 @@ export default class WebSocketClient {
this.responseSequence = 1;
if (this.connectFailCount === 0) {
console.log('websocket closed'); //eslint-disable-line no-console
console.log('websocket closed', url); //eslint-disable-line no-console
}
this.connectFailCount++;
@@ -200,7 +201,7 @@ export default class WebSocketClient {
this.conn!.onError((evt: any) => {
if (this.connectFailCount <= 1) {
console.log('websocket error'); //eslint-disable-line no-console
console.log('websocket error', url); //eslint-disable-line no-console
console.log(evt); //eslint-disable-line no-console
}
@@ -224,14 +225,14 @@ export default class WebSocketClient {
// We check the hello packet, which is always the first packet in a stream.
if (msg.event === WebsocketEvents.HELLO && this.reconnectCallback) {
//eslint-disable-next-line no-console
console.log('got connection id ', msg.data.connection_id);
console.log(url, 'got connection id ', msg.data.connection_id);
// If we already have a connectionId present, and server sends a different one,
// that means it's either a long timeout, or server restart, or sequence number is not found.
// Then we do the sync calls, and reset sequence number to 0.
if (this.connectionId !== '' && this.connectionId !== msg.data.connection_id) {
//eslint-disable-next-line no-console
console.log('long timeout, or server restart, or sequence number is not found.');
console.log(url, 'long timeout, or server restart, or sequence number is not found.');
this.reconnectCallback();
this.serverSequence = 0;
}
@@ -245,7 +246,7 @@ export default class WebSocketClient {
// we just disconnect and reconnect.
if (msg.seq !== this.serverSequence) {
// eslint-disable-next-line no-console
console.log('missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence);
console.log(url, 'missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence);
// We are not calling this.close() because we need to auto-restart.
this.connectFailCount = 0;
@@ -255,7 +256,7 @@ export default class WebSocketClient {
}
} else if (msg.seq !== this.serverSequence && this.reconnectCallback) {
// eslint-disable-next-line no-console
console.log('missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence);
console.log(url, 'missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence);
this.reconnectCallback();
}

View File

@@ -12,8 +12,8 @@ import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import urlParse from 'url-parse';
import {switchToChannelByName} from '@actions/local/channel';
import {showPermalink} from '@actions/local/permalink';
import {switchToChannelByName} from '@actions/remote/channel';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import {Navigation} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';

View File

@@ -12,7 +12,7 @@ type DateSeparatorProps = {
date: number | Date;
style?: StyleProp<ViewStyle>;
theme: Theme;
timezone?: UserTimezone | null;
timezone?: string | null;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {

View File

@@ -5,7 +5,7 @@ import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {ReactElement, useCallback} from 'react';
import {DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleSheet, ViewToken} from 'react-native';
import {AppStateStatus, DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleSheet, ViewToken} from 'react-native';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
@@ -19,6 +19,7 @@ import {useTheme} from '@context/theme';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {emptyFunction} from '@utils/general';
import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList} from '@utils/post_list';
import {getTimezone} from '@utils/user';
import type {WithDatabaseArgs} from '@typings/database/database';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
@@ -36,7 +37,7 @@ type RefreshProps = {
}
type Props = {
currentTimezone: UserTimezone | null;
currentTimezone: string | null;
currentUsername: string;
isTimezoneEnabled: boolean;
lastViewedAt: number;
@@ -142,7 +143,7 @@ const PostList = ({currentTimezone, currentUsername, isTimezoneEnabled, lastView
date={getDateForDateLine(item)}
theme={theme}
style={style.scale}
timezone={currentTimezone}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
@@ -214,36 +215,35 @@ const PostList = ({currentTimezone, currentUsername, isTimezoneEnabled, lastView
keyExtractor={(item) => (typeof item === 'string' ? item : item.id)}
style={{flex: 1}}
contentContainerStyle={{paddingTop: 5}}
initialNumToRender={25}
maxToRenderPerBatch={25}
initialNumToRender={10}
maxToRenderPerBatch={10}
removeClippedSubviews={true}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={VIEWABILITY_CONFIG}
windowSize={30}
scrollEventThrottle={60}
/>
</PostListRefreshControl>
);
};
const withPosts = withObservables(['channelId'], ({database, channelId}: {channelId: string} & WithDatabaseArgs) => {
const withPosts = withObservables(['channelId', 'forceQueryAfterAppState'], ({database, channelId}: {channelId: string; forceQueryAfterAppState: AppStateStatus} & WithDatabaseArgs) => {
const currentUser = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap((currentUserId) => database.get<UserModel>(USER).findAndObserve(currentUserId.value)),
);
return {
currentTimezone: currentUser.pipe((switchMap((user) => of$(user.timezone)))),
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))),
currentUsername: currentUser.pipe((switchMap((user) => of$(user.username)))),
isTimezoneEnabled: database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')),
),
lastViewedAt: database.get<MyChannelModel>(MY_CHANNEL).findAndObserve(channelId).pipe(
switchMap((myChannel) => of$(myChannel.lastViewedAt)),
switchMap((myChannel) => of$(myChannel.viewedAt)),
),
posts: database.get<PostsInChannelModel>(POSTS_IN_CHANNEL).query(
Q.where('channel_id', channelId),
Q.experimentalSortBy('latest', Q.desc),
).observe().pipe(
).observeWithColumns(['earliest', 'latest']).pipe(
switchMap((postsInChannel) => {
if (!postsInChannel.length) {
return of$([]);
@@ -269,4 +269,4 @@ const withPosts = withObservables(['channelId'], ({database, channelId}: {channe
};
});
export default withDatabase(withPosts(React.memo(PostList)));
export default withDatabase(withPosts(PostList));

View File

@@ -29,9 +29,10 @@ export default function TeamList({myOrderedTeams}: Props) {
bounces={false}
contentContainerStyle={styles.contentContainer}
data={myOrderedTeams}
fadingEdgeLength={36}
fadingEdgeLength={30}
keyExtractor={keyExtractor}
renderItem={renderTeam}
showsVerticalScrollIndicator={false}
/>
</View>
);
@@ -44,5 +45,6 @@ const styles = StyleSheet.create({
contentContainer: {
alignItems: 'center',
marginVertical: 6,
paddingBottom: 10,
},
});

View File

@@ -63,7 +63,6 @@ export const SYSTEM_IDENTIFIERS = {
export const GLOBAL_IDENTIFIERS = {
DEVICE_TOKEN: 'deviceToken',
MENTION_COUNT: 'mentionCount',
};
export default {

View File

@@ -5,8 +5,9 @@ import keyMirror from '@utils/key_mirror';
export default keyMirror({
ACCOUNT_SELECT_TABLET_VIEW: null,
CHANNEL_DELETED: null,
LEAVE_CHANNEL: null,
LEAVE_TEAM: null,
NOTIFICATION_ERROR: null,
TEAM_LOAD_ERROR: null,
CHANNEL_DELETED: null,
});

View File

@@ -5,6 +5,7 @@ import keyMirror from '@utils/key_mirror';
const Navigation = keyMirror({
NAVIGATION_CLOSE_MODAL: null,
NAVIGATION_HOME: null,
NAVIGATION_NO_TEAMS: null,
NAVIGATION_ERROR_TEAMS: null,
NAVIGATION_SHOW_OVERLAY: null,

View File

@@ -11,6 +11,7 @@ export const CUSTOM_STATUS = 'CustomStatus';
export const FORGOT_PASSWORD = 'ForgotPassword';
export const HOME = 'Home';
export const INTEGRATION_SELECTOR = 'IntegrationSelector';
export const IN_APP_NOTIFICATION = 'InAppNotification';
export const LOGIN = 'Login';
export const LOGIN_OPTIONS = 'LoginOptions';
export const MAIN_SIDEBAR = 'MainSidebar';
@@ -33,6 +34,7 @@ export default {
FORGOT_PASSWORD,
HOME,
INTEGRATION_SELECTOR,
IN_APP_NOTIFICATION,
LOGIN,
LOGIN_OPTIONS,
MAIN_SIDEBAR,

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import {Database, Q} from '@nozbe/watermelondb';
import DatabaseProvider from '@nozbe/watermelondb/DatabaseProvider';
import React, {ComponentType, useEffect, useState} from 'react';
@@ -26,23 +26,27 @@ export function withServerDatabase<T>(Component: ComponentType<T>): ComponentTyp
const db = DatabaseManager.appDatabase?.database;
const observer = (servers: ServersModel[]) => {
const server = servers.reduce((a, b) =>
const server = servers?.length ? servers.reduce((a, b) =>
(b.lastActiveAt > a.lastActiveAt ? b : a),
);
) : undefined;
const serverDatabase =
DatabaseManager.serverDatabases[server?.url]?.database;
if (server) {
const serverDatabase =
DatabaseManager.serverDatabases[server?.url]?.database;
setState({
database: serverDatabase,
serverUrl: server?.url,
});
setState({
database: serverDatabase,
serverUrl: server?.url,
});
} else {
setState(undefined);
}
};
useEffect(() => {
const subscription = db?.collections.
get(SERVERS).
query().
query(Q.where('identifier', Q.notEq(''))).
observeWithColumns(['last_active_at']).
subscribe(observer);

View File

@@ -6,7 +6,6 @@ import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs';
import logger from '@nozbe/watermelondb/utils/common/logger';
import {DeviceEventEmitter, Platform} from 'react-native';
import {FileSystem} from 'react-native-unimodules';
import urlParse from 'url-parse';
import {MIGRATION_EVENTS, MM_TABLES} from '@constants/database';
import AppDatabaseMigrations from '@database/migration/app';
@@ -22,7 +21,7 @@ import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
import {schema as appSchema} from '@database/schema/app';
import {serverSchema} from '@database/schema/server';
import {queryActiveServer, queryServer} from '@queries/app/servers';
import {queryActiveServer, queryServer, queryServerByIdentifier} from '@queries/app/servers';
import {DatabaseType} from '@typings/database/enums';
import {deleteIOSDatabase} from '@utils/mattermost_managed';
import {hashCode} from '@utils/security';
@@ -96,7 +95,7 @@ class DatabaseManager {
};
public createServerDatabase = async ({config}: CreateServerDatabaseArgs): Promise<ServerDatabase|undefined> => {
const {dbName, displayName, serverUrl} = config;
const {dbName, displayName, identifier, serverUrl} = config;
if (serverUrl) {
try {
@@ -111,6 +110,7 @@ class DatabaseManager {
await this.addServerToAppDatabase({
databaseFilePath,
displayName: displayName || dbName,
identifier,
serverUrl,
});
@@ -139,30 +139,56 @@ class DatabaseManager {
});
};
private addServerToAppDatabase = async ({databaseFilePath, displayName, serverUrl}: RegisterServerDatabaseArgs): Promise<void> => {
private addServerToAppDatabase = async ({databaseFilePath, displayName, identifier = '', serverUrl}: RegisterServerDatabaseArgs): Promise<void> => {
try {
const isServerPresent = await this.isServerPresent(serverUrl);
const appDatabase = this.appDatabase?.database;
if (appDatabase) {
const isServerPresent = await this.isServerPresent(serverUrl);
if (this.appDatabase?.database && !isServerPresent) {
const appDatabase = this.appDatabase.database;
await appDatabase.write(async () => {
const serversCollection = appDatabase.collections.get(SERVERS);
await serversCollection.create((server: ServersModel) => {
server.dbPath = databaseFilePath;
server.displayName = displayName;
server.mentionCount = 0;
server.unreadCount = 0;
server.url = serverUrl;
server.isSecured = urlParse(serverUrl).protocol === 'https';
server.lastActiveAt = 0;
if (!isServerPresent) {
await appDatabase.write(async () => {
const serversCollection = appDatabase.collections.get(SERVERS);
await serversCollection.create((server: ServersModel) => {
server.dbPath = databaseFilePath;
server.displayName = displayName;
server.url = serverUrl;
server.identifier = identifier;
server.lastActiveAt = 0;
});
});
});
} else if (identifier) {
await this.updateServerIdentifier(serverUrl, identifier);
}
}
} catch (e) {
// do nothing
}
};
public updateServerIdentifier = async (serverUrl: string, identifier: string) => {
const appDatabase = this.appDatabase?.database;
if (appDatabase) {
const server = await queryServer(appDatabase, serverUrl);
await appDatabase.write(async () => {
await server.update((record) => {
record.identifier = identifier;
});
});
}
}
public updateServerDisplayName = async (serverUrl: string, displayName: string) => {
const appDatabase = this.appDatabase?.database;
if (appDatabase) {
const server = await queryServer(appDatabase, serverUrl);
await appDatabase.write(async () => {
await server.update((record) => {
record.displayName = displayName;
});
});
}
}
private isServerPresent = async (serverUrl: string): Promise<boolean> => {
if (this.appDatabase?.database) {
const server = await queryServer(this.appDatabase.database, serverUrl);
@@ -182,6 +208,16 @@ class DatabaseManager {
return null;
}
public getServerUrlFromIdentifier = async (identifier: string): Promise<string|undefined> => {
const database = this.appDatabase?.database;
if (database) {
const server = await queryServerByIdentifier(database, identifier);
return server?.url;
}
return undefined;
}
public getActiveServerDatabase = async (): Promise<Database|undefined> => {
const database = this.appDatabase?.database;
if (database) {
@@ -216,6 +252,7 @@ class DatabaseManager {
database.write(async () => {
await server.update((record) => {
record.lastActiveAt = 0;
record.identifier = '';
});
});

View File

@@ -7,7 +7,6 @@ import logger from '@nozbe/watermelondb/utils/common/logger';
import {DeviceEventEmitter, Platform} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import {FileSystem} from 'react-native-unimodules';
import urlParse from 'url-parse';
import {MIGRATION_EVENTS, MM_TABLES} from '@constants/database';
import AppDatabaseMigrations from '@database/migration/app';
@@ -23,7 +22,7 @@ import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
import {schema as appSchema} from '@database/schema/app';
import {serverSchema} from '@database/schema/server';
import {queryActiveServer, queryServer} from '@queries/app/servers';
import {queryActiveServer, queryServer, queryServerByIdentifier} from '@queries/app/servers';
import {DatabaseType} from '@typings/database/enums';
import {deleteIOSDatabase, getIOSAppGroupDetails} from '@utils/mattermost_managed';
import {hashCode} from '@utils/security';
@@ -34,372 +33,416 @@ const {SERVERS} = MM_TABLES.APP;
const APP_DATABASE = 'app';
class DatabaseManager {
public appDatabase?: AppDatabase;
public serverDatabases: ServerDatabases = {};
private readonly appModels: Models;
private readonly databaseDirectory: string | null;
private readonly serverModels: Models;
public appDatabase?: AppDatabase;
public serverDatabases: ServerDatabases = {};
private readonly appModels: Models;
private readonly databaseDirectory: string | null;
private readonly serverModels: Models;
constructor() {
this.appModels = [InfoModel, GlobalModel, ServersModel];
this.serverModels = [
ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel,
GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
TermsOfServiceModel, UserModel,
];
constructor() {
this.appModels = [InfoModel, GlobalModel, ServersModel];
this.serverModels = [
ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel,
GroupModel, GroupMembershipModel, GroupsChannelModel, GroupsTeamModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SlashCommandModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
TermsOfServiceModel, UserModel,
];
this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : FileSystem.documentDirectory;
}
this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : `${FileSystem.documentDirectory}databases/`;
}
/**
* init : Retrieves all the servers registered in the default database
* @param {string[]} serverUrls
* @returns {Promise<void>}
*/
public init = async (serverUrls: string[]): Promise<void> => {
await this.createAppDatabase();
for await (const serverUrl of serverUrls) {
await this.initServerDatabase(serverUrl);
}
this.appDatabase?.operator.handleInfo({
info: [{
build_number: DeviceInfo.getBuildNumber(),
created_at: Date.now(),
version_number: DeviceInfo.getVersion(),
}],
prepareRecordsOnly: false,
});
};
/**
* init : Retrieves all the servers registered in the default database
* @param {string[]} serverUrls
* @returns {Promise<void>}
*/
public init = async (serverUrls: string[]): Promise<void> => {
await this.createAppDatabase();
for await (const serverUrl of serverUrls) {
await this.initServerDatabase(serverUrl);
}
this.appDatabase?.operator.handleInfo({
info: [{
build_number: DeviceInfo.getBuildNumber(),
created_at: Date.now(),
version_number: DeviceInfo.getVersion(),
}],
prepareRecordsOnly: false,
});
};
/**
* createAppDatabase: Creates the App database. However,
* if a database could not be created, it will return undefined.
* @returns {Promise<AppDatabase|undefined>}
*/
private createAppDatabase = async (): Promise<AppDatabase|undefined> => {
try {
const databaseName = APP_DATABASE;
/**
* createAppDatabase: Creates the App database. However,
* if a database could not be created, it will return undefined.
* @returns {Promise<AppDatabase|undefined>}
*/
private createAppDatabase = async (): Promise<AppDatabase|undefined> => {
try {
const databaseName = APP_DATABASE;
const databaseFilePath = this.getDatabaseFilePath(databaseName);
const modelClasses = this.appModels;
const schema = appSchema;
if (Platform.OS === 'android') {
await FileSystem.makeDirectoryAsync(this.databaseDirectory!, {intermediates: true});
}
const databaseFilePath = this.getDatabaseFilePath(databaseName);
const modelClasses = this.appModels;
const schema = appSchema;
const adapter = new SQLiteAdapter({
dbName: databaseFilePath,
migrationEvents: this.buildMigrationCallbacks(databaseName),
migrations: AppDatabaseMigrations,
jsi: true,
schema,
});
const adapter = new SQLiteAdapter({
dbName: databaseFilePath,
migrationEvents: this.buildMigrationCallbacks(databaseName),
migrations: AppDatabaseMigrations,
jsi: true,
schema,
});
const database = new Database({adapter, modelClasses});
const operator = new AppDataOperator(database);
const database = new Database({adapter, modelClasses});
const operator = new AppDataOperator(database);
this.appDatabase = {
database,
operator,
};
this.appDatabase = {
database,
operator,
};
return this.appDatabase;
} catch (e) {
// TODO : report to sentry? Show something on the UI ?
}
return this.appDatabase;
} catch (e) {
// TODO : report to sentry? Show something on the UI ?
}
return undefined;
};
return undefined;
};
/**
* createServerDatabase: Creates a server database and registers the the server in the app database. However,
* if a database connection could not be created, it will return undefined.
* @param {CreateServerDatabaseArgs} createServerDatabaseArgs
*
* @returns {Promise<ServerDatabase|undefined>}
*/
public createServerDatabase = async ({config}: CreateServerDatabaseArgs): Promise<ServerDatabase|undefined> => {
const {dbName, displayName, serverUrl} = config;
/**
* createServerDatabase: Creates a server database and registers the the server in the app database. However,
* if a database connection could not be created, it will return undefined.
* @param {CreateServerDatabaseArgs} createServerDatabaseArgs
*
* @returns {Promise<ServerDatabase|undefined>}
*/
public createServerDatabase = async ({config}: CreateServerDatabaseArgs): Promise<ServerDatabase|undefined> => {
const {dbName, displayName, identifier, serverUrl} = config;
if (serverUrl) {
try {
const databaseName = hashCode(serverUrl);
const databaseFilePath = this.getDatabaseFilePath(databaseName);
const migrations = ServerDatabaseMigrations;
const modelClasses = this.serverModels;
const schema = serverSchema;
if (serverUrl) {
try {
const databaseName = hashCode(serverUrl);
const databaseFilePath = this.getDatabaseFilePath(databaseName);
const migrations = ServerDatabaseMigrations;
const modelClasses = this.serverModels;
const schema = serverSchema;
const adapter = new SQLiteAdapter({
dbName: databaseFilePath,
migrationEvents: this.buildMigrationCallbacks(databaseName),
migrations,
jsi: true,
schema,
});
const adapter = new SQLiteAdapter({
dbName: databaseFilePath,
migrationEvents: this.buildMigrationCallbacks(databaseName),
migrations,
jsi: true,
schema,
});
// Registers the new server connection into the DEFAULT database
await this.addServerToAppDatabase({
databaseFilePath,
displayName: displayName || dbName,
serverUrl,
});
// Registers the new server connection into the DEFAULT database
await this.addServerToAppDatabase({
databaseFilePath,
displayName: displayName || dbName,
identifier,
serverUrl,
});
const database = new Database({adapter, modelClasses});
const operator = new ServerDataOperator(database);
const serverDatabase = {database, operator};
const database = new Database({adapter, modelClasses});
const operator = new ServerDataOperator(database);
const serverDatabase = {database, operator};
this.serverDatabases[serverUrl] = serverDatabase;
this.serverDatabases[serverUrl] = serverDatabase;
return serverDatabase;
} catch (e) {
// TODO : report to sentry? Show something on the UI ?
}
}
return serverDatabase;
} catch (e) {
// TODO : report to sentry? Show something on the UI ?
}
}
return undefined;
};
return undefined;
};
/**
* initServerDatabase : initializes the server database.
* @param {string} serverUrl
* @returns {Promise<void>}
*/
private initServerDatabase = async (serverUrl: string): Promise<void> => {
await this.createServerDatabase({
config: {
dbName: serverUrl,
dbType: DatabaseType.SERVER,
serverUrl,
},
});
};
/**
* initServerDatabase : initializes the server database.
* @param {string} serverUrl
* @returns {Promise<void>}
*/
private initServerDatabase = async (serverUrl: string): Promise<void> => {
await this.createServerDatabase({
config: {
dbName: serverUrl,
dbType: DatabaseType.SERVER,
serverUrl,
},
});
};
/**
* addServerToAppDatabase: Adds a record in the 'app' database - into the 'servers' table - for this new server connection
* @param {string} databaseFilePath
* @param {string} displayName
* @param {string} serverUrl
* @returns {Promise<void>}
*/
private addServerToAppDatabase = async ({databaseFilePath, displayName, serverUrl}: RegisterServerDatabaseArgs): Promise<void> => {
try {
const isServerPresent = await this.isServerPresent(serverUrl);
/**
* addServerToAppDatabase: Adds a record in the 'app' database - into the 'servers' table - for this new server connection
* @param {string} databaseFilePath
* @param {string} displayName
* @param {string} serverUrl
* @param {string} identifier
* @returns {Promise<void>}
*/
private addServerToAppDatabase = async ({databaseFilePath, displayName, serverUrl, identifier = ''}: RegisterServerDatabaseArgs): Promise<void> => {
try {
const appDatabase = this.appDatabase?.database;
if (appDatabase) {
const isServerPresent = await this.isServerPresent(serverUrl);
if (this.appDatabase?.database && !isServerPresent) {
const appDatabase = this.appDatabase.database;
await appDatabase.write(async () => {
const serversCollection = appDatabase.collections.get(SERVERS);
await serversCollection.create((server: ServersModel) => {
server.dbPath = databaseFilePath;
server.displayName = displayName;
server.mentionCount = 0;
server.unreadCount = 0;
server.url = serverUrl;
server.isSecured = urlParse(serverUrl).protocol === 'https';
server.lastActiveAt = 0;
});
});
}
} catch (e) {
// TODO : report to sentry? Show something on the UI ?
}
};
if (!isServerPresent) {
await appDatabase.write(async () => {
const serversCollection = appDatabase.collections.get(SERVERS);
await serversCollection.create((server: ServersModel) => {
server.dbPath = databaseFilePath;
server.displayName = displayName;
server.url = serverUrl;
server.identifier = identifier;
server.lastActiveAt = 0;
});
});
} else if (identifier) {
await this.updateServerIdentifier(serverUrl, identifier);
}
}
} catch (e) {
// TODO : report to sentry? Show something on the UI ?
}
};
/**
* isServerPresent : Confirms if the current serverUrl does not already exist in the database
* @param {String} serverUrl
* @returns {Promise<boolean>}
*/
private isServerPresent = async (serverUrl: string): Promise<boolean> => {
if (this.appDatabase?.database) {
const server = await queryServer(this.appDatabase.database, serverUrl);
return Boolean(server);
}
public updateServerIdentifier = async (serverUrl: string, identifier: string) => {
const appDatabase = this.appDatabase?.database;
if (appDatabase) {
const server = await queryServer(appDatabase, serverUrl);
await appDatabase.write(async () => {
await server.update((record) => {
record.identifier = identifier;
});
});
}
}
return false;
}
public updateServerDisplayName = async (serverUrl: string, displayName: string) => {
const appDatabase = this.appDatabase?.database;
if (appDatabase) {
const server = await queryServer(appDatabase, serverUrl);
await appDatabase.write(async () => {
await server.update((record) => {
record.displayName = displayName;
});
});
}
}
/**
* getActiveServerUrl: Get the record for active server database.
* @returns {Promise<string|null|undefined>}
*/
public getActiveServerUrl = async (): Promise<string|null|undefined> => {
const database = this.appDatabase?.database;
if (database) {
const server = await queryActiveServer(database);
return server?.url;
}
/**
* isServerPresent : Confirms if the current serverUrl does not already exist in the database
* @param {String} serverUrl
* @returns {Promise<boolean>}
*/
private isServerPresent = async (serverUrl: string): Promise<boolean> => {
if (this.appDatabase?.database) {
const server = await queryServer(this.appDatabase.database, serverUrl);
return Boolean(server);
}
return null;
}
return false;
}
/**
* getActiveServerDatabase: Get the record for active server database.
* @returns {Promise<Database|undefined>}
*/
public getActiveServerDatabase = async (): Promise<Database|undefined> => {
const database = this.appDatabase?.database;
if (database) {
const server = await queryActiveServer(database);
if (server?.url) {
return this.serverDatabases[server.url]?.database;
}
}
/**
* getActiveServerUrl: Get the record for active server database.
* @returns {Promise<string|null|undefined>}
*/
public getActiveServerUrl = async (): Promise<string|null|undefined> => {
const database = this.appDatabase?.database;
if (database) {
const server = await queryActiveServer(database);
return server?.url;
}
return undefined;
}
return null;
}
/**
* setActiveServerDatabase: Set the new active server database.
* This method should be called when switching to another server.
* @param {string} serverUrl
* @returns {Promise<void>}
*/
public setActiveServerDatabase = async (serverUrl: string): Promise<void> => {
if (this.appDatabase?.database) {
const database = this.appDatabase?.database;
await database.write(async () => {
const servers = await database.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch();
if (servers.length) {
servers[0].update((server: ServersModel) => {
server.lastActiveAt = Date.now();
});
}
});
}
};
public getServerUrlFromIdentifier = async (identifier: string): Promise<string|undefined> => {
const database = this.appDatabase?.database;
if (database) {
const server = await queryServerByIdentifier(database, identifier);
return server?.url;
}
/**
* deleteServerDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android.
* Also, it sets the last_active_at to '0' entry in the 'servers' table from the APP database
* @param {string} serverUrl
* @returns {Promise<boolean>}
*/
public deleteServerDatabase = async (serverUrl: string): Promise<void> => {
if (this.appDatabase?.database) {
const database = this.appDatabase?.database;
const server = await queryServer(database, serverUrl);
if (server) {
database.write(async () => {
await server.update((record) => {
record.lastActiveAt = 0;
});
});
return undefined;
}
delete this.serverDatabases[serverUrl];
this.deleteServerDatabaseFiles(serverUrl);
}
}
}
/**
* getActiveServerDatabase: Get the record for active server database.
* @returns {Promise<Database|undefined>}
*/
public getActiveServerDatabase = async (): Promise<Database|undefined> => {
const database = this.appDatabase?.database;
if (database) {
const server = await queryActiveServer(database);
if (server?.url) {
return this.serverDatabases[server.url]?.database;
}
}
/**
* destroyServerDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android.
* Also, removes the entry in the 'servers' table from the APP database
* @param {string} serverUrl
* @returns {Promise<boolean>}
*/
public destroyServerDatabase = async (serverUrl: string): Promise<void> => {
if (this.appDatabase?.database) {
const database = this.appDatabase?.database;
const server = await queryServer(database, serverUrl);
if (server) {
database.write(async () => {
await server.destroyPermanently();
});
return undefined;
}
delete this.serverDatabases[serverUrl];
this.deleteServerDatabaseFiles(serverUrl);
}
}
}
/**
* setActiveServerDatabase: Set the new active server database.
* This method should be called when switching to another server.
* @param {string} serverUrl
* @returns {Promise<void>}
*/
public setActiveServerDatabase = async (serverUrl: string): Promise<void> => {
if (this.appDatabase?.database) {
const database = this.appDatabase?.database;
await database.write(async () => {
const servers = await database.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch();
if (servers.length) {
servers[0].update((server: ServersModel) => {
server.lastActiveAt = Date.now();
});
}
});
}
};
/**
* deleteServerDatabaseFiles: Removes the *.db file from the App-Group directory for iOS or the files directory on Android.
* @param {string} serverUrl
* @returns {Promise<void>}
*/
private deleteServerDatabaseFiles = async (serverUrl: string): Promise<void> => {
const databaseName = hashCode(serverUrl);
/**
* deleteServerDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android.
* Also, it sets the last_active_at to '0' entry in the 'servers' table from the APP database
* @param {string} serverUrl
* @returns {Promise<boolean>}
*/
public deleteServerDatabase = async (serverUrl: string): Promise<void> => {
if (this.appDatabase?.database) {
const database = this.appDatabase?.database;
const server = await queryServer(database, serverUrl);
if (server) {
database.write(async () => {
await server.update((record) => {
record.lastActiveAt = 0;
record.identifier = '';
});
});
if (Platform.OS === 'ios') {
// On iOS, we'll delete the *.db file under the shared app-group/databases folder
deleteIOSDatabase({databaseName});
return;
}
delete this.serverDatabases[serverUrl];
this.deleteServerDatabaseFiles(serverUrl);
}
}
}
// On Android, we'll delete both the *.db file and the *.db-journal file
const androidFilesDir = `${this.databaseDirectory}databases/`;
const databaseFile = `${androidFilesDir}${databaseName}.db`;
const databaseJournal = `${androidFilesDir}${databaseName}.db-journal`;
/**
* destroyServerDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android.
* Also, removes the entry in the 'servers' table from the APP database
* @param {string} serverUrl
* @returns {Promise<boolean>}
*/
public destroyServerDatabase = async (serverUrl: string): Promise<void> => {
if (this.appDatabase?.database) {
const database = this.appDatabase?.database;
const server = await queryServer(database, serverUrl);
if (server) {
database.write(async () => {
await server.destroyPermanently();
});
await FileSystem.deleteAsync(databaseFile);
await FileSystem.deleteAsync(databaseJournal);
}
delete this.serverDatabases[serverUrl];
this.deleteServerDatabaseFiles(serverUrl);
}
}
}
/**
* factoryReset: Removes the databases directory and all its contents on the respective platform
* @param {boolean} shouldRemoveDirectory
* @returns {Promise<boolean>}
*/
factoryReset = async (shouldRemoveDirectory: boolean): Promise<boolean> => {
try {
//On iOS, we'll delete the databases folder under the shared AppGroup folder
if (Platform.OS === 'ios') {
deleteIOSDatabase({shouldRemoveDirectory});
return true;
}
/**
* deleteServerDatabaseFiles: Removes the *.db file from the App-Group directory for iOS or the files directory on Android.
* @param {string} serverUrl
* @returns {Promise<void>}
*/
private deleteServerDatabaseFiles = async (serverUrl: string): Promise<void> => {
const databaseName = hashCode(serverUrl);
// On Android, we'll remove the databases folder under the Document Directory
const androidFilesDir = `${this.databaseDirectory}databases/`;
await FileSystem.deleteAsync(androidFilesDir);
return true;
} catch (e) {
return false;
}
};
if (Platform.OS === 'ios') {
// On iOS, we'll delete the *.db file under the shared app-group/databases folder
deleteIOSDatabase({databaseName});
return;
}
/**
* buildMigrationCallbacks: Creates a set of callbacks that can be used to monitor the migration process.
* For example, we can display a processing spinner while we have a migration going on. Moreover, we can also
* hook into those callbacks to assess how many of our servers successfully completed their migration.
* @param {string} dbName
* @returns {MigrationEvents}
*/
private buildMigrationCallbacks = (dbName: string) => {
const migrationEvents = {
onSuccess: () => {
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_SUCCESS, {
dbName,
});
},
onStart: () => {
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_STARTED, {
dbName,
});
},
onError: (error: Error) => {
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_ERROR, {
dbName,
error,
});
},
};
// On Android, we'll delete the *.db, the *.db-shm and *.db-wal files
const androidFilesDir = this.databaseDirectory;
const databaseFile = `${androidFilesDir}${databaseName}.db`;
const databaseShm = `${androidFilesDir}${databaseName}.db-shm`;
const databaseWal = `${androidFilesDir}${databaseName}.db-wal`;
return migrationEvents;
};
FileSystem.deleteAsync(databaseFile);
FileSystem.deleteAsync(databaseShm);
FileSystem.deleteAsync(databaseWal);
}
/**
* getDatabaseFilePath: Using the database name, this method will return the database file path for each platform.
* On iOS, it will point towards the AppGroup shared directory while on Android, it will point towards the Files Directory.
* Please note that in each case, the *.db files will be created/grouped under a 'databases' sub-folder.
* iOS Simulator : appGroup => /Users/{username}/Library/Developer/CoreSimulator/Devices/DA6F1C73/data/Containers/Shared/AppGroup/ACA65327/databases"}
* Android Device: file:///data/user/0/com.mattermost.rnbeta/files/databases
*
* @param {string} dbName
* @returns {string}
*/
private getDatabaseFilePath = (dbName: string): string => {
return Platform.OS === 'ios' ? `${this.databaseDirectory}/${dbName}.db` : `${this.databaseDirectory}${dbName}.db`;
};
/**
* factoryReset: Removes the databases directory and all its contents on the respective platform
* @param {boolean} shouldRemoveDirectory
* @returns {Promise<boolean>}
*/
factoryReset = async (shouldRemoveDirectory: boolean): Promise<boolean> => {
try {
//On iOS, we'll delete the databases folder under the shared AppGroup folder
if (Platform.OS === 'ios') {
deleteIOSDatabase({shouldRemoveDirectory});
return true;
}
// On Android, we'll remove the databases folder under the Document Directory
const androidFilesDir = `${this.databaseDirectory}databases/`;
await FileSystem.deleteAsync(androidFilesDir);
return true;
} catch (e) {
return false;
}
};
/**
* buildMigrationCallbacks: Creates a set of callbacks that can be used to monitor the migration process.
* For example, we can display a processing spinner while we have a migration going on. Moreover, we can also
* hook into those callbacks to assess how many of our servers successfully completed their migration.
* @param {string} dbName
* @returns {MigrationEvents}
*/
private buildMigrationCallbacks = (dbName: string) => {
const migrationEvents = {
onSuccess: () => {
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_SUCCESS, {
dbName,
});
},
onStart: () => {
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_STARTED, {
dbName,
});
},
onError: (error: Error) => {
return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_ERROR, {
dbName,
error,
});
},
};
return migrationEvents;
};
/**
* getDatabaseFilePath: Using the database name, this method will return the database file path for each platform.
* On iOS, it will point towards the AppGroup shared directory while on Android, it will point towards the Files Directory.
* Please note that in each case, the *.db files will be created/grouped under a 'databases' sub-folder.
* iOS Simulator : appGroup => /Users/{username}/Library/Developer/CoreSimulator/Devices/DA6F1C73/data/Containers/Shared/AppGroup/ACA65327/databases"}
* Android Device: file:///data/user/0/com.mattermost.rnbeta/files/databases
*
* @param {string} dbName
* @returns {string}
*/
private getDatabaseFilePath = (dbName: string): string => {
return Platform.OS === 'ios' ? `${this.databaseDirectory}/${dbName}.db` : `${this.databaseDirectory}${dbName}.db`;
};
}
if (!__DEV__) {

View File

@@ -19,6 +19,8 @@ describe('*** Database Manager tests ***', () => {
const serverUrls = ['https://appv1.mattermost.com', 'https://appv2.mattermost.com'];
beforeAll(async () => {
await DatabaseManager.init(serverUrls);
await DatabaseManager.updateServerIdentifier(serverUrls[0], 'appv1');
await DatabaseManager.updateServerIdentifier(serverUrls[1], 'appv2');
});
it('=> should return a default database', async () => {
@@ -37,8 +39,9 @@ describe('*** Database Manager tests ***', () => {
const connection1 = await DatabaseManager!.createServerDatabase({
config: {
dbName: 'community mattermost',
serverUrl: 'https://appv1.mattermost.com',
dbName: 'appv3 mattermost',
serverUrl: 'https://appv3.mattermost.com',
identifier: 'appv3',
},
});
@@ -84,4 +87,11 @@ describe('*** Database Manager tests ***', () => {
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
expect(activeServerUrl).toEqual(serverUrls[1]);
});
it('=> should return appv3 server url from the servers table of App database', async () => {
expect.assertions(1);
const serverUrl = await DatabaseManager.getServerUrlFromIdentifier('appv3');
expect(serverUrl).toBe('https://appv3.mattermost.com');
});
});

View File

@@ -34,6 +34,7 @@ export default async () => {
dbName: 'community mattermost',
dbType: DatabaseType.SERVER,
serverUrl: 'https://comm4.mattermost.com',
identifier: 'test-server',
},
});
};

View File

@@ -22,18 +22,12 @@ export default class ServersModel extends Model {
/** display_name : The server display name */
@field('display_name') displayName!: string;
/** mention_count : The number of mention on this server */
@field('mention_count') mentionCount!: number;
/** unread_count : The number of unread messages on this server */
@field('unread_count') unreadCount!: number;
/** url : The online address for the Mattermost server */
@field('url') url!: string;
/** last_active_at: The last time this server was active */
@field('last_active_at') lastActiveAt!: number;
/** is_secured: Determines if the protocol used for this server url is HTTP or HTTPS */
@field('is_secured') isSecured!: boolean;
/** identifier: Determines the installation identifier of a server */
@field('identifier') identifier!: string;
}

View File

@@ -98,7 +98,7 @@ export default class ChannelModel extends Model {
@field('team_id') teamId!: string;
/** type : The type of the channel ( e.g. G: group messages, D: direct messages, P: private channel and O: public channel) */
@field('type') type!: string;
@field('type') type!: ChannelType;
/** members : Users belonging to this channel */
@children(CHANNEL_MEMBERSHIP) members!: ChannelMembershipModel[];
@@ -130,4 +130,26 @@ export default class ChannelModel extends Model {
/** settings: User specific settings/preferences for this channel */
@immutableRelation(MY_CHANNEL_SETTINGS, 'id') settings!: Relation<MyChannelSettingsModel>;
toApi = (): Channel => {
return {
id: this.id,
create_at: this.createAt,
update_at: this.updateAt,
delete_at: this.deleteAt,
team_id: this.teamId,
type: this.type,
display_name: this.displayName,
name: this.name,
header: '',
purpose: '',
last_post_at: 0,
total_msg_count: 0,
extra_update_at: 0,
creator_id: this.creatorId,
scheme_id: null,
group_constrained: null,
shared: this.shared,
};
};
}

View File

@@ -40,6 +40,9 @@ export default class MyChannelModel extends Model {
/** roles : The user's privileges on this channel */
@field('roles') roles!: string;
/** viewed_at : The timestamp showing when the user's last opened this channel (this is used for the new line message indicator) */
@field('viewed_at') viewedAt!: number;
/** channel : The relation pointing to the CHANNEL table */
@immutableRelation(CHANNEL, 'id') channel!: Relation<ChannelModel>;
}

View File

@@ -128,6 +128,7 @@ export const transformMyChannelRecord = ({action, database, value}: TransformerA
myChannel.mentionsCount = raw.mention_count;
myChannel.lastPostAt = raw.last_post_at || 0;
myChannel.lastViewedAt = raw.last_viewed_at;
myChannel.viewedAt = record?.viewedAt || 0;
};
return prepareBaseRecord({

View File

@@ -50,7 +50,7 @@ export const createPostsChain = ({order, posts, previousPostId = ''}: ChainPosts
}
return result;
}, [] as Post[]);
}, [] as Post[]).reverse();
};
export const getPostListEdges = (posts: Post[]) => {

View File

@@ -29,11 +29,9 @@ export const schema: AppSchema = appSchema({
columns: [
{name: 'db_path', type: 'string'},
{name: 'display_name', type: 'string'},
{name: 'mention_count', type: 'number'},
{name: 'unread_count', type: 'number'},
{name: 'url', type: 'string', isIndexed: true},
{name: 'last_active_at', type: 'number', isIndexed: true},
{name: 'is_secured', type: 'boolean'},
{name: 'identifier', type: 'string', isIndexed: true},
],
}),
],

View File

@@ -12,10 +12,8 @@ export default tableSchema({
columns: [
{name: 'db_path', type: 'string'},
{name: 'display_name', type: 'string'},
{name: 'mention_count', type: 'number'},
{name: 'unread_count', type: 'number'},
{name: 'url', type: 'string', isIndexed: true},
{name: 'last_active_at', type: 'number', isIndexed: true},
{name: 'is_secured', type: 'boolean'},
{name: 'identifier', type: 'string', isIndexed: true},
],
});

View File

@@ -7,8 +7,8 @@ import {schema} from './index';
const {INFO, GLOBAL, SERVERS} = MM_TABLES.APP;
describe('*** Test schema for DEFAULT database ***', () => {
it('=> The DEFAULT SCHEMA should strictly match', () => {
describe('*** Test schema for APP database ***', () => {
it('=> The APP SCHEMA should strictly match', () => {
expect(schema).toEqual({
version: 1,
tables: {
@@ -39,20 +39,16 @@ describe('*** Test schema for DEFAULT database ***', () => {
columns: {
db_path: {name: 'db_path', type: 'string'},
display_name: {name: 'display_name', type: 'string'},
mention_count: {name: 'mention_count', type: 'number'},
unread_count: {name: 'unread_count', type: 'number'},
url: {name: 'url', type: 'string', isIndexed: true},
last_active_at: {name: 'last_active_at', type: 'number', isIndexed: true},
is_secured: {name: 'is_secured', type: 'boolean'},
identifier: {name: 'identifier', type: 'string', isIndexed: true},
},
columnArray: [
{name: 'db_path', type: 'string'},
{name: 'display_name', type: 'string'},
{name: 'mention_count', type: 'number'},
{name: 'unread_count', type: 'number'},
{name: 'url', type: 'string', isIndexed: true},
{name: 'last_active_at', type: 'number', isIndexed: true},
{name: 'is_secured', type: 'boolean'},
{name: 'identifier', type: 'string', isIndexed: true},
],
},
},

View File

@@ -16,5 +16,6 @@ export default tableSchema({
{name: 'mentions_count', type: 'number'},
{name: 'message_count', type: 'number'},
{name: 'roles', type: 'string'},
{name: 'viewed_at', type: 'number'},
],
});

View File

@@ -117,6 +117,7 @@ describe('*** Test schema for SERVER database ***', () => {
mentions_count: {name: 'mentions_count', type: 'number'},
message_count: {name: 'message_count', type: 'number'},
roles: {name: 'roles', type: 'string'},
viewed_at: {name: 'viewed_at', type: 'number'},
},
columnArray: [
{name: 'last_post_at', type: 'number'},
@@ -125,6 +126,7 @@ describe('*** Test schema for SERVER database ***', () => {
{name: 'mentions_count', type: 'number'},
{name: 'message_count', type: 'number'},
{name: 'roles', type: 'string'},
{name: 'viewed_at', type: 'number'},
],
},
[MY_CHANNEL_SETTINGS]: {

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {NativeModules, useWindowDimensions} from 'react-native';
import {AppState, NativeModules, useWindowDimensions} from 'react-native';
import {Device} from '@constants';
@@ -23,3 +23,22 @@ export function useSplitView() {
return isSplitView;
}
export function useAppState() {
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const listener = AppState.addEventListener('change', (nextState) => {
setAppState(nextState);
});
return () => listener.remove();
}, [appState]);
return appState;
}
export function useIsTablet() {
const isSplitView = useSplitView();
return Device.IS_TABLET && !isSplitView;
}

View File

@@ -5,6 +5,7 @@ import CookieManager, {Cookie} from '@react-native-cookies/cookies';
import {Alert, DeviceEventEmitter, Linking, Platform} from 'react-native';
import semver from 'semver';
import {selectAllMyChannelIds} from '@actions/local/channel';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import LocalConfig from '@assets/config.json';
import {General, REDIRECT_URL_SCHEME, REDIRECT_URL_SCHEME_DEV} from '@constants';
@@ -84,6 +85,8 @@ class GlobalEventHandler {
onLogout = async (serverUrl: string) => {
await removeServerCredentials(serverUrl);
const channelIds = await selectAllMyChannelIds(serverUrl);
PushNotifications.cancelChannelsNotifications(channelIds);
NetworkManager.invalidateClient(serverUrl);
WebsocketManager.invalidateClient(serverUrl);
@@ -95,20 +98,14 @@ class GlobalEventHandler {
analytics.invalidate(serverUrl);
}
deleteFileCache(serverUrl);
PushNotifications.clearNotifications(serverUrl);
this.resetLocale();
this.clearCookiesForServer(serverUrl);
deleteFileCache(serverUrl);
relaunchApp({launchType: LaunchType.Normal}, true);
};
onServerConfigChanged = ({serverUrl, config}: {serverUrl: string; config: ClientConfig}) => {
this.configureAnalytics(serverUrl, config);
if (config.ExtendSessionLengthWithActivity === 'true') {
PushNotifications.cancelAllLocalNotifications();
}
};
onServerVersionChanged = async ({serverUrl, serverVersion}: {serverUrl: string; serverVersion?: string}) => {

View File

@@ -2,10 +2,10 @@
// See LICENSE.txt for license information.
import Emm from '@mattermost/react-native-emm';
import {Alert, Linking} from 'react-native';
import {Alert, Linking, Platform} from 'react-native';
import {Notifications} from 'react-native-notifications';
import {appEntry, upgradeEntry} from '@actions/remote/entry';
import {appEntry, pushNotificationEntry, upgradeEntry} from '@actions/remote/entry';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials';
@@ -14,6 +14,7 @@ import {queryCurrentUserId} from '@queries/servers/system';
import {goToScreen, resetToHome, resetToSelectServer} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkType, DeepLinkWithData, LaunchProps, LaunchType} from '@typings/launch';
import {convertToNotificationData} from '@utils/notification';
import {parseDeepLink} from '@utils/url';
export const initialLaunch = async () => {
@@ -24,8 +25,18 @@ export const initialLaunch = async () => {
}
const notification = await Notifications.getInitialNotification();
if (notification && notification.payload?.type === 'message') {
launchAppFromNotification(notification);
let tapped = Platform.select({android: true, ios: false})!;
if (Platform.OS === 'ios') {
// when a notification is received on iOS, getInitialNotification, will return the notification
// as the app will initialized cause we are using background fetch,
// that does not necessarily mean that the app was opened cause of the notification was tapped.
// Here we are going to dettermine if the notification still exists in NotificationCenter to determine if
// the app was opened because of a tap or cause of the background fetch init
const delivered = await Notifications.ios.getDeliveredNotifications();
tapped = delivered.find((d) => (d as unknown as NotificationData).ack_id === notification?.payload.ack_id) == null;
}
if (notification?.payload?.type === 'message' && tapped) {
launchAppFromNotification(convertToNotificationData(notification));
return;
}
@@ -37,8 +48,8 @@ const launchAppFromDeepLink = (deepLinkUrl: string) => {
launchApp(props);
};
const launchAppFromNotification = (notification: NotificationWithData) => {
const props = getLaunchPropsFromNotification(notification);
const launchAppFromNotification = async (notification: NotificationWithData) => {
const props = await getLaunchPropsFromNotification(notification);
launchApp(props);
};
@@ -52,8 +63,7 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => {
}
break;
case LaunchType.Notification: {
const extra = props.extra as NotificationWithData;
serverUrl = extra.payload?.server_url;
serverUrl = props.serverUrl;
break;
}
default:
@@ -76,8 +86,13 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => {
hasCurrentUser = Boolean(currentUserId);
}
let launchType = props.launchType;
if (!hasCurrentUser) {
// migrating from v1
if (launchType === LaunchType.Normal) {
launchType = LaunchType.Upgrade;
}
const result = await upgradeEntry(serverUrl);
if (result.error) {
Alert.alert(
@@ -96,7 +111,7 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => {
}
}
launchToHome({...props, launchType: hasCurrentUser ? LaunchType.Normal : LaunchType.Upgrade, serverUrl}, resetNavigation);
launchToHome({...props, launchType, serverUrl});
return;
}
}
@@ -104,15 +119,22 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => {
launchToServer(props, resetNavigation);
};
const launchToHome = (props: LaunchProps, resetNavigation: Boolean) => {
const launchToHome = async (props: LaunchProps) => {
let openPushNotification = false;
switch (props.launchType) {
case LaunchType.DeepLink:
// TODO:
// deepLinkEntry({props.serverUrl, props.extra});
break;
case LaunchType.Notification: {
// TODO:
// pushNotificationEntry({props.serverUrl, props.extra})
const extra = props.extra as NotificationWithData;
openPushNotification = Boolean(props.serverUrl && !props.launchError && extra.userInteraction && extra.payload?.channel_id && !extra.payload?.userInfo?.local);
if (openPushNotification) {
pushNotificationEntry(props.serverUrl!, extra);
} else {
appEntry(props.serverUrl!);
}
break;
}
case LaunchType.Normal:
@@ -125,15 +147,9 @@ const launchToHome = (props: LaunchProps, resetNavigation: Boolean) => {
...props,
};
if (resetNavigation) {
// eslint-disable-next-line no-console
console.log('Launch app in Home screen');
resetToHome(passProps);
return;
}
const title = '';
goToScreen(Screens.HOME, title, passProps);
// eslint-disable-next-line no-console
console.log('Launch app in Home screen');
resetToHome(passProps);
};
const launchToServer = (props: LaunchProps, resetNavigation: Boolean) => {
@@ -185,15 +201,23 @@ export const getLaunchPropsFromDeepLink = (deepLinkUrl: string): LaunchProps =>
return launchProps;
};
export const getLaunchPropsFromNotification = (notification: NotificationWithData): LaunchProps => {
const {payload} = notification;
export const getLaunchPropsFromNotification = async (notification: NotificationWithData): Promise<LaunchProps> => {
const launchProps: LaunchProps = {
launchType: LaunchType.Notification,
};
const {payload} = notification;
(launchProps.extra as NotificationWithData) = notification;
if (payload?.server_url) {
(launchProps.extra as NotificationWithData) = notification;
launchProps.serverUrl = payload.server_url;
} else if (payload?.server_id) {
const serverUrl = await DatabaseManager.getServerUrlFromIdentifier(payload.server_id);
if (serverUrl) {
launchProps.serverUrl = serverUrl;
} else {
launchProps.launchError = true;
}
} else {
launchProps.launchError = true;
}

View File

@@ -14,15 +14,21 @@ import {
Registered,
} from 'react-native-notifications';
import {Device, General, Navigation} from '@constants';
import {markChannelAsViewed} from '@actions/local/channel';
import {backgroundNotification, openNotification} from '@actions/remote/notifications';
import EphemeralStore from '@app/store/ephemeral_store';
import {Device, General, Navigation, Screens} from '@constants';
import {GLOBAL_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
import {getLaunchPropsFromNotification, relaunchApp} from '@init/launch';
import NativeNotifications from '@notifications';
import {queryMentionCount} from '@queries/app/global';
import {queryServerName} from '@queries/app/servers';
import {queryCurrentChannelId} from '@queries/servers/system';
import {showOverlay} from '@screens/navigation';
import {isTablet} from '@utils/helpers';
import {convertToNotificationData} from '@utils/notification';
import {getActiveServerUrl} from './credentials';
const CATEGORY = 'CAN_REPLY';
const REPLY_ACTION = 'REPLY_ACTION';
@@ -33,203 +39,257 @@ const NOTIFICATION_TYPE = {
};
class PushNotifications {
configured = false;
configured = false;
init() {
Notifications.registerRemoteNotifications();
Notifications.events().registerNotificationOpened(this.onNotificationOpened);
Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered);
Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground);
Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground);
}
init() {
Notifications.registerRemoteNotifications();
Notifications.events().registerNotificationOpened(this.onNotificationOpened);
Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered);
Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground);
Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground);
}
cancelAllLocalNotifications = () => {
Notifications.cancelAllLocalNotifications();
};
cancelAllLocalNotifications = () => {
Notifications.cancelAllLocalNotifications();
};
clearNotifications = (serverUrl: string) => {
// TODO Notifications: Only cancel the local notifications that belong to this server
cancelChannelNotifications = async (channelId: string) => {
const notifications = await NativeNotifications.getDeliveredNotifications();
this.cancelNotificationsForChannel(notifications, channelId);
};
// eslint-disable-next-line no-console
console.log('Clear notifications for server', serverUrl);
this.cancelAllLocalNotifications();
cancelChannelsNotifications = async (channelIds: string[]) => {
const notifications = await NativeNotifications.getDeliveredNotifications();
for (const channelId of channelIds) {
this.cancelNotificationsForChannel(notifications, channelId);
}
};
if (Platform.OS === 'ios') {
// TODO Notifications: Set the badge number to the total amount of mentions on other servers
Notifications.ios.setBadgeCount(0);
}
};
cancelNotificationsForChannel = (notifications: NotificationWithChannel[], channelId: string) => {
if (Platform.OS === 'android') {
NativeNotifications.removeDeliveredNotifications(channelId);
} else {
const ids: string[] = [];
let badgeCount = notifications.length;
clearChannelNotifications = async (channelId: string) => {
const notifications = await NativeNotifications.getDeliveredNotifications();
if (Platform.OS === 'android') {
const notificationForChannel = notifications.find(
(n: NotificationWithChannel) => n.channel_id === channelId,
);
if (notificationForChannel) {
NativeNotifications.removeDeliveredNotifications(
notificationForChannel.identifier,
channelId,
);
}
} else {
const ids: string[] = [];
let badgeCount = notifications.length;
for (const notification of notifications) {
if (notification.channel_id === channelId) {
ids.push(notification.identifier);
badgeCount--;
}
}
for (const notification of notifications) {
if (notification.channel_id === channelId) {
ids.push(notification.identifier);
badgeCount--;
}
}
// TODO: Set the badgeCount with databases mention count aggregate ??
// or should we use the badge count from the icon?
// Set the badgeCount with default database mention count aggregate
const appDatabase = DatabaseManager.appDatabase?.database;
if (appDatabase) {
const mentions = await queryMentionCount(appDatabase);
if (mentions) {
badgeCount = parseInt(mentions, 10);
}
}
if (ids.length) {
NativeNotifications.removeDeliveredNotifications(ids);
}
if (ids.length) {
NativeNotifications.removeDeliveredNotifications(ids);
}
if (Platform.OS === 'ios') {
badgeCount = badgeCount <= 0 ? 0 : badgeCount;
Notifications.ios.setBadgeCount(badgeCount);
}
}
}
if (Platform.OS === 'ios') {
badgeCount = badgeCount <= 0 ? 0 : badgeCount;
Notifications.ios.setBadgeCount(badgeCount);
}
}
};
createReplyCategory = () => {
const replyTitle = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.title'));
const replyButton = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.button'));
const replyPlaceholder = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.placeholder'));
const replyTextInput: NotificationTextInput = {buttonTitle: replyButton, placeholder: replyPlaceholder};
const replyAction = new NotificationAction(REPLY_ACTION, 'background', replyTitle, true, replyTextInput);
return new NotificationCategory(CATEGORY, [replyAction]);
};
createReplyCategory = () => {
const replyTitle = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.title'));
const replyButton = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.button'));
const replyPlaceholder = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.placeholder'));
const replyTextInput: NotificationTextInput = {buttonTitle: replyButton, placeholder: replyPlaceholder};
const replyAction = new NotificationAction(REPLY_ACTION, 'background', replyTitle, true, replyTextInput);
return new NotificationCategory(CATEGORY, [replyAction]);
};
getServerUrlFromNotification = async (notification: NotificationWithData) => {
const {payload} = notification;
handleNotification = async (notification: NotificationWithData) => {
const {payload, foreground, userInteraction} = notification;
if (!payload?.channel_id && (!payload?.server_url || !payload.server_id)) {
return undefined;
}
if (payload) {
switch (payload.type) {
case NOTIFICATION_TYPE.CLEAR:
// TODO Notifications: Mark the channel as read
break;
case NOTIFICATION_TYPE.MESSAGE:
// TODO Notifications: fetch the posts for the channel
let serverUrl = payload.server_url;
if (!serverUrl && payload.server_id) {
serverUrl = await DatabaseManager.getServerUrlFromIdentifier(payload.server_id);
}
if (foreground) {
this.handleInAppNotification(notification);
} else if (userInteraction && !payload.userInfo?.local) {
const props = getLaunchPropsFromNotification(notification);
relaunchApp(props, true);
}
break;
case NOTIFICATION_TYPE.SESSION:
// eslint-disable-next-line no-console
console.log('Session expired notification');
return serverUrl;
}
if (payload.server_url) {
DeviceEventEmitter.emit(General.SERVER_LOGOUT, payload.server_url);
}
break;
}
}
};
handleClearNotification = async (notification: NotificationWithData) => {
const {payload} = notification;
const serverUrl = await this.getServerUrlFromNotification(notification);
handleInAppNotification = async (notification: NotificationWithData) => {
const {payload} = notification;
if (serverUrl && payload?.channel_id) {
markChannelAsViewed(serverUrl, payload?.channel_id, false);
}
}
if (payload?.server_url) {
const database = DatabaseManager.serverDatabases[payload.server_url]?.database;
const channelId = await queryCurrentChannelId(database);
handleInAppNotification = async (serverUrl: string, notification: NotificationWithData) => {
const {payload} = notification;
if (channelId && payload.channel_id !== channelId) {
const screen = 'Notification';
const passProps = {
notification,
};
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (database) {
const isTabletDevice = await isTablet();
const activeServerUrl = await getActiveServerUrl();
const displayName = await queryServerName(DatabaseManager.appDatabase!.database, serverUrl);
const channelId = await queryCurrentChannelId(database);
let serverName;
if (serverUrl !== activeServerUrl && Object.keys(DatabaseManager.serverDatabases).length > 1) {
serverName = displayName;
}
DeviceEventEmitter.emit(Navigation.NAVIGATION_SHOW_OVERLAY);
showOverlay(screen, passProps);
}
}
};
const isDifferentChannel = payload?.channel_id !== channelId;
let isChannelScreenVisible = EphemeralStore.getNavigationTopComponentId() === Screens.CHANNEL;
if (isTabletDevice) {
isChannelScreenVisible = EphemeralStore.getVisibleTab() === Screens.HOME;
}
localNotification = (notification: Notification) => {
Notifications.postLocalNotification(notification);
};
if (isDifferentChannel || !isChannelScreenVisible) {
DeviceEventEmitter.emit(Navigation.NAVIGATION_SHOW_OVERLAY);
onNotificationOpened = (notification: NotificationWithData, completion: () => void) => {
notification.userInteraction = true;
this.handleNotification(notification);
completion();
};
const screen = Screens.IN_APP_NOTIFICATION;
const passProps = {
notification,
overlay: true,
serverName,
serverUrl,
};
onNotificationReceivedBackground = (notification: NotificationWithData, completion: (response: NotificationBackgroundFetchResult) => void) => {
this.handleNotification(notification);
completion(NotificationBackgroundFetchResult.NO_DATA);
};
showOverlay(screen, passProps);
}
}
};
onNotificationReceivedForeground = (notification: NotificationWithData, completion: (response: NotificationCompletion) => void) => {
notification.foreground = AppState.currentState === 'active';
completion({alert: false, sound: true, badge: true});
this.handleNotification(notification);
};
handleMessageNotification = async (notification: NotificationWithData) => {
const {payload, foreground, userInteraction} = notification;
const serverUrl = await this.getServerUrlFromNotification(notification);
onRemoteNotificationsRegistered = async (event: Registered) => {
if (!this.configured) {
this.configured = true;
const {deviceToken} = event;
let prefix;
if (serverUrl) {
if (foreground) {
// Move this to a local action
this.handleInAppNotification(serverUrl, notification);
} else if (userInteraction && !payload?.userInfo?.local) {
// Handle notification tapped
openNotification(serverUrl, notification);
this.cancelChannelNotifications(notification.payload!.channel_id);
} else {
backgroundNotification(serverUrl, notification);
}
}
}
if (Platform.OS === 'ios') {
prefix = Device.PUSH_NOTIFY_APPLE_REACT_NATIVE;
if (DeviceInfo.getBundleId().includes('rnbeta')) {
prefix = `${prefix}beta`;
}
} else {
prefix = Device.PUSH_NOTIFY_ANDROID_REACT_NATIVE;
}
handleSessionNotification = async (notification: NotificationWithData) => {
// eslint-disable-next-line no-console
console.log('Session expired notification');
const operator = DatabaseManager.appDatabase?.operator;
const serverUrl = await this.getServerUrlFromNotification(notification);
if (!operator) {
return {error: 'No App database found'};
}
if (serverUrl) {
DeviceEventEmitter.emit(General.SERVER_LOGOUT, serverUrl);
}
}
operator.handleGlobal({
global: [{id: GLOBAL_IDENTIFIERS.DEVICE_TOKEN, value: `${prefix}:${deviceToken}`}],
prepareRecordsOnly: false,
});
processNotification = async (notification: NotificationWithData) => {
const {payload} = notification;
// Store the device token in the default database
this.requestNotificationReplyPermissions();
}
return null;
};
if (payload) {
switch (payload.type) {
case NOTIFICATION_TYPE.CLEAR:
this.handleClearNotification(notification);
break;
case NOTIFICATION_TYPE.MESSAGE:
this.handleMessageNotification(notification);
break;
case NOTIFICATION_TYPE.SESSION:
this.handleSessionNotification(notification);
break;
}
}
};
requestNotificationReplyPermissions = () => {
if (Platform.OS === 'ios') {
const replyCategory = this.createReplyCategory();
Notifications.setCategories([replyCategory]);
}
};
localNotification = (notification: Notification) => {
Notifications.postLocalNotification(notification);
};
scheduleNotification = (notification: Notification) => {
if (notification.fireDate) {
if (Platform.OS === 'ios') {
notification.fireDate = new Date(notification.fireDate).toISOString();
}
// This triggers when a notification is tapped and the app was in the background (iOS)
onNotificationOpened = (incoming: Notification, completion: () => void) => {
const notification = convertToNotificationData(incoming, false);
notification.userInteraction = true;
Notifications.postLocalNotification(notification);
}
};
this.processNotification(notification);
completion();
};
// This triggers when the app was in the background (iOS)
onNotificationReceivedBackground = async (incoming: Notification, completion: (response: NotificationBackgroundFetchResult) => void) => {
const notification = convertToNotificationData(incoming, false);
this.processNotification(notification);
completion(NotificationBackgroundFetchResult.NEW_DATA);
};
// This triggers when the app was in the foreground (Android and iOS)
// Also triggers when the app was in the background (Android)
onNotificationReceivedForeground = (incoming: Notification, completion: (response: NotificationCompletion) => void) => {
const notification = convertToNotificationData(incoming, false);
if (AppState.currentState !== 'inactive') {
notification.foreground = AppState.currentState === 'active';
this.processNotification(notification);
}
completion({alert: false, sound: true, badge: true});
};
onRemoteNotificationsRegistered = async (event: Registered) => {
if (!this.configured) {
this.configured = true;
const {deviceToken} = event;
let prefix;
if (Platform.OS === 'ios') {
prefix = Device.PUSH_NOTIFY_APPLE_REACT_NATIVE;
if (DeviceInfo.getBundleId().includes('rnbeta')) {
prefix = `${prefix}beta`;
}
} else {
prefix = Device.PUSH_NOTIFY_ANDROID_REACT_NATIVE;
}
const operator = DatabaseManager.appDatabase?.operator;
if (!operator) {
return {error: 'No App database found'};
}
operator.handleGlobal({
global: [{id: GLOBAL_IDENTIFIERS.DEVICE_TOKEN, value: `${prefix}:${deviceToken}`}],
prepareRecordsOnly: false,
});
// Store the device token in the default database
this.requestNotificationReplyPermissions();
}
return null;
};
requestNotificationReplyPermissions = () => {
if (Platform.OS === 'ios') {
const replyCategory = this.createReplyCategory();
Notifications.setCategories([replyCategory]);
}
};
scheduleNotification = (notification: Notification) => {
if (notification.fireDate) {
if (Platform.OS === 'ios') {
notification.fireDate = new Date(notification.fireDate).toISOString();
}
Notifications.postLocalNotification(notification);
}
};
}
export default new PushNotifications();

View File

@@ -9,7 +9,7 @@ import {fetchStatusByIds} from '@actions/remote/user';
import {handleClose, handleEvent, handleFirstConnect, handleReconnect} from '@actions/websocket';
import WebSocketClient from '@app/client/websocket';
import {General} from '@app/constants';
import {queryWebSocketLastDisconnected} from '@app/queries/servers/system';
import {queryCurrentUserId, resetWebSocketLastDisconnected} from '@app/queries/servers/system';
import {queryAllUsers} from '@app/queries/servers/user';
import DatabaseManager from '@database/manager';
@@ -30,13 +30,13 @@ class WebsocketManager {
await Promise.all(
serverCredentials.map(
async ({serverUrl, token}) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return;
}
const lastDisconnect = await queryWebSocketLastDisconnected(database);
await resetWebSocketLastDisconnected(operator);
try {
this.createClient(serverUrl, token, lastDisconnect);
this.createClient(serverUrl, token, 0);
} catch (error) {
console.log('WebsocketManager init error', error); //eslint-disable-line no-console
}
@@ -64,7 +64,7 @@ class WebsocketManager {
client.setReconnectCallback(() => this.onReconnect(serverUrl));
client.setCloseCallback((connectFailCount: number, lastDisconnect: number) => this.onWebsocketClose(serverUrl, connectFailCount, lastDisconnect));
if (this.netConnected) {
if (this.netConnected && ['unknown', 'active'].includes(AppState.currentState)) {
client.initialize();
}
this.clients[serverUrl] = client;
@@ -121,9 +121,10 @@ class WebsocketManager {
return;
}
const currentUserId = await queryCurrentUserId(database.database);
const users = await queryAllUsers(database.database);
const userIds = users.map((u) => u.id);
const userIds = users.map((u) => u.id).filter((id) => id !== currentUserId);
if (!userIds.length) {
return;
}

View File

@@ -1,15 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Notifications} from 'react-native-notifications';
/* eslint-disable */
export default {
getDeliveredNotifications: Notifications.ios.getDeliveredNotifications,
getPreferences: async () => null,
play: (soundUri: string) => {},
removeDeliveredNotifications: Notifications.ios.removeDeliveredNotifications,
setNotificationSound: () => {},
setShouldBlink: (shouldBlink: boolean) => {},
setShouldVibrate: (shouldVibrate: boolean) => {},
} as NativeNotification;

View File

@@ -1,40 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {NativeModules, PermissionsAndroid} from 'react-native';
const {NotificationPreferences} = NativeModules;
const defaultPreferences: NativeNotificationPreferences = {
sounds: [],
shouldBlink: false,
shouldVibrate: true,
};
export default {
getDeliveredNotifications: NotificationPreferences.getDeliveredNotifications,
getPreferences: async () => {
try {
const hasPermission = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE);
let granted;
if (!hasPermission) {
granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
);
}
if (hasPermission || granted === PermissionsAndroid.RESULTS.GRANTED) {
return await NotificationPreferences.getPreferences();
}
return defaultPreferences;
} catch (error) {
return defaultPreferences;
}
},
play: NotificationPreferences.previewSound,
removeDeliveredNotifications: NotificationPreferences.removeDeliveredNotifications,
setNotificationSound: NotificationPreferences.setNotificationSound,
setShouldBlink: NotificationPreferences.setShouldBlink,
setShouldVibrate: NotificationPreferences.setShouldVibrate,
} as NativeNotification;
// @ts-expect-error platform specific
export {default} from './notifications';

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {NativeModules, PermissionsAndroid} from 'react-native';
const {NotificationPreferences} = NativeModules;
const defaultPreferences: NativeNotificationPreferences = {
sounds: [],
shouldBlink: false,
shouldVibrate: true,
};
export default {
getDeliveredNotifications: NotificationPreferences.getDeliveredNotifications,
getPreferences: async () => {
try {
const hasPermission = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE);
let granted;
if (!hasPermission) {
granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
);
}
if (hasPermission || granted === PermissionsAndroid.RESULTS.GRANTED) {
return await NotificationPreferences.getPreferences();
}
return defaultPreferences;
} catch (error) {
return defaultPreferences;
}
},
play: NotificationPreferences.previewSound,
removeDeliveredNotifications: NotificationPreferences.removeDeliveredNotifications,
setNotificationSound: NotificationPreferences.setNotificationSound,
setShouldBlink: NotificationPreferences.setShouldBlink,
setShouldVibrate: NotificationPreferences.setShouldVibrate,
} as NativeNotification;

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Notifications} from 'react-native-notifications';
import {emptyFunction} from '@utils/general';
export default {
getDeliveredNotifications: async () => Notifications.ios.getDeliveredNotifications(),
getPreferences: async () => null,
play: (soundUri: string) => emptyFunction(soundUri),
removeDeliveredNotifications: async (ids: string[]) => Notifications.ios.removeDeliveredNotifications(ids),
setNotificationSound: () => emptyFunction(),
setShouldBlink: (shouldBlink: boolean) => emptyFunction(shouldBlink),
setShouldVibrate: (shouldVibrate: boolean) => emptyFunction(shouldVibrate),
} as NativeNotification;

View File

@@ -17,12 +17,3 @@ export const queryDeviceToken = async (appDatabase: Database) => {
return '';
}
};
export const queryMentionCount = async (appDatabase: Database) => {
try {
const mentions = await appDatabase.get(GLOBAL).find(GLOBAL_IDENTIFIERS.MENTION_COUNT) as Global;
return mentions?.value || '';
} catch {
return '';
}
};

View File

@@ -5,24 +5,44 @@ import {Database, Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type Servers from '@typings/database/models/app/servers';
import type ServerModel from '@typings/database/models/app/servers';
const {APP: {SERVERS}} = MM_TABLES;
export const queryServer = async (appDatabase: Database, serverUrl: string) => {
const servers = (await appDatabase.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch()) as Servers[];
const servers = (await appDatabase.get<ServerModel>(SERVERS).query(Q.where('url', serverUrl)).fetch());
return servers?.[0];
};
export const queryAllServers = async (appDatabse: Database) => {
return (await appDatabse.collections.get(MM_TABLES.APP.SERVERS).query().fetch()) as Servers[];
export const queryAllServers = async (appDatabase: Database) => {
return appDatabase.get<ServerModel>(MM_TABLES.APP.SERVERS).query().fetch();
};
export const queryActiveServer = async (appDatabse: Database) => {
export const queryActiveServer = async (appDatabase: Database) => {
try {
const servers = await queryAllServers(appDatabse);
return servers.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a));
const servers = await queryAllServers(appDatabase);
const server = servers?.filter((s) => s.identifier)?.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a));
return server;
} catch {
return undefined;
}
};
export const queryServerByIdentifier = async (appDatabase: Database, identifier: string) => {
try {
const servers = (await appDatabase.get<ServerModel>(SERVERS).query(Q.where('identifier', identifier)).fetch());
return servers?.[0];
} catch {
return undefined;
}
};
export const queryServerName = async (appDatabase: Database, serverUrl: string) => {
try {
const servers = (await appDatabase.get<ServerModel>(SERVERS).query(Q.where('url', serverUrl)).fetch());
return servers?.[0].displayName;
} catch {
return serverUrl;
}
};

View File

@@ -96,12 +96,16 @@ export const prepareDeleteChannel = async (channel: ChannelModel): Promise<Model
};
export const queryAllChannelsForTeam = (database: Database, teamId: string) => {
return database.get(CHANNEL).query(Q.where('team_id', teamId)).fetch() as Promise<ChannelModel[]>;
return database.get<ChannelModel>(CHANNEL).query(Q.where('team_id', teamId)).fetch();
};
export const queryAllMyChannelIds = (database: Database) => {
return database.get<MyChannelModel>(MY_CHANNEL).query().fetchIds();
};
export const queryMyChannel = async (database: Database, channelId: string) => {
try {
const member = await database.get(MY_CHANNEL).find(channelId) as MyChannelModel;
const member = await database.get<MyChannelModel>(MY_CHANNEL).find(channelId);
return member;
} catch {
return undefined;

View File

@@ -8,7 +8,7 @@ import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type SystemModel from '@typings/database/models/servers/system';
type PrepareCommonSystemValuesArgs = {
export type PrepareCommonSystemValuesArgs = {
config?: ClientConfig;
currentChannelId?: string;
currentTeamId?: string;
@@ -118,6 +118,14 @@ export const queryWebSocketLastDisconnected = async (serverDatabase: Database) =
}
};
export const resetWebSocketLastDisconnected = (operator: ServerDataOperator) => {
return operator.handleSystem({systems: [{
id: SYSTEM_IDENTIFIERS.WEBSOCKET,
value: 0,
}],
prepareRecordsOnly: false});
};
export const queryTeamHistory = async (serverDatabase: Database) => {
try {
const teamHistory = await serverDatabase.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.TEAM_HISTORY);
@@ -127,7 +135,7 @@ export const queryTeamHistory = async (serverDatabase: Database) => {
}
};
export const patchTeamHistory = async (operator: ServerDataOperator, value: string[], prepareRecordsOnly = false) => {
export const patchTeamHistory = (operator: ServerDataOperator, value: string[], prepareRecordsOnly = false) => {
return operator.handleSystem({systems: [{
id: SYSTEM_IDENTIFIERS.TEAM_HISTORY,
value: JSON.stringify(value),

View File

@@ -16,6 +16,7 @@ import StatusBar from '@components/status_bar';
import {Screens, Database} from '@constants';
import {useServerUrl} from '@context/server_url';
import {useTheme} from '@context/theme';
import {useAppState} from '@hooks/device';
import {goToScreen} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
@@ -69,6 +70,7 @@ const Channel = ({currentChannelId, currentTeamId, time}: ChannelProps) => {
const theme = useTheme();
const intl = useIntl();
const styles = getStyleSheet(theme);
const appState = useAppState();
const serverUrl = useServerUrl();
@@ -99,6 +101,7 @@ const Channel = ({currentChannelId, currentTeamId, time}: ChannelProps) => {
<PostList
channelId={currentChannelId}
testID='channel.post_list'
forceQueryAfterAppState={appState}
/>
<View style={styles.sectionContainer}>
<Text
@@ -118,7 +121,7 @@ const Channel = ({currentChannelId, currentTeamId, time}: ChannelProps) => {
</View>
</>
);
}, [currentTeamId, currentChannelId, theme]);
}, [currentTeamId, currentChannelId, theme, appState]);
return (
<SafeAreaView

View File

@@ -13,6 +13,7 @@ import {CustomStatusDuration, CST} from '@constants/custom_status';
import {useTheme} from '@context/theme';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {getTimezone} from '@utils/user';
import DateTimePicker from './date_time_selector';
@@ -130,9 +131,9 @@ const ClearAfterMenuItem = ({currentUser, duration, expiryTime = '', handleItemC
</TouchableOpacity>
{showDateTimePicker && (
<DateTimePicker
currentUser={currentUser}
theme={theme}
handleChange={handleCustomExpiresAtChange}
theme={theme}
timezone={getTimezone(currentUser.timezone)}
/>
)}
</View>

View File

@@ -17,14 +17,12 @@ import {MM_TABLES} from '@constants/database';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {getCurrentMomentForTimezone, getRoundedTime, getUtcOffsetForTimeZone} from '@utils/helpers';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {getTimezone} from '@utils/user';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PreferenceModel from '@typings/database/models/servers/preference';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
currentUser: UserModel;
timezone: string | null;
isMilitaryTime: boolean;
theme: Theme;
handleChange: (currentDate: Moment) => void;
@@ -49,9 +47,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const DateTimeSelector = ({currentUser, handleChange, isMilitaryTime, theme}: Props) => {
const DateTimeSelector = ({timezone, handleChange, isMilitaryTime, theme}: Props) => {
const styles = getStyleSheet(theme);
const timezone = getTimezone(currentUser.timezone);
const currentTime = getCurrentMomentForTimezone(timezone);
const timezoneOffSetInMinutes = timezone ? getUtcOffsetForTimeZone(timezone) : undefined;
const minimumDate = getRoundedTime(currentTime);

View File

@@ -3,11 +3,14 @@
import {createBottomTabNavigator, BottomTabBarProps} from '@react-navigation/bottom-tabs';
import {NavigationContainer} from '@react-navigation/native';
import React from 'react';
import {Platform} from 'react-native';
import React, {useEffect} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, Platform} from 'react-native';
import {enableScreens} from 'react-native-screens';
import {Events} from '@constants';
import {useTheme} from '@context/theme';
import {notificationError} from '@utils/notification';
import Account from './account';
import ChannelList from './channel_list';
@@ -30,6 +33,15 @@ const Tab = createBottomTabNavigator();
export default function HomeScreen(props: HomeProps) {
const theme = useTheme();
const intl = useIntl();
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.NOTIFICATION_ERROR, (value: 'Team' | 'Channel') => {
notificationError(intl, value);
});
return () => listener.remove();
}, []);
return (
<NavigationContainer

View File

@@ -7,6 +7,8 @@ import {Shadow} from 'react-native-neomorph-shadows';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {Navigation as NavigationConstants, Screens} from '@constants';
import EphemeralStore from '@store/ephemeral_store';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import Account from './account';
@@ -71,6 +73,15 @@ function TabBar({state, descriptors, navigation, theme}: BottomTabBarProps & {th
return () => event.remove();
}, []);
useEffect(() => {
const listner = DeviceEventEmitter.addListener(NavigationConstants.NAVIGATION_HOME, () => {
EphemeralStore.setVisibleTap(Screens.HOME);
navigation.navigate(Screens.HOME);
});
return () => listner.remove();
});
const transform = useAnimatedStyle(() => {
const translateX = withTiming(state.index * tabWidth, {duration: 150});
return {
@@ -130,6 +141,7 @@ function TabBar({state, descriptors, navigation, theme}: BottomTabBarProps & {th
if (!isFocused && !event.defaultPrevented) {
// The `merge: true` option makes sure that the params inside the tab screen are preserved
navigation.navigate({params: {...route.params, direction}, name: route.name, merge: true});
EphemeralStore.setVisibleTap(route.name);
}
};

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {StyleSheet, View} from 'react-native';
import FastImage, {Source} from 'react-native-fast-image';
import {of as of$} from 'rxjs';
import {catchError, switchMap} from 'rxjs/operators';
import CompassIcon from '@components/compass_icon';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import NetworkManager from '@init/network_manager';
import {WithDatabaseArgs} from '@typings/database/database';
import SystemModel from '@typings/database/models/servers/system';
import type {Client} from '@client/rest';
import type UserModel from '@typings/database/models/servers/user';
interface NotificationIconProps {
author?: UserModel;
enablePostIconOverride: boolean;
fromWebhook: boolean;
overrideIconUrl?: string;
serverUrl: string;
useUserIcon: boolean;
}
const {SERVER: {SYSTEM, USER}} = MM_TABLES;
const IMAGE_SIZE = 36;
const logo = require('@assets/images/icon.png');
const styles = StyleSheet.create({
icon: {
borderRadius: (IMAGE_SIZE / 2),
height: IMAGE_SIZE,
width: IMAGE_SIZE,
},
});
const NotificationIcon = ({author, enablePostIconOverride, fromWebhook, overrideIconUrl, serverUrl, useUserIcon}: NotificationIconProps) => {
let client: Client | undefined;
try {
client = NetworkManager.getClient(serverUrl);
} catch {
// do nothing, client is not set
}
let icon;
if (client && fromWebhook && !useUserIcon && enablePostIconOverride) {
if (overrideIconUrl) {
const source: Source = {uri: client.getAbsoluteUrl(overrideIconUrl)};
icon = (
<FastImage
source={source}
style={styles.icon}
/>
);
} else {
icon = (
<CompassIcon
name='webhook'
style={styles.icon}
/>
);
}
} else if (author && client) {
const pictureUrl = client.getProfilePictureUrl(author.id, author.lastPictureUpdate);
icon = (
<FastImage
key={pictureUrl}
style={{width: IMAGE_SIZE, height: IMAGE_SIZE, borderRadius: (IMAGE_SIZE / 2)}}
source={{uri: `${serverUrl}${pictureUrl}`}}
/>
);
} else {
icon = (
<FastImage
source={logo}
style={styles.icon}
/>
);
}
return (
<View testID='in_app_notification.icon'>
{icon}
</View>
);
};
const enhanced = withObservables([], ({database, senderId}: WithDatabaseArgs & {senderId: string}) => {
const config = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG);
const author = database.get<UserModel>(USER).findAndObserve(senderId).pipe(
catchError(() => of$(undefined)),
);
return {
author,
enablePostIconOverride: config.pipe(
switchMap(({value}) => of$((value as ClientConfig).EnablePostIconOverride === 'true')),
),
};
});
export default enhanced(NotificationIcon);

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useRef, useState} from 'react';
import {DeviceEventEmitter, Platform, StyleSheet, Text, TouchableOpacity, View} from 'react-native';
import {PanGestureHandler} from 'react-native-gesture-handler';
import {Navigation} from 'react-native-navigation';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {openNotification} from '@actions/remote/notifications';
import {Navigation as NavigationTypes} from '@constants';
import DatabaseManager from '@database/manager';
import {useIsTablet} from '@hooks/device';
import {dismissOverlay} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity} from '@utils/theme';
import Icon from './icon';
import Server from './server';
import Title from './title';
type InAppNotificationProps = {
componentId: string;
notification: NotificationWithData;
serverName?: string;
serverUrl: string;
}
const AUTO_DISMISS_TIME_MILLIS = 5000;
const styles = StyleSheet.create({
container: {
alignSelf: 'center',
backgroundColor: changeOpacity('#000', 0.88),
borderRadius: 12,
flexDirection: 'row',
padding: 10,
width: '95%',
},
tablet: {
width: 500,
},
flex: {
width: '100%',
},
message: {
color: '#FFFFFF',
fontSize: 13,
fontFamily: 'OpenSans',
},
titleContainer: {
flex: 1,
flexDirection: 'column',
alignSelf: 'stretch',
alignItems: 'flex-start',
marginLeft: 10,
minHeight: 60,
},
touchable: {
flexDirection: 'row',
},
});
const InAppNotification = ({componentId, serverName, serverUrl, notification}: InAppNotificationProps) => {
const [animate, setAnimate] = useState(false);
const dismissTimerRef = useRef<NodeJS.Timeout | null>(null);
const initial = useSharedValue(-130);
const isTablet = useIsTablet();
let insets = {top: 0};
if (Platform.OS === 'ios') {
// on Android we disable the safe area provider as it conflicts with the gesture system
// eslint-disable-next-line react-hooks/rules-of-hooks
insets = useSafeAreaInsets();
}
const tapped = useRef<boolean>(false);
const animateDismissOverlay = () => {
cancelDismissTimer();
setAnimate(true);
dismissTimerRef.current = setTimeout(dismiss, 1000);
};
const cancelDismissTimer = () => {
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
}
};
const dismiss = () => {
cancelDismissTimer();
dismissOverlay(componentId);
};
const notificationTapped = preventDoubleTap(() => {
tapped.current = true;
dismiss();
});
useEffect(() => {
initial.value = 0;
dismissTimerRef.current = setTimeout(() => {
if (!tapped.current) {
animateDismissOverlay();
}
}, AUTO_DISMISS_TIME_MILLIS);
return cancelDismissTimer;
}, []);
useEffect(() => {
const didDismissListener = Navigation.events().registerComponentDidDisappearListener(async ({componentId: screen}) => {
if (componentId === screen && tapped.current && serverUrl) {
const {channel_id} = notification.payload || {};
if (channel_id) {
openNotification(serverUrl, notification);
}
}
});
return () => didDismissListener.remove();
}, []);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(NavigationTypes.NAVIGATION_SHOW_OVERLAY, dismiss);
return () => listener.remove();
}, []);
const animatedStyle = useAnimatedStyle(() => {
const translateY = animate ? withTiming(-130, {duration: 300}) : withTiming(initial.value, {duration: 300});
return {
marginTop: insets.top,
transform: [{translateY}],
};
}, [animate, insets.top]);
const message = notification.payload?.body || notification.payload?.message;
return (
<PanGestureHandler
onGestureEvent={animateDismissOverlay}
minOffsetY={-20}
>
<Animated.View
style={[styles.container, isTablet ? styles.tablet : undefined, animatedStyle]}
testID='in_app_notification.screen'
>
<View style={styles.flex}>
<TouchableOpacity
style={styles.touchable}
onPress={notificationTapped}
activeOpacity={1}
>
<Icon
database={DatabaseManager.serverDatabases[serverUrl].database}
fromWebhook={notification.payload?.from_webhook === 'true'}
overrideIconUrl={notification.payload?.override_icon_url}
senderId={notification.payload?.sender_id || ''}
serverUrl={serverUrl}
useUserIcon={notification.payload?.use_user_icon === 'true'}
/>
<View style={styles.titleContainer}>
<Title channelName={notification.payload?.channel_name || ''}/>
<View style={styles.flex}>
<Text
numberOfLines={2}
ellipsizeMode='tail'
style={styles.message}
testID='in_app_notification.message'
>
{message}
</Text>
</View>
{Boolean(serverName) && <Server serverName={serverName!}/>}
</View>
</TouchableOpacity>
</View>
</Animated.View>
</PanGestureHandler>
);
};
export default InAppNotification;

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, Text, View} from 'react-native';
interface NotificationServerProps {
serverName: string;
}
const styles = StyleSheet.create({
container: {
alignItems: 'flex-start',
marginTop: 5,
},
text: {
color: 'rgba(255, 255, 255, 0.64)',
fontFamily: 'OpenSans',
fontSize: 10,
},
});
const Server = ({serverName}: NotificationServerProps) => {
return (
<View style={styles.container}>
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.text}
testID='in_app_notification.title'
>
{serverName}
</Text>
</View>
);
};
export default Server;

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, Text} from 'react-native';
interface NotificationTitleProps {
channelName: string;
}
const Title = ({channelName}: NotificationTitleProps) => {
return (
<Text
numberOfLines={1}
ellipsizeMode='tail'
style={styles.title}
testID='in_app_notification.title'
>
{channelName}
</Text>
);
};
const styles = StyleSheet.create({
title: {
color: '#FFFFFF',
fontSize: 14,
fontFamily: 'OpenSans-SemiBold',
},
});
export default Title;

View File

@@ -120,6 +120,14 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.FORGOT_PASSWORD:
screen = withIntl(require('@screens/forgot_password').default);
break;
case Screens.IN_APP_NOTIFICATION: {
const notificationScreen = require('@screens/in_app_notification').default;
Navigation.registerComponent(Screens.IN_APP_NOTIFICATION, () => Platform.select({
android: notificationScreen,
ios: withSafeAreaInsets(notificationScreen),
}));
return;
}
// case 'Gallery':
// screen = require('@screens/gallery').default;
// break;

View File

@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import merge from 'deepmerge';
import {Appearance, DeviceEventEmitter, NativeModules, Platform} from 'react-native';
import {Navigation, Options, OptionsModalPresentationStyle} from 'react-native-navigation';
@@ -232,6 +234,37 @@ export async function dismissAllModalsAndPopToRoot() {
DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_DISMISS_AND_POP_TO_ROOT);
}
/**
* Dismisses All modals in the stack and pops the stack to the desired screen
* (if the screen is not in the stack, it will push a new one)
* @param screenId Screen to pop or display
* @param title Title to be shown in the top bar
* @param passProps Props to pass to the screen (Only if the screen does not exist in the stack)
* @param options Navigation options
*/
export async function dismissAllModalsAndPopToScreen(screenId: string, title: string, passProps = {}, options = {}) {
await dismissAllModals();
if (EphemeralStore.getNavigationComponents().includes(screenId)) {
let mergeOptions = options;
if (title) {
mergeOptions = merge(mergeOptions, {
topBar: {
title: {
text: title,
},
},
});
}
try {
await Navigation.popTo(screenId, mergeOptions);
} catch {
// catch in case there is nothing to pop
}
} else {
goToScreen(screenId, title, passProps, options);
}
}
export function showModal(name: string, title: string, passProps = {}, options = {}) {
const theme = getThemeFromState();
const modalPresentationStyle: OptionsModalPresentationStyle = Platform.OS === 'ios' ? OptionsModalPresentationStyle.pageSheet : OptionsModalPresentationStyle.none;

View File

@@ -159,7 +159,7 @@ const Server: NavigationFunctionComponent = ({componentId, extra, launchType, la
return;
}
const data = await fetchConfigAndLicense(serverUrl);
const data = await fetchConfigAndLicense(serverUrl, true);
if (data.error) {
setError(data.error as ClientError);
setConnecting(false);

View File

@@ -73,7 +73,7 @@ const SSO = ({config, extra, launchError, launchType, serverUrl, ssoType, theme}
};
const doSSOLogin = async (bearerToken: string, csrfToken: string) => {
const result: LoginActionResponse = await ssoLogin(serverUrl!, bearerToken, csrfToken);
const result: LoginActionResponse = await ssoLogin(serverUrl!, config.DiagnosticId!, bearerToken, csrfToken);
if (result?.error && result.failed) {
onLoadEndError(result.error);
return;

View File

@@ -6,13 +6,30 @@ class EphemeralStore {
navigationComponentIdStack: string[] = [];
navigationModalStack: string[] = [];
theme: Theme | undefined;
visibleTab = 'Home'
getNavigationTopComponentId = () => {
return this.navigationComponentIdStack[0];
addNavigationComponentId = (componentId: string) => {
this.addToNavigationComponentIdStack(componentId);
this.addToAllNavigationComponentIds(componentId);
};
addToAllNavigationComponentIds = (componentId: string) => {
if (!this.allNavigationComponentIds.includes(componentId)) {
this.allNavigationComponentIds.unshift(componentId);
}
}
getNavigationTopModalId = () => {
return this.navigationModalStack[0];
addToNavigationComponentIdStack = (componentId: string) => {
const index = this.navigationComponentIdStack.indexOf(componentId);
if (index >= 0) {
this.navigationComponentIdStack = this.navigationComponentIdStack.slice(index, 1);
}
this.navigationComponentIdStack.unshift(componentId);
}
addNavigationModal = (componentId: string) => {
this.navigationModalStack.unshift(componentId);
}
clearNavigationComponents = () => {
@@ -25,30 +42,20 @@ class EphemeralStore {
this.navigationModalStack = [];
}
addNavigationComponentId = (componentId: string) => {
this.addToNavigationComponentIdStack(componentId);
this.addToAllNavigationComponentIds(componentId);
};
addNavigationModal = (componentId: string) => {
this.navigationModalStack.unshift(componentId);
getNavigationTopComponentId = () => {
return this.navigationComponentIdStack[0];
}
addToNavigationComponentIdStack = (componentId: string) => {
const index = this.navigationComponentIdStack.indexOf(componentId);
if (index >= 0) {
this.navigationComponentIdStack = this.navigationComponentIdStack.slice(index, 1);
}
this.navigationComponentIdStack.unshift(componentId);
getNavigationTopModalId = () => {
return this.navigationModalStack[0];
}
addToAllNavigationComponentIds = (componentId: string) => {
if (!this.allNavigationComponentIds.includes(componentId)) {
this.allNavigationComponentIds.unshift(componentId);
}
getNavigationComponents = () => {
return this.navigationComponentIdStack;
}
getVisibleTab = () => this.visibleTab;
hasModalsOpened = () => this.navigationModalStack.length > 0;
removeNavigationComponentId = (componentId: string) => {
@@ -65,6 +72,44 @@ class EphemeralStore {
this.navigationModalStack.splice(index, 1);
}
}
setVisibleTap = (tab: string) => {
this.visibleTab = tab;
}
/**
* Waits until a screen has been mounted and is part of the stack.
* Use this function only if you know what you are doing
* this function will run until the screen appears in the stack
* and can easily run forever if the screen is never prevesented.
* @param componentId string
*/
waitUntilScreenHasLoaded = async (componentId: string) => {
let found = false;
while (!found) {
// eslint-disable-next-line no-await-in-loop
await (new Promise((r) => requestAnimationFrame(r)));
found = this.navigationComponentIdStack.includes(componentId);
}
}
/**
* Waits until a screen has been removed as part of the stack.
* Use this function only if you know what you are doing
* this function will run until the screen disappears from the stack
* and can easily run forever if the screen is never removed.
* @param componentId string
*/
waitUntilScreensIsRemoved = async (componentId: string) => {
let found = false;
while (!found) {
// eslint-disable-next-line no-await-in-loop
await (new Promise((r) => requestAnimationFrame(r)));
found = !this.navigationComponentIdStack.includes(componentId);
}
}
}
export default new EphemeralStore();

View File

@@ -95,7 +95,7 @@ export function safeParseJSON(rawJson: string | Record<string, unknown> | unknow
return data;
}
export function getCurrentMomentForTimezone(timezone: string) {
export function getCurrentMomentForTimezone(timezone: string | null) {
return timezone ? moment.tz(timezone) : moment();
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {createIntl, IntlShape} from 'react-intl';
import {Alert, DeviceEventEmitter} from 'react-native';
import {Events} from '@constants';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import PushNotifications from '@init/push_notifications';
import {popToRoot} from '@screens/navigation';
import {sortByNewest} from '@utils/general';
export const convertToNotificationData = (notification: Notification, tapped = true) => {
if (!notification.payload) {
return notification as unknown as NotificationWithData;
}
const {payload} = notification;
const notificationData: NotificationWithData = {
...notification,
payload: {
ack_id: payload.ack_id,
channel_id: payload.channel_id,
channel_name: payload.channel_name,
identifier: payload.identifier || notification.identifier,
from_webhook: payload.from_webhook,
message: ((payload.type === 'message') ? payload.message || notification.body : undefined),
override_icon_url: payload.override_icon_url,
override_username: payload.override_username,
post_id: payload.post_id,
root_id: payload.root_id,
sender_id: payload.sender_id,
sender_name: payload.sender_name,
server_id: payload.server_id,
server_url: payload.server_url,
team_id: payload.team_id,
type: payload.type,
use_user_icon: payload.use_user_icon,
version: payload.version,
},
userInteraction: tapped,
foreground: false,
};
return notificationData;
};
export const notificationError = (intl: IntlShape, type: 'Team' | 'Channel') => {
const title = intl.formatMessage({id: 'notification.message_not_found', defaultMessage: 'Message not found'});
let message;
switch (type) {
case 'Channel':
message = intl.formatMessage({
id: 'notification.not_channel_member',
defaultMessage: 'This message belongs to a channel where you are not a member.',
});
break;
case 'Team':
message = intl.formatMessage({
id: 'notification.not_team_member',
defaultMessage: 'This message belongs to a team where you are not a member.',
});
break;
}
Alert.alert(title, message);
popToRoot();
};
export const emitNotificationError = (type: 'Team' | 'Channel') => {
const req = setTimeout(() => {
DeviceEventEmitter.emit(Events.NOTIFICATION_ERROR, type);
clearTimeout(req);
}, 500);
};
export const scheduleExpiredNotification = async (sessions: Session[], siteName: string, locale = DEFAULT_LOCALE) => {
const session = sessions.sort(sortByNewest)[0];
const expiresAt = session?.expires_at || 0;
const expiresInDays = Math.ceil(Math.abs(moment.duration(moment().diff(moment(expiresAt))).asDays()));
const intl = createIntl({locale, messages: getTranslations(locale)});
const body = intl.formatMessage({
id: 'mobile.session_expired',
defaultMessage: 'Session Expired: Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.',
}, {siteName, daysCount: expiresInDays});
if (expiresAt && body) {
//@ts-expect-error: Does not need to set all Notification properties
PushNotifications.scheduleNotification({
fireDate: expiresAt,
body,
payload: {
userInfo: {
local: true,
},
},
});
}
};

View File

@@ -4,7 +4,6 @@
import moment from 'moment-timezone';
import {Post} from '@constants';
import {getTimezone} from '@utils/user';
import type PostModel from '@typings/database/models/servers/post';
@@ -171,7 +170,7 @@ function isJoinLeavePostForUsername(post: PostModel, currentUsername: string): b
// are we going to do something with selectedPostId as in v1?
function selectOrderedPosts(
posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUsername: string, showJoinLeave: boolean,
timezoneEnabled: boolean, currentTimezone: UserTimezone | null, isThreadScreen = false) {
timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false) {
if (posts.length === 0) {
return [];
}
@@ -200,9 +199,8 @@ function selectOrderedPosts(
const postDate = new Date(post.createAt);
if (timezoneEnabled) {
const currentOffset = postDate.getTimezoneOffset() * 60 * 1000;
const timezone = getTimezone(currentTimezone);
if (timezone) {
const zone = moment.tz.zone(timezone);
if (currentTimezone) {
const zone = moment.tz.zone(currentTimezone);
if (zone) {
const timezoneOffset = zone.utcOffset(post.createAt) * 60 * 1000;
postDate.setTime(post.createAt + (currentOffset - timezoneOffset));
@@ -354,7 +352,7 @@ export function isStartOfNewMessages(item: string) {
export function preparePostList(
posts: PostModel[], lastViewedAt: number, indicateNewMessages: boolean, currentUsername: string, showJoinLeave: boolean,
timezoneEnabled: boolean, currentTimezone: UserTimezone | null, isThreadScreen = false) {
timezoneEnabled: boolean, currentTimezone: string | null, isThreadScreen = false) {
const orderedPosts = selectOrderedPosts(posts, lastViewedAt, indicateNewMessages, currentUsername, showJoinLeave, timezoneEnabled, currentTimezone, isThreadScreen);
return combineUserActivityPosts(orderedPosts);
}

View File

@@ -260,6 +260,9 @@
"modal.manual_status.auto_responder.message_dnd": "Would you like to switch your status to \"Do Not Disturb\" and disable Automatic Replies?",
"modal.manual_status.auto_responder.message_offline": "Would you like to switch your status to \"Offline\" and disable Automatic Replies?",
"modal.manual_status.auto_responder.message_online": "Would you like to switch your status to \"Online\" and disable Automatic Replies?",
"notification.message_not_found": "Message not found",
"notification.not_channel_member": "This message belongs to a channel where you are not a member.",
"notification.not_team_member": "This message belongs to a team where you are not a member.",
"password_form.title": "Password Reset",
"password_send.checkInbox": "Please check your inbox.",
"password_send.description": "To reset your password, enter the email address you used to sign up",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -47,19 +47,24 @@ if (Platform.OS === 'android') {
AppRegistry.registerComponent('MattermostShare', () => ShareExtension);
}
let alreadyInitialized = false;
Navigation.events().registerAppLaunchedListener(async () => {
GlobalEventHandler.init();
ManagedApp.init();
registerNavigationListeners();
registerScreens();
// See caution in the library doc https://wix.github.io/react-native-navigation/docs/app-launch#android
if (!alreadyInitialized) {
alreadyInitialized = true;
GlobalEventHandler.init();
ManagedApp.init();
registerNavigationListeners();
registerScreens();
const serverCredentials = await getAllServerCredentials();
const serverUrls = serverCredentials.map((credential) => credential.serverUrl);
const serverCredentials = await getAllServerCredentials();
const serverUrls = serverCredentials.map((credential) => credential.serverUrl);
await DatabaseManager.init(serverUrls);
await NetworkManager.init(serverCredentials);
await WebsocketManager.init(serverCredentials);
PushNotifications.init();
await DatabaseManager.init(serverUrls);
await NetworkManager.init(serverCredentials);
await WebsocketManager.init(serverCredentials);
PushNotifications.init();
}
initialLaunch();
});
@@ -76,8 +81,10 @@ function componentWillAppear({componentId}: ComponentDidAppearEvent) {
}
}
function componentDidAppearListener({componentId}: ComponentDidAppearEvent) {
EphemeralStore.addNavigationComponentId(componentId);
function componentDidAppearListener({componentId, passProps}: ComponentDidAppearEvent) {
if (!(passProps as any)?.overlay) {
EphemeralStore.addNavigationComponentId(componentId);
}
}
function componentDidDisappearListener({componentId}: ComponentDidDisappearEvent) {

View File

@@ -22,16 +22,15 @@ public struct AckNotification: Codable {
case type = "type"
case id = "ack_id"
case postId = "post_id"
case serverUrl = "server_url"
case server_id = "server_id"
case isIdLoaded = "id_loaded"
case platform = "platform"
}
public enum AckNotificationRequestKeys: String, CodingKey {
case type = "type"
case id = "ack_id"
case id = "id"
case postId = "post_id"
case serverUrl = "server_url"
case isIdLoaded = "is_id_loaded"
case receivedAt = "received_at"
case platform = "platform"
@@ -42,11 +41,15 @@ public struct AckNotification: Codable {
id = try container.decode(String.self, forKey: .id)
type = try container.decode(String.self, forKey: .type)
postId = try? container.decode(String.self, forKey: .postId)
isIdLoaded = (try? container.decode(Bool.self, forKey: .isIdLoaded)) == true
if container.contains(.isIdLoaded) {
isIdLoaded = (try? container.decode(Bool.self, forKey: .isIdLoaded)) == true
} else {
isIdLoaded = false
}
receivedAt = Date().millisecondsSince1970
if let decodedServerUrl = try? container.decode(String.self, forKey: .serverUrl) {
serverUrl = decodedServerUrl
if let decodedIdentifier = try? container.decode(String.self, forKey: .server_id) {
serverUrl = try Database.default.getServerUrlForServer(decodedIdentifier)
} else {
serverUrl = try Database.default.getOnlyServerUrl()
}
@@ -112,8 +115,8 @@ extension Network {
let channelId = notification.userInfo["channel_id"] as! String
let serverUrl = notification.userInfo["server_url"] as! String
let currentUserId = try! Database.default.queryCurrentUserId(serverUrl)
let currentUser = try! Database.default.queryCurrentUser(serverUrl)
let currentUserId = currentUser?[Expression<String>("id")]
let currentUsername = currentUser?[Expression<String>("username")]
var postData: PostData? = nil
@@ -122,7 +125,7 @@ extension Network {
var users: Set<User> = Set()
group.enter()
let since = try! Database.default.queryPostsSinceForChannel(withId: channelId, withServerUrl: serverUrl)
let since = try? Database.default.queryPostsSinceForChannel(withId: channelId, withServerUrl: serverUrl)
self.fetchPostsForChannel(withId: channelId, withSince: since, withServerUrl: serverUrl) { data, response, error in
if self.responseOK(response), let data = data {
postData = try! JSONDecoder().decode(PostData.self, from: data)
@@ -130,7 +133,7 @@ extension Network {
var authorIds: Set<String> = Set()
var usernames: Set<String> = Set()
postData!.posts.forEach{post in
if (post.user_id != currentUserId) {
if (currentUserId != nil && post.user_id != currentUserId) {
authorIds.insert(post.user_id)
}
self.matchUsername(in: post.message).forEach{
@@ -141,32 +144,34 @@ extension Network {
}
if (authorIds.count > 0) {
let existingIds = try! Database.default.queryUsers(byIds: authorIds, withServerUrl: serverUrl)
userIdsToLoad = authorIds.filter { !existingIds.contains($0) }
if (userIdsToLoad.count > 0) {
group.enter()
self.fetchUsers(byIds: Array(userIdsToLoad), withServerUrl: serverUrl) { data, response, error in
if self.responseOK(response), let data = data {
let usersData = try! JSONDecoder().decode([User].self, from: data)
usersData.forEach { users.insert($0) }
if let existingIds = try? Database.default.queryUsers(byIds: authorIds, withServerUrl: serverUrl) {
userIdsToLoad = authorIds.filter { !existingIds.contains($0) }
if (userIdsToLoad.count > 0) {
group.enter()
self.fetchUsers(byIds: Array(userIdsToLoad), withServerUrl: serverUrl) { data, response, error in
if self.responseOK(response), let data = data {
let usersData = try! JSONDecoder().decode([User].self, from: data)
usersData.forEach { users.insert($0) }
}
group.leave()
}
group.leave()
}
}
}
if (usernames.count > 0) {
let existingUsernames = try! Database.default.queryUsers(byUsernames: usernames, withServerUrl: serverUrl)
usernamesToLoad = usernames.filter{ !existingUsernames.contains($0)}
if (usernamesToLoad.count > 0) {
group.enter()
if let existingUsernames = try? Database.default.queryUsers(byUsernames: usernames, withServerUrl: serverUrl) {
usernamesToLoad = usernames.filter{ !existingUsernames.contains($0)}
if (usernamesToLoad.count > 0) {
group.enter()
self.fetchUsers(byUsernames: Array(usernamesToLoad), withServerUrl: serverUrl) { data, response, error in
if self.responseOK(response), let data = data {
let usersData = try! JSONDecoder().decode([User].self, from: data)
usersData.forEach { users.insert($0) }
self.fetchUsers(byUsernames: Array(usernamesToLoad), withServerUrl: serverUrl) { data, response, error in
if self.responseOK(response), let data = data {
let usersData = try! JSONDecoder().decode([User].self, from: data)
usersData.forEach { users.insert($0) }
}
group.leave()
}
group.leave()
}
}
}
@@ -179,12 +184,13 @@ extension Network {
group.wait()
group.enter()
if (postData != nil) {
let db = try! Database.default.getDatabaseForServer(serverUrl)
try! db.transaction {
try! Database.default.handlePostData(db, postData!, channelId, since != nil)
if (users.count > 0) {
try! Database.default.insertUsers(db, users)
if (postData != nil && postData?.posts != nil && postData!.posts.count > 0) {
if let db = try? Database.default.getDatabaseForServer(serverUrl) {
try? db.transaction {
try? Database.default.handlePostData(db, postData!, channelId, since != nil)
if (users.count > 0) {
try? Database.default.insertUsers(db, users)
}
}
}
}

View File

@@ -98,9 +98,9 @@ extension Database {
var earliest: Int64?
var latest: Int64?
if let result = try db.pluck(earliestLatestQuery) {
earliest = try result.get(earliestCol)
latest = try result.get(latestCol)
if let result = try? db.pluck(earliestLatestQuery) {
earliest = try? result.get(earliestCol)
latest = try? result.get(latestCol)
} else {
return nil
}
@@ -146,22 +146,19 @@ extension Database {
public func handlePostData(_ db: Connection, _ postData: PostData, _ channelId: String, _ usedSince: Bool = false) throws {
let sortedChainedPosts = chainAndSortPosts(postData)
try insertOrUpdatePosts(db, sortedChainedPosts, channelId)
try handlePostsInChannel(db, channelId, postData, usedSince)
let earliest = sortedChainedPosts.first!.create_at
let latest = sortedChainedPosts.last!.create_at
try handlePostsInChannel(db, channelId, earliest, latest, usedSince)
try handlePostsInThread(db, postData.posts)
}
private func handlePostsInChannel(_ db: Connection, _ channelId: String, _ postData: PostData, _ usedSince: Bool = false) throws {
let firstId = postData.order.first
let lastId = postData.order.last
let earliest = postData.posts.first(where: { $0.id == lastId})!.create_at
let latest = postData.posts.first(where: { $0.id == firstId})!.create_at
private func handlePostsInChannel(_ db: Connection, _ channelId: String, _ earliest: Int64, _ latest: Int64, _ usedSince: Bool = false) throws {
if usedSince {
try updatePostsInChannelLatestOnly(db, channelId, latest)
try? updatePostsInChannelLatestOnly(db, channelId, latest)
} else {
let updated = try updatePostsInChannelEarliestAndLatest(db, channelId, earliest, latest)
if (!updated) {
try insertPostsInChannel(db, channelId, earliest, latest)
try? insertPostsInChannel(db, channelId, earliest, latest)
}
}
}
@@ -393,10 +390,10 @@ extension Database {
fileSetter.append(ext <- f["extension"] as! String)
fileSetter.append(size <- f["size"] as! Int64)
fileSetter.append(mimeType <- f["mime_type"] as! String)
fileSetter.append(width <- f["width"] as! Int64)
fileSetter.append(height <- f["height"] as! Int64)
fileSetter.append(width <- (f["width"] as? Int64 ?? 0))
fileSetter.append(height <- (f["height"] as? Int64 ?? 0))
fileSetter.append(localPath <- "")
fileSetter.append(imageThumbnail <- f["mini_preview"] as? String)
fileSetter.append(imageThumbnail <- (f["mini_preview"] as? String ?? ""))
fileSetter.append(statusCol <- "created")
fileSetters.append(fileSetter)

View File

@@ -79,13 +79,13 @@ public class Database: NSObject {
}
public func generateId() -> String {
let alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
let alphabet = Array("0123456789abcdefghijklmnopqrstuvwxyz")
let alphabetLenght = alphabet.count
let idLenght = 16
var id = ""
for _ in 1...(idLenght / 2) {
let random = floor(drand48() * Double(alphabetLenght) * Double(alphabetLenght))
let random = floor(Double.random(in: 0..<1) * Double(alphabetLenght) * Double(alphabetLenght))
let firstIndex = Int(floor(random / Double(alphabetLenght)))
let lastIndex = Int(random) % alphabetLenght
id += String(alphabet[firstIndex])
@@ -98,8 +98,9 @@ public class Database: NSObject {
public func getOnlyServerUrl() throws -> String {
let db = try Connection(DEFAULT_DB_PATH)
let url = Expression<String>("url")
let identifier = Expression<String>("identifier")
let lastActiveAt = Expression<Int64>("last_active_at")
let query = serversTable.select(url).filter(lastActiveAt > 0)
let query = serversTable.select(url).filter(lastActiveAt > 0 && identifier != "")
var serverUrl: String?
for result in try db.prepare(query) {
@@ -117,6 +118,22 @@ public class Database: NSObject {
throw DatabaseError.NoResults(query.asSQL())
}
public func getServerUrlForServer(_ id: String) throws -> String {
let db = try Connection(DEFAULT_DB_PATH)
let url = Expression<String>("url")
let identifier = Expression<String>("identifier")
let query = serversTable.select(url).filter(identifier == id)
if let server = try db.pluck(query) {
let serverUrl: String? = try server.get(url)
if (serverUrl != nil) {
return serverUrl!
}
}
throw DatabaseError.NoResults(query.asSQL())
}
internal func getDatabaseForServer(_ serverUrl: String) throws -> Connection {
let db = try Connection(DEFAULT_DB_PATH)
let url = Expression<String>("url")

View File

@@ -96,21 +96,19 @@ MattermostBucket* bucket = nil;
UIApplicationState state = [UIApplication sharedApplication].applicationState;
NSString* action = [userInfo objectForKey:@"type"];
NSString* channelId = [userInfo objectForKey:@"channel_id"];
BOOL isClearAction = (action && [action isEqualToString: NOTIFICATION_CLEAR_ACTION]);
RuntimeUtils *utils = [[RuntimeUtils alloc] init];
if ((action && [action isEqualToString: NOTIFICATION_CLEAR_ACTION]) || (state == UIApplicationStateInactive)) {
if (isClearAction) {
// If received a notification that a channel was read, remove all notifications from that channel (only with app in foreground/background)
[self cleanNotificationsFromChannel:channelId];
[[Network default] postNotificationReceipt:userInfo];
}
[[Network default] postNotificationReceipt:userInfo];
[utils delayWithSeconds:0.2 closure:^(void) {
// This is to notify the NotificationCenter that something has changed.
if (state != UIApplicationStateActive || isClearAction) {
[RNNotifications didReceiveBackgroundNotification:userInfo withCompletionHandler:completionHandler];
} else {
completionHandler(UIBackgroundFetchResultNewData);
}];
}
}
-(void)cleanNotificationsFromChannel:(NSString *)channelId {

View File

@@ -66,7 +66,7 @@ class NotificationService: UNNotificationServiceExtension {
bestAttemptContent.title = channelName
}
let userInfoKeys = ["channel_name", "team_id", "sender_id", "root_id", "override_username", "override_icon_url", "from_webhook"]
let userInfoKeys = ["channel_name", "team_id", "sender_id", "root_id", "override_username", "override_icon_url", "from_webhook", "message"]
for key in userInfoKeys {
if let value = json[key] as? String {
bestAttemptContent.userInfo[key] = value

4
package-lock.json generated
View File

@@ -3469,7 +3469,7 @@
},
"node_modules/@mattermost/react-native-network-client": {
"version": "0.1.0",
"resolved": "git+ssh://git@github.com/mattermost/react-native-network-client.git#7b73186df4b8dd4858544bb426e0f69c1cfb4e2d",
"resolved": "git+ssh://git@github.com/mattermost/react-native-network-client.git#43907501f3547f53434475d44bc969c0d02b4cf7",
"license": "MIT",
"dependencies": {
"validator": "13.6.0",
@@ -27632,7 +27632,7 @@
"requires": {}
},
"@mattermost/react-native-network-client": {
"version": "git+ssh://git@github.com/mattermost/react-native-network-client.git#7b73186df4b8dd4858544bb426e0f69c1cfb4e2d",
"version": "git+ssh://git@github.com/mattermost/react-native-network-client.git#43907501f3547f53434475d44bc969c0d02b4cf7",
"from": "@mattermost/react-native-network-client@github:mattermost/react-native-network-client",
"requires": {
"validator": "13.6.0",

View File

@@ -11,10 +11,18 @@ index bb8f49a..1036aca 100644
jsi?: boolean
}
diff --git a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/Database.kt b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/Database.kt
index 802f137..640b08c 100644
index 802f137..785239e 100644
--- a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/Database.kt
+++ b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/Database.kt
@@ -18,6 +18,18 @@ class Database(private val name: String, private val context: Context) {
@@ -11,17 +11,35 @@ import java.io.File
class Database(private val name: String, private val context: Context) {
private val db: SQLiteDatabase by lazy {
- SQLiteDatabase.openOrCreateDatabase(
+ SQLiteDatabase.openDatabase(
// TODO: This SUCKS. Seems like Android doesn't like sqlite `?mode=memory&cache=shared` mode. To avoid random breakages, save the file to /tmp, but this is slow.
// NOTE: This is because Android system SQLite is not compiled with SQLITE_USE_URI=1
// issue `PRAGMA cache=shared` query after connection when needed
if (name == ":memory:" || name.contains("mode=memory")) {
context.cacheDir.delete()
File(context.cacheDir, name).path
@@ -26,10 +34,21 @@ index 802f137..640b08c 100644
+ val truePath = name.substringAfterLast("file://").substringBeforeLast("/")
+
+ // Creates the directory
+ val fileObj = File(truePath, "databases")
+ fileObj.mkdir()
+ if (!truePath.contains("databases")) {
+ val fileObj = File(truePath, "databases")
+ fileObj.mkdir()
+
+ File("${truePath}/databases", dbName).path
+ File("${truePath}/databases", dbName).path
+ } else {
+ File(truePath, dbName).path
+ }
} else
// On some systems there is some kind of lock on `/databases` folder ¯\_(ツ)_/¯
context.getDatabasePath("$name.db").path.replace("/databases", ""),
- null)
+ null,
+ SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING,
+ )
}
var userVersion: Int

View File

@@ -12,8 +12,6 @@ import {DatabaseType} from './enums';
import type AppDataOperator from '@database/operator/app_data_operator';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Config} from '@typings/database/models/servers/config';
import type {License} from '@typings/database/models/servers/license';
import type System from '@typings/database/models/servers/system';
export type WithDatabaseArgs = { database: Database }
@@ -23,12 +21,14 @@ export type CreateServerDatabaseConfig = {
dbType?: DatabaseType.DEFAULT | DatabaseType.SERVER;
displayName?: string;
serverUrl?: string;
identifier?: string;
};
export type RegisterServerDatabaseArgs = {
databaseFilePath: string;
displayName: string;
serverUrl: string;
identifier?: string;
};
export type AppDatabase = {
@@ -246,9 +246,9 @@ export type HandleDraftArgs = PrepareOnly & {
};
export type LoginArgs = {
config: Partial<Config>;
config: Partial<ClientConfig>;
ldapOnly?: boolean;
license: Partial<License>;
license: Partial<ClientLicense>;
loginId: string;
mfaToken?: string;
password: string;

View File

@@ -17,18 +17,12 @@ export default class ServersModel extends Model {
/** display_name : The server display name */
displayName: string;
/** mention_count : The number of mention on this server */
mentionCount: number;
/** unread_count : The number of unread messages on this server */
unreadCount: number;
/** url : The online address for the Mattermost server */
url: string;
/** last_active_at: The last time this server was active */
lastActiveAt!: number;
/** is_secured: Determines if the protocol used for this server url is HTTP or HTTPS */
isSecured!: boolean;
/** diagnostic_id: Determines the installation identifier of a server */
identifier!: string;
}

View File

@@ -73,4 +73,6 @@ export default class ChannelModel extends Model {
/** settings: User specific settings/preferences for this channel */
settings: Relation<MyChannelSettingsModel>;
toApi = () => Channel;
}

View File

@@ -29,6 +29,9 @@ export default class MyChannelModel extends Model {
/** roles : The user's privileges on this channel */
roles: string;
/** viewed_at : The timestamp showing when the user's last opened this channel (this is used for the new line message indicator) */
viewedAt: number;
/** channel : The relation pointing to the CHANNEL table */
channel: Relation<ChannelModel>;
}

View File

@@ -19,9 +19,11 @@ interface NotificationUserInfo {
}
interface NotificationData {
ack_id?: string;
body?: string;
channel_id: string;
channel_name?: string;
identifier?: string;
from_webhook?: string;
message?: string;
override_icon_url?: string;
@@ -29,6 +31,8 @@ interface NotificationData {
post_id: string;
root_id?: string;
sender_id?: string;
sender_name?: string;
server_id?: string;
server_url?: string;
team_id?: string;
type: string;