forked from Ivasoft/mattermost-mobile
[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:
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
};
|
||||
|
||||
35
app/actions/local/notification.ts
Normal file
35
app/actions/local/notification.ts
Normal 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};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
100
app/actions/remote/entry/app.ts
Normal file
100
app/actions/remote/entry/app.ts
Normal 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};
|
||||
}
|
||||
};
|
||||
189
app/actions/remote/entry/common.ts
Normal file
189
app/actions/remote/entry/common.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
6
app/actions/remote/entry/index.ts
Normal file
6
app/actions/remote/entry/index.ts
Normal 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';
|
||||
178
app/actions/remote/entry/login.ts
Normal file
178
app/actions/remote/entry/login.ts
Normal 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};
|
||||
}
|
||||
};
|
||||
144
app/actions/remote/entry/notification.ts
Normal file
144
app/actions/remote/entry/notification.ts
Normal 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};
|
||||
};
|
||||
139
app/actions/remote/notifications.ts
Normal file
139
app/actions/remote/notifications.ts
Normal 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};
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,7 +63,6 @@ export const SYSTEM_IDENTIFIERS = {
|
||||
|
||||
export const GLOBAL_IDENTIFIERS = {
|
||||
DEVICE_TOKEN: 'deviceToken',
|
||||
MENTION_COUNT: 'mentionCount',
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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__) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ export default async () => {
|
||||
dbName: 'community mattermost',
|
||||
dbType: DatabaseType.SERVER,
|
||||
serverUrl: 'https://comm4.mattermost.com',
|
||||
identifier: 'test-server',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -50,7 +50,7 @@ export const createPostsChain = ({order, posts, previousPostId = ''}: ChainPosts
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [] as Post[]);
|
||||
}, [] as Post[]).reverse();
|
||||
};
|
||||
|
||||
export const getPostListEdges = (posts: Post[]) => {
|
||||
|
||||
@@ -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},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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'},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
40
app/notifications/notifications.android.ts
Normal file
40
app/notifications/notifications.android.ts
Normal 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;
|
||||
16
app/notifications/notifications.ios.ts
Normal file
16
app/notifications/notifications.ios.ts
Normal 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;
|
||||
@@ -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 '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
106
app/screens/in_app_notification/icon.tsx
Normal file
106
app/screens/in_app_notification/icon.tsx
Normal 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);
|
||||
188
app/screens/in_app_notification/index.tsx
Normal file
188
app/screens/in_app_notification/index.tsx
Normal 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;
|
||||
38
app/screens/in_app_notification/server.tsx
Normal file
38
app/screens/in_app_notification/server.tsx
Normal 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;
|
||||
32
app/screens/in_app_notification/title.tsx
Normal file
32
app/screens/in_app_notification/title.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
100
app/utils/notification/index.ts
Normal file
100
app/utils/notification/index.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 |
31
index.ts
31
index.ts
@@ -47,19 +47,24 @@ if (Platform.OS === 'android') {
|
||||
AppRegistry.registerComponent('MattermostShare', () => ShareExtension);
|
||||
}
|
||||
|
||||
let alreadyInitialized = false;
|
||||
Navigation.events().registerAppLaunchedListener(async () => {
|
||||
GlobalEventHandler.init();
|
||||
ManagedApp.init();
|
||||
registerNavigationListeners();
|
||||
registerScreens();
|
||||
// See caution in the library doc https://wix.github.io/react-native-navigation/docs/app-launch#android
|
||||
if (!alreadyInitialized) {
|
||||
alreadyInitialized = true;
|
||||
GlobalEventHandler.init();
|
||||
ManagedApp.init();
|
||||
registerNavigationListeners();
|
||||
registerScreens();
|
||||
|
||||
const serverCredentials = await getAllServerCredentials();
|
||||
const serverUrls = serverCredentials.map((credential) => credential.serverUrl);
|
||||
const serverCredentials = await getAllServerCredentials();
|
||||
const serverUrls = serverCredentials.map((credential) => credential.serverUrl);
|
||||
|
||||
await DatabaseManager.init(serverUrls);
|
||||
await NetworkManager.init(serverCredentials);
|
||||
await WebsocketManager.init(serverCredentials);
|
||||
PushNotifications.init();
|
||||
await DatabaseManager.init(serverUrls);
|
||||
await NetworkManager.init(serverCredentials);
|
||||
await WebsocketManager.init(serverCredentials);
|
||||
PushNotifications.init();
|
||||
}
|
||||
|
||||
initialLaunch();
|
||||
});
|
||||
@@ -76,8 +81,10 @@ function componentWillAppear({componentId}: ComponentDidAppearEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function componentDidAppearListener({componentId}: ComponentDidAppearEvent) {
|
||||
EphemeralStore.addNavigationComponentId(componentId);
|
||||
function componentDidAppearListener({componentId, passProps}: ComponentDidAppearEvent) {
|
||||
if (!(passProps as any)?.overlay) {
|
||||
EphemeralStore.addNavigationComponentId(componentId);
|
||||
}
|
||||
}
|
||||
|
||||
function componentDidDisappearListener({componentId}: ComponentDidDisappearEvent) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
8
types/database/database.d.ts
vendored
8
types/database/database.d.ts
vendored
@@ -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;
|
||||
|
||||
10
types/database/models/app/servers.d.ts
vendored
10
types/database/models/app/servers.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
2
types/database/models/servers/channel.d.ts
vendored
2
types/database/models/servers/channel.d.ts
vendored
@@ -73,4 +73,6 @@ export default class ChannelModel extends Model {
|
||||
|
||||
/** settings: User specific settings/preferences for this channel */
|
||||
settings: Relation<MyChannelSettingsModel>;
|
||||
|
||||
toApi = () => Channel;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
4
types/global/push_notifications.d.ts
vendored
4
types/global/push_notifications.d.ts
vendored
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user