forked from Ivasoft/mattermost-mobile
Compare commits
53 Commits
build-458
...
release-2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15fd6925b3 | ||
|
|
571070e284 | ||
|
|
ab8a43032e | ||
|
|
6904be23da | ||
|
|
6bc7c05ccb | ||
|
|
4b142483a5 | ||
|
|
63674e2a43 | ||
|
|
cdaf1f50e7 | ||
|
|
10735dcbf1 | ||
|
|
619decd253 | ||
|
|
55f18bcfc3 | ||
|
|
870336142a | ||
|
|
27d7875dd7 | ||
|
|
0309d7a60b | ||
|
|
5a497ab15b | ||
|
|
3daa91ce9d | ||
|
|
67a28dd926 | ||
|
|
ef3e45f523 | ||
|
|
b0c61b220c | ||
|
|
139be16b05 | ||
|
|
c7e5adbc3e | ||
|
|
bc4d89c3e1 | ||
|
|
e75791c98d | ||
|
|
bf33df84d6 | ||
|
|
b6f1f8999d | ||
|
|
f6610693e2 | ||
|
|
b90275bff3 | ||
|
|
7c6b34afe3 | ||
|
|
ac3bd14891 | ||
|
|
d61fbd3180 | ||
|
|
2fc1386b78 | ||
|
|
c6dc00e4df | ||
|
|
9f84ab79ce | ||
|
|
98f25046af | ||
|
|
bc3ace278b | ||
|
|
5cdcbfb12a | ||
|
|
854cbbbe7f | ||
|
|
6f6e808cf8 | ||
|
|
95759a5632 | ||
|
|
472ded8cee | ||
|
|
f7c2c9b01a | ||
|
|
b023751656 | ||
|
|
d5a76a6e9b | ||
|
|
22efcbcca7 | ||
|
|
1f9ca219ae | ||
|
|
f769500ba3 | ||
|
|
12aa21018a | ||
|
|
31b3e9cf01 | ||
|
|
39d8394ca8 | ||
|
|
d2124151be | ||
|
|
5427468e2f | ||
|
|
c8f36fb544 | ||
|
|
5ccf042801 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,6 +7,9 @@ mattermost.keystore
|
||||
tmp/
|
||||
.env
|
||||
env.d.ts
|
||||
*.apk
|
||||
*.aab
|
||||
*.ipa
|
||||
|
||||
*/**/compass-icons.ttf
|
||||
|
||||
@@ -30,8 +33,6 @@ xcuserdata
|
||||
*.moved-aside
|
||||
DerivedData
|
||||
*.hmap
|
||||
*.ipa
|
||||
*.apk
|
||||
*.xcuserstate
|
||||
project.xcworkspace
|
||||
ios/Pods
|
||||
|
||||
@@ -110,7 +110,7 @@ android {
|
||||
applicationId "com.mattermost.rnbeta"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 458
|
||||
versionCode 459
|
||||
versionName "2.1.0"
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.mattermost.helpers.database_extension.queryCurrentUserId
|
||||
import com.nozbe.watermelondb.Database
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
import kotlin.math.max
|
||||
|
||||
suspend fun PushNotificationDataRunnable.Companion.fetchMyChannel(db: Database, serverUrl: String, channelId: String, isCRTEnabled: Boolean): Triple<ReadableMap?, ReadableMap?, ReadableArray?> {
|
||||
val channel = fetch(serverUrl, "/api/v4/channels/$channelId")
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getPostById, queryPostsInChannel, queryPostsInThread} from '@queries/servers/post';
|
||||
import {logError} from '@utils/log';
|
||||
|
||||
export const updatePostSinceCache = async (serverUrl: string, notification: NotificationWithData) => {
|
||||
try {
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
if (notification.payload?.channel_id) {
|
||||
const chunks = await queryPostsInChannel(database, notification.payload.channel_id).fetch();
|
||||
if (chunks.length) {
|
||||
const recent = chunks[0];
|
||||
const lastPost = await getPostById(database, notification.payload.post_id);
|
||||
if (lastPost) {
|
||||
await operator.database.write(async () => {
|
||||
await recent.update(() => {
|
||||
recent.latest = lastPost.createAt;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
logError('Failed updatePostSinceCache', error);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const updatePostsInThreadsSinceCache = async (serverUrl: string, notification: NotificationWithData) => {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
try {
|
||||
if (notification.payload?.root_id) {
|
||||
const {database} = operator;
|
||||
const chunks = await queryPostsInThread(database, notification.payload.root_id).fetch();
|
||||
if (chunks.length) {
|
||||
const recent = chunks[0];
|
||||
const lastPost = await getPostById(database, notification.payload.post_id);
|
||||
if (lastPost) {
|
||||
await operator.database.write(async () => {
|
||||
await recent.update(() => {
|
||||
recent.latest = lastPost.createAt;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
@@ -1,87 +1,38 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {dataRetentionCleanup, setLastServerVersionCheck} from '@actions/local/systems';
|
||||
import {setLastServerVersionCheck} from '@actions/local/systems';
|
||||
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {prepareCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, getCurrentChannelId, getConfig, getLicense} from '@queries/servers/system';
|
||||
import {getCurrentUser} from '@queries/servers/user';
|
||||
import {setTeamLoading} from '@store/team_load_store';
|
||||
import WebsocketManager from '@managers/websocket_manager';
|
||||
import {prepareCommonSystemValues} from '@queries/servers/system';
|
||||
import {deleteV1Data} from '@utils/file';
|
||||
import {isTablet} from '@utils/helpers';
|
||||
import {logInfo} from '@utils/log';
|
||||
|
||||
import {handleEntryAfterLoadNavigation, registerDeviceToken, syncOtherServers, verifyPushProxy} from './common';
|
||||
import {deferredAppEntryActions, entry} from './gql_common';
|
||||
import {verifyPushProxy} from './common';
|
||||
|
||||
export async function appEntry(serverUrl: string, since = 0, isUpgrade = false) {
|
||||
export async function appEntry(serverUrl: string, since = 0) {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
if (!since) {
|
||||
registerDeviceToken(serverUrl);
|
||||
if (Object.keys(DatabaseManager.serverDatabases).length === 1) {
|
||||
await setLastServerVersionCheck(serverUrl, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Run data retention cleanup
|
||||
await dataRetentionCleanup(serverUrl);
|
||||
|
||||
// clear lastUnreadChannelId
|
||||
const removeLastUnreadChannelId = await prepareCommonSystemValues(operator, {lastUnreadChannelId: ''});
|
||||
if (removeLastUnreadChannelId) {
|
||||
await operator.batchRecords(removeLastUnreadChannelId, 'appEntry - removeLastUnreadChannelId');
|
||||
}
|
||||
|
||||
const {database} = operator;
|
||||
|
||||
const currentTeamId = await getCurrentTeamId(database);
|
||||
const currentChannelId = await getCurrentChannelId(database);
|
||||
const lastDisconnectedAt = (await getWebSocketLastDisconnected(database)) || since;
|
||||
|
||||
setTeamLoading(serverUrl, true);
|
||||
const entryData = await entry(serverUrl, currentTeamId, currentChannelId, since);
|
||||
if ('error' in entryData) {
|
||||
setTeamLoading(serverUrl, false);
|
||||
return {error: entryData.error};
|
||||
}
|
||||
|
||||
const {models, initialTeamId, initialChannelId, prefData, teamData, chData, meData} = entryData;
|
||||
if (isUpgrade && meData?.user) {
|
||||
const isTabletDevice = await isTablet();
|
||||
const me = await prepareCommonSystemValues(operator, {
|
||||
currentUserId: meData.user.id,
|
||||
currentTeamId: initialTeamId,
|
||||
currentChannelId: isTabletDevice ? initialChannelId : undefined,
|
||||
});
|
||||
if (me?.length) {
|
||||
await operator.batchRecords(me, 'appEntry - upgrade store me');
|
||||
}
|
||||
}
|
||||
|
||||
await handleEntryAfterLoadNavigation(serverUrl, teamData.memberships || [], chData?.memberships || [], currentTeamId, currentChannelId, initialTeamId, initialChannelId);
|
||||
|
||||
const dt = Date.now();
|
||||
await operator.batchRecords(models, 'appEntry');
|
||||
logInfo('ENTRY MODELS BATCHING TOOK', `${Date.now() - dt}ms`);
|
||||
setTeamLoading(serverUrl, false);
|
||||
|
||||
const {id: currentUserId, locale: currentUserLocale} = meData?.user || (await getCurrentUser(database))!;
|
||||
const config = await getConfig(database);
|
||||
const license = await getLicense(database);
|
||||
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId);
|
||||
|
||||
if (!since) {
|
||||
// Load data from other servers
|
||||
syncOtherServers(serverUrl);
|
||||
}
|
||||
WebsocketManager.openAll();
|
||||
|
||||
verifyPushProxy(serverUrl);
|
||||
|
||||
return {userId: currentUserId};
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function upgradeEntry(serverUrl: string) {
|
||||
@@ -89,7 +40,7 @@ export async function upgradeEntry(serverUrl: string) {
|
||||
|
||||
try {
|
||||
const configAndLicense = await fetchConfigAndLicense(serverUrl, false);
|
||||
const entryData = await appEntry(serverUrl, 0, true);
|
||||
const entryData = await appEntry(serverUrl, 0);
|
||||
const error = configAndLicense.error || entryData.error;
|
||||
|
||||
if (!error) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {markChannelAsViewed} from '@actions/local/channel';
|
||||
import {dataRetentionCleanup} from '@actions/local/systems';
|
||||
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, markChannelAsRead, MyChannelsRequest} from '@actions/remote/channel';
|
||||
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, MyChannelsRequest} from '@actions/remote/channel';
|
||||
import {fetchGroupsForMember} from '@actions/remote/groups';
|
||||
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
|
||||
import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
|
||||
@@ -11,8 +9,7 @@ import {fetchRoles} from '@actions/remote/role';
|
||||
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||
import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
|
||||
import {syncTeamThreads} from '@actions/remote/thread';
|
||||
import {autoUpdateTimezone, fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user';
|
||||
import {gqlAllChannels} from '@client/graphQL/entry';
|
||||
import {fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user';
|
||||
import {General, Preferences, Screens} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {PUSH_PROXY_RESPONSE_NOT_AVAILABLE, PUSH_PROXY_RESPONSE_UNKNOWN, PUSH_PROXY_STATUS_NOT_AVAILABLE, PUSH_PROXY_STATUS_UNKNOWN, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
|
||||
@@ -22,16 +19,13 @@ import {selectDefaultTeam} from '@helpers/api/team';
|
||||
import {DEFAULT_LOCALE} from '@i18n';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import {getDeviceToken} from '@queries/app/global';
|
||||
import {getAllServers} from '@queries/app/servers';
|
||||
import {prepareMyChannelsForTeam, queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel';
|
||||
import {queryAllChannelsForTeam, queryChannelsById} from '@queries/servers/channel';
|
||||
import {prepareModels, truncateCrtRelatedTables} from '@queries/servers/entry';
|
||||
import {getHasCRTChanged} from '@queries/servers/preference';
|
||||
import {getConfig, getCurrentChannelId, getCurrentTeamId, getCurrentUserId, getPushVerificationStatus, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||
import {getConfig, getCurrentChannelId, getCurrentTeamId, getPushVerificationStatus, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||
import {deleteMyTeams, getAvailableTeamIds, getTeamChannelHistory, queryMyTeams, queryMyTeamsByIds, queryTeamsById} from '@queries/servers/team';
|
||||
import {getIsCRTEnabled} from '@queries/servers/thread';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
import {isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
|
||||
import {getMemberChannelsFromGQLQuery, gqlToClientChannelMembership} from '@utils/graphql';
|
||||
import {isTablet} from '@utils/helpers';
|
||||
import {logDebug} from '@utils/log';
|
||||
import {processIsCRTEnabled} from '@utils/thread';
|
||||
@@ -112,6 +106,12 @@ export const entryRest = async (serverUrl: string, teamId?: string, channelId?:
|
||||
}
|
||||
|
||||
const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds, isCRTEnabled} = fetchedData;
|
||||
const chError = chData?.error as ClientError | undefined;
|
||||
if (chError?.status_code === 403) {
|
||||
// if the user does not have appropriate permissions, which means the user those not belong to the team,
|
||||
// we set it as there is no errors, so that the teams and others can be properly handled
|
||||
chData!.error = undefined;
|
||||
}
|
||||
const error = teamData.error || chData?.error || prefData.error || meData.error;
|
||||
if (error) {
|
||||
return {error};
|
||||
@@ -312,13 +312,8 @@ export async function restDeferredAppEntryActions(
|
||||
serverUrl: string, since: number, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined,
|
||||
config: ClientConfig, license: ClientLicense | undefined, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined,
|
||||
initialTeamId?: string, initialChannelId?: string) {
|
||||
// defer sidebar DM & GM profiles
|
||||
let channelsToFetchProfiles: Set<Channel>|undefined;
|
||||
setTimeout(async () => {
|
||||
if (chData?.channels?.length && chData.memberships?.length) {
|
||||
const directChannels = chData.channels.filter(isDMorGM);
|
||||
channelsToFetchProfiles = new Set<Channel>(directChannels);
|
||||
|
||||
// defer fetching posts for unread channels on initial team
|
||||
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
|
||||
}
|
||||
@@ -350,8 +345,11 @@ export async function restDeferredAppEntryActions(
|
||||
// Fetch groups for current user
|
||||
fetchGroupsForMember(serverUrl, currentUserId);
|
||||
|
||||
// defer sidebar DM & GM profiles
|
||||
setTimeout(async () => {
|
||||
if (channelsToFetchProfiles?.size) {
|
||||
const directChannels = chData?.channels?.filter(isDMorGM);
|
||||
const channelsToFetchProfiles = new Set<Channel>(directChannels);
|
||||
if (channelsToFetchProfiles.size) {
|
||||
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
|
||||
fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
|
||||
}
|
||||
@@ -374,107 +372,6 @@ export const registerDeviceToken = async (serverUrl: string) => {
|
||||
return {error: undefined};
|
||||
};
|
||||
|
||||
export const syncOtherServers = async (serverUrl: string) => {
|
||||
const servers = await getAllServers();
|
||||
for (const server of servers) {
|
||||
if (server.url !== serverUrl && server.lastActiveAt > 0) {
|
||||
registerDeviceToken(server.url);
|
||||
syncAllChannelMembersAndThreads(server.url).then(() => {
|
||||
dataRetentionCleanup(server.url);
|
||||
});
|
||||
autoUpdateTimezone(server.url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const syncAllChannelMembersAndThreads = async (serverUrl: string) => {
|
||||
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
||||
if (!database) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await getConfig(database);
|
||||
|
||||
if (config?.FeatureFlagGraphQL === 'true') {
|
||||
const error = await graphQLSyncAllChannelMembers(serverUrl);
|
||||
if (error) {
|
||||
logDebug('failed graphQL, falling back to rest', error);
|
||||
restSyncAllChannelMembers(serverUrl);
|
||||
}
|
||||
} else {
|
||||
restSyncAllChannelMembers(serverUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const graphQLSyncAllChannelMembers = async (serverUrl: string) => {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return 'Server database not found';
|
||||
}
|
||||
|
||||
const {database} = operator;
|
||||
|
||||
const response = await gqlAllChannels(serverUrl);
|
||||
if ('error' in response) {
|
||||
return response.error;
|
||||
}
|
||||
|
||||
if (response.errors) {
|
||||
return response.errors[0].message;
|
||||
}
|
||||
|
||||
const userId = await getCurrentUserId(database);
|
||||
|
||||
const channels = getMemberChannelsFromGQLQuery(response.data);
|
||||
const memberships = response.data.channelMembers?.map((m) => gqlToClientChannelMembership(m, userId));
|
||||
|
||||
if (channels && memberships) {
|
||||
const modelPromises = await prepareMyChannelsForTeam(operator, '', channels, memberships, undefined, true);
|
||||
const models = (await Promise.all(modelPromises)).flat();
|
||||
if (models.length) {
|
||||
await operator.batchRecords(models, 'graphQLSyncAllChannelMembers');
|
||||
}
|
||||
}
|
||||
|
||||
const isCRTEnabled = await getIsCRTEnabled(database);
|
||||
if (isCRTEnabled) {
|
||||
const myTeams = await queryMyTeams(operator.database).fetch();
|
||||
for await (const myTeam of myTeams) {
|
||||
// need to await here since GM/DM threads in different teams overlap
|
||||
await syncTeamThreads(serverUrl, myTeam.id);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const restSyncAllChannelMembers = async (serverUrl: string) => {
|
||||
let client;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const myTeams = await client.getMyTeams();
|
||||
const preferences = await client.getMyPreferences();
|
||||
const config = await client.getClientConfigOld();
|
||||
|
||||
let excludeDirect = false;
|
||||
for await (const myTeam of myTeams) {
|
||||
fetchMyChannelsForTeam(serverUrl, myTeam.id, false, 0, false, excludeDirect);
|
||||
excludeDirect = true;
|
||||
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads, config.Version)) {
|
||||
// need to await here since GM/DM threads in different teams overlap
|
||||
await syncTeamThreads(serverUrl, myTeam.id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
};
|
||||
|
||||
export async function verifyPushProxy(serverUrl: string) {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
@@ -539,7 +436,14 @@ export async function handleEntryAfterLoadNavigation(
|
||||
const isThreadsMounted = mountedScreens.includes(Screens.THREAD);
|
||||
const tabletDevice = await isTablet();
|
||||
|
||||
if (currentTeamIdAfterLoad !== currentTeamId) {
|
||||
if (!currentTeamIdAfterLoad) {
|
||||
// First load or no team
|
||||
if (tabletDevice) {
|
||||
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
|
||||
} else {
|
||||
await setCurrentTeamAndChannelId(operator, initialTeamId, '');
|
||||
}
|
||||
} else if (currentTeamIdAfterLoad !== currentTeamId) {
|
||||
// Switched teams while loading
|
||||
if (!teamMembers.find((t) => t.team_id === currentTeamIdAfterLoad && t.delete_at === 0)) {
|
||||
await handleKickFromTeam(serverUrl, currentTeamIdAfterLoad);
|
||||
@@ -561,9 +465,6 @@ export async function handleEntryAfterLoadNavigation(
|
||||
} else {
|
||||
await setCurrentTeamAndChannelId(operator, initialTeamId, initialChannelId);
|
||||
}
|
||||
} else if (tabletDevice && initialChannelId === currentChannelId) {
|
||||
await markChannelAsRead(serverUrl, initialChannelId);
|
||||
markChannelAsViewed(serverUrl, initialChannelId);
|
||||
}
|
||||
} catch (error) {
|
||||
logDebug('could not manage the entry after load navigation', error);
|
||||
|
||||
@@ -7,9 +7,9 @@ import {fetchPostsForUnreadChannels} from '@actions/remote/post';
|
||||
import {fetchDataRetentionPolicy} from '@actions/remote/systems';
|
||||
import {MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
|
||||
import {syncTeamThreads} from '@actions/remote/thread';
|
||||
import {autoUpdateTimezone, updateAllUsersSince} from '@actions/remote/user';
|
||||
import {autoUpdateTimezone, fetchProfilesInGroupChannels, updateAllUsersSince} from '@actions/remote/user';
|
||||
import {gqlEntry, gqlEntryChannels, gqlOtherChannels} from '@client/graphQL/entry';
|
||||
import {Preferences} from '@constants';
|
||||
import {General, Preferences} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getPreferenceValue} from '@helpers/api/preference';
|
||||
import {selectDefaultTeam} from '@helpers/api/team';
|
||||
@@ -28,6 +28,8 @@ import type ClientError from '@client/rest/error';
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
|
||||
const FETCH_MISSING_GM_TIMEOUT = 2500;
|
||||
|
||||
export async function deferredAppEntryGraphQLActions(
|
||||
serverUrl: string,
|
||||
since: number,
|
||||
@@ -98,6 +100,19 @@ export async function deferredAppEntryGraphQLActions(
|
||||
updateCanJoinTeams(serverUrl);
|
||||
updateAllUsersSince(serverUrl, since);
|
||||
|
||||
// defer sidebar GM profiles
|
||||
setTimeout(async () => {
|
||||
const gmIds = chData?.channels?.reduce<Set<string>>((acc, v) => {
|
||||
if (v?.type === General.GM_CHANNEL) {
|
||||
acc.add(v.id);
|
||||
}
|
||||
return acc;
|
||||
}, new Set<string>());
|
||||
if (gmIds?.size) {
|
||||
fetchProfilesInGroupChannels(serverUrl, Array.from(gmIds));
|
||||
}
|
||||
}, FETCH_MISSING_GM_TIMEOUT);
|
||||
|
||||
return {error: undefined};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,81 +1,34 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {switchToChannelById} from '@actions/remote/channel';
|
||||
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import {setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||
import {setTeamLoading} from '@store/team_load_store';
|
||||
import {isTablet} from '@utils/helpers';
|
||||
|
||||
import {deferredAppEntryActions, entry} from './gql_common';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
import {getServerCredentials} from '@init/credentials';
|
||||
import WebsocketManager from '@managers/websocket_manager';
|
||||
|
||||
type AfterLoginArgs = {
|
||||
serverUrl: string;
|
||||
user: UserProfile;
|
||||
deviceToken?: string;
|
||||
}
|
||||
|
||||
export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs): Promise<{error?: any; hasTeams?: boolean; time?: number}> {
|
||||
const dt = Date.now();
|
||||
|
||||
export async function loginEntry({serverUrl}: AfterLoginArgs): Promise<{error?: any}> {
|
||||
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 {
|
||||
const clData = await fetchConfigAndLicense(serverUrl, false);
|
||||
if (clData.error) {
|
||||
return {error: clData.error};
|
||||
}
|
||||
|
||||
setTeamLoading(serverUrl, true);
|
||||
const entryData = await entry(serverUrl, '', '');
|
||||
|
||||
if ('error' in entryData) {
|
||||
setTeamLoading(serverUrl, false);
|
||||
return {error: entryData.error};
|
||||
const credentials = await getServerCredentials(serverUrl);
|
||||
if (credentials?.token) {
|
||||
WebsocketManager.createClient(serverUrl, credentials.token);
|
||||
await WebsocketManager.initializeClient(serverUrl);
|
||||
}
|
||||
|
||||
const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData;
|
||||
|
||||
const isTabletDevice = await isTablet();
|
||||
|
||||
let switchToChannel = false;
|
||||
if (initialChannelId && isTabletDevice) {
|
||||
switchToChannel = true;
|
||||
switchToChannelById(serverUrl, initialChannelId, initialTeamId);
|
||||
} else {
|
||||
setCurrentTeamAndChannelId(operator, initialTeamId, '');
|
||||
}
|
||||
|
||||
await operator.batchRecords(models, 'loginEntry');
|
||||
setTeamLoading(serverUrl, false);
|
||||
|
||||
const config = clData.config || {} as ClientConfig;
|
||||
const license = clData.license || {} as ClientLicense;
|
||||
deferredAppEntryActions(serverUrl, 0, user.id, user.locale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchToChannel ? initialChannelId : undefined);
|
||||
|
||||
return {time: Date.now() - dt, hasTeams: Boolean(teamData.teams?.length)};
|
||||
return {};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
@@ -1,47 +1,44 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {switchToChannelById} from '@actions/remote/channel';
|
||||
import {fetchMyChannel, switchToChannelById} from '@actions/remote/channel';
|
||||
import {fetchPostById} from '@actions/remote/post';
|
||||
import {fetchMyTeam} from '@actions/remote/team';
|
||||
import {fetchAndSwitchToThread} from '@actions/remote/thread';
|
||||
import {Screens} from '@constants';
|
||||
import {getDefaultThemeByAppearance} from '@context/theme';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import WebsocketManager from '@managers/websocket_manager';
|
||||
import {getMyChannel} from '@queries/servers/channel';
|
||||
import {getPostById} from '@queries/servers/post';
|
||||
import {queryThemePreferences} from '@queries/servers/preference';
|
||||
import {getConfig, getCurrentTeamId, getLicense, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||
import {getCurrentTeamId} from '@queries/servers/system';
|
||||
import {getMyTeamById} from '@queries/servers/team';
|
||||
import {getIsCRTEnabled} from '@queries/servers/thread';
|
||||
import {getCurrentUser} from '@queries/servers/user';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
import {setTeamLoading} from '@store/team_load_store';
|
||||
import {isTablet} from '@utils/helpers';
|
||||
import {emitNotificationError} from '@utils/notification';
|
||||
import {setThemeDefaults, updateThemeIfNeeded} from '@utils/theme';
|
||||
|
||||
import {syncOtherServers} from './common';
|
||||
import {deferredAppEntryActions, entry} from './gql_common';
|
||||
import type ClientError from '@client/rest/error';
|
||||
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
||||
import type MyTeamModel from '@typings/database/models/servers/my_team';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
export async function pushNotificationEntry(serverUrl: string, notification: NotificationWithData) {
|
||||
export async function pushNotificationEntry(serverUrl: string, notification: NotificationData) {
|
||||
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 rootId = notification.payload!.root_id!;
|
||||
const channelId = notification.channel_id!;
|
||||
const rootId = notification.root_id!;
|
||||
const {database} = operator;
|
||||
const currentTeamId = await getCurrentTeamId(database);
|
||||
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
|
||||
const lastDisconnectedAt = await getWebSocketLastDisconnected(database);
|
||||
|
||||
let isDirectChannel = false;
|
||||
|
||||
let teamId = notification.payload?.team_id;
|
||||
let teamId = notification.team_id;
|
||||
if (!teamId) {
|
||||
// If the notification payload does not have a teamId we assume is a DM/GM
|
||||
isDirectChannel = true;
|
||||
teamId = currentTeamId;
|
||||
}
|
||||
|
||||
@@ -61,91 +58,62 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not
|
||||
updateThemeIfNeeded(theme, true);
|
||||
}
|
||||
|
||||
await NavigationStore.waitUntilScreenHasLoaded(Screens.HOME);
|
||||
|
||||
// To make the switch faster we determine if we already have the team & channel
|
||||
const myChannel = await getMyChannel(database, channelId);
|
||||
const myTeam = await getMyTeamById(database, teamId);
|
||||
let myChannel: MyChannelModel | ChannelMembership | undefined = await getMyChannel(database, channelId);
|
||||
let myTeam: MyTeamModel | TeamMembership | undefined = await getMyTeamById(database, teamId);
|
||||
|
||||
if (!myTeam) {
|
||||
const resp = await fetchMyTeam(serverUrl, teamId);
|
||||
if (resp.error) {
|
||||
if ((resp.error as ClientError).status_code === 403) {
|
||||
emitNotificationError('Team');
|
||||
} else {
|
||||
emitNotificationError('Connection');
|
||||
}
|
||||
} else {
|
||||
myTeam = resp.memberships?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!myChannel) {
|
||||
const resp = await fetchMyChannel(serverUrl, teamId, channelId);
|
||||
if (resp.error) {
|
||||
if ((resp.error as ClientError).status_code === 403) {
|
||||
emitNotificationError('Channel');
|
||||
} else {
|
||||
emitNotificationError('Connection');
|
||||
}
|
||||
} else {
|
||||
myChannel = resp.memberships?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
const isCRTEnabled = await getIsCRTEnabled(database);
|
||||
const isThreadNotification = isCRTEnabled && Boolean(rootId);
|
||||
|
||||
let switchedToScreen = false;
|
||||
let switchedToChannel = false;
|
||||
if (myChannel && myTeam) {
|
||||
if (isThreadNotification) {
|
||||
await fetchAndSwitchToThread(serverUrl, rootId, true);
|
||||
} else {
|
||||
switchedToChannel = true;
|
||||
await switchToChannelById(serverUrl, channelId, teamId);
|
||||
}
|
||||
switchedToScreen = true;
|
||||
}
|
||||
let post: PostModel | Post | undefined = await getPostById(database, rootId);
|
||||
if (!post) {
|
||||
const resp = await fetchPostById(serverUrl, rootId);
|
||||
post = resp.post;
|
||||
}
|
||||
|
||||
setTeamLoading(serverUrl, true);
|
||||
const entryData = await entry(serverUrl, teamId, channelId);
|
||||
if ('error' in entryData) {
|
||||
setTeamLoading(serverUrl, false);
|
||||
return {error: entryData.error};
|
||||
}
|
||||
const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData;
|
||||
const actualRootId = post && ('root_id' in post ? post.root_id : post.rootId);
|
||||
|
||||
// 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) {
|
||||
selectedChannelId = initialChannelId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!switchedToScreen) {
|
||||
const isTabletDevice = await isTablet();
|
||||
if (isTabletDevice || (channelId === selectedChannelId)) {
|
||||
// Make switch again to get the missing data and make sure the team is the correct one
|
||||
switchedToScreen = true;
|
||||
if (isThreadNotification) {
|
||||
if (actualRootId) {
|
||||
await fetchAndSwitchToThread(serverUrl, actualRootId, true);
|
||||
} else if (post) {
|
||||
await fetchAndSwitchToThread(serverUrl, rootId, true);
|
||||
} else {
|
||||
switchedToChannel = true;
|
||||
await switchToChannelById(serverUrl, channelId, teamId);
|
||||
emitNotificationError('Post');
|
||||
}
|
||||
} else if (teamId !== selectedTeamId || channelId !== selectedChannelId) {
|
||||
// If in the end the selected team or channel is different than the one from the notification
|
||||
// we switch again
|
||||
await setCurrentTeamAndChannelId(operator, selectedTeamId, selectedChannelId);
|
||||
} else {
|
||||
await switchToChannelById(serverUrl, channelId, teamId);
|
||||
}
|
||||
}
|
||||
|
||||
if (teamId !== selectedTeamId) {
|
||||
emitNotificationError('Team');
|
||||
} else if (channelId !== selectedChannelId) {
|
||||
emitNotificationError('Channel');
|
||||
}
|
||||
WebsocketManager.openAll();
|
||||
|
||||
// Waiting for the screen to display fixes a race condition when fetching and storing data
|
||||
if (switchedToChannel) {
|
||||
await NavigationStore.waitUntilScreenHasLoaded(Screens.CHANNEL);
|
||||
} else if (switchedToScreen && isThreadNotification) {
|
||||
await NavigationStore.waitUntilScreenHasLoaded(Screens.THREAD);
|
||||
}
|
||||
|
||||
await operator.batchRecords(models, 'pushNotificationEntry');
|
||||
setTeamLoading(serverUrl, false);
|
||||
|
||||
const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(operator.database))!;
|
||||
const config = await getConfig(database);
|
||||
const license = await getLicense(database);
|
||||
|
||||
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, selectedTeamId, selectedChannelId);
|
||||
|
||||
syncOtherServers(serverUrl);
|
||||
|
||||
return {userId: currentUserId};
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export const backgroundNotification = async (serverUrl: string, notification: No
|
||||
serverUrl, teamId,
|
||||
channel ? [channel] : [],
|
||||
myChannel ? [myChannel] : [],
|
||||
true, isCRTEnabled,
|
||||
false, isCRTEnabled,
|
||||
);
|
||||
|
||||
if (data.categoryChannels?.length && channel) {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {General, Preferences} from '@constants';
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {handleReconnect} from '@actions/websocket';
|
||||
import {Events, General, Preferences} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import {getChannelById} from '@queries/servers/channel';
|
||||
import {truncateCrtRelatedTables} from '@queries/servers/entry';
|
||||
import {querySavedPostsPreferences} from '@queries/servers/preference';
|
||||
import {getCurrentUserId} from '@queries/servers/system';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import {getUserIdFromChannelName} from '@utils/user';
|
||||
|
||||
import {forceLogoutIfNecessary} from './session';
|
||||
@@ -185,3 +190,11 @@ export const savePreferredSkinTone = async (serverUrl: string, skinCode: string)
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const handleCRTToggled = async (serverUrl: string) => {
|
||||
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
|
||||
await truncateCrtRelatedTables(serverUrl);
|
||||
await handleReconnect(serverUrl);
|
||||
EphemeralStore.setEnablingCRT(false);
|
||||
DeviceEventEmitter.emit(Events.CRT_TOGGLED, serverUrl === currentServerUrl);
|
||||
};
|
||||
|
||||
@@ -7,16 +7,14 @@ import {DeviceEventEmitter, Platform} from 'react-native';
|
||||
import {Database, Events} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getServerCredentials} from '@init/credentials';
|
||||
import PushNotifications from '@init/push_notifications';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
import WebsocketManager from '@managers/websocket_manager';
|
||||
import {getDeviceToken} from '@queries/app/global';
|
||||
import {getServerDisplayName} from '@queries/app/servers';
|
||||
import {getCurrentUserId, getExpiredSession, getConfig, getLicense} from '@queries/servers/system';
|
||||
import {getCurrentUserId, getExpiredSession} from '@queries/servers/system';
|
||||
import {getCurrentUser} from '@queries/servers/user';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
import {logWarning, logError} from '@utils/log';
|
||||
import {logWarning, logError, logDebug} from '@utils/log';
|
||||
import {scheduleExpiredNotification} from '@utils/notification';
|
||||
import {getCSRFFromCookie} from '@utils/security';
|
||||
|
||||
@@ -27,47 +25,25 @@ import type {LoginArgs} from '@typings/database/database';
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
|
||||
export const completeLogin = async (serverUrl: string) => {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
export const addPushProxyVerificationStateFromLogin = async (serverUrl: string) => {
|
||||
try {
|
||||
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
||||
const systems: IdValue[] = [];
|
||||
|
||||
// Set push proxy verification
|
||||
const ppVerification = EphemeralStore.getPushProxyVerificationState(serverUrl);
|
||||
if (ppVerification) {
|
||||
systems.push({id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: ppVerification});
|
||||
}
|
||||
|
||||
if (systems.length) {
|
||||
await operator.handleSystem({systems, prepareRecordsOnly: false});
|
||||
}
|
||||
} catch (error) {
|
||||
logDebug('error setting the push proxy verification state on login', error);
|
||||
}
|
||||
|
||||
const {database} = operator;
|
||||
const license = await getLicense(database);
|
||||
const config = await getConfig(database);
|
||||
|
||||
if (!Object.keys(config)?.length || !license || !Object.keys(license)?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await DatabaseManager.setActiveServerDatabase(serverUrl);
|
||||
|
||||
const systems: IdValue[] = [];
|
||||
|
||||
// Set push proxy verification
|
||||
const ppVerification = EphemeralStore.getPushProxyVerificationState(serverUrl);
|
||||
if (ppVerification) {
|
||||
systems.push({id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: ppVerification});
|
||||
}
|
||||
|
||||
// Start websocket
|
||||
const credentials = await getServerCredentials(serverUrl);
|
||||
if (credentials?.token) {
|
||||
WebsocketManager.createClient(serverUrl, credentials.token);
|
||||
systems.push({
|
||||
id: SYSTEM_IDENTIFIERS.WEBSOCKET,
|
||||
value: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (systems.length) {
|
||||
operator.handleSystem({systems, prepareRecordsOnly: false});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const forceLogoutIfNecessary = async (serverUrl: string, err: ClientErrorProps) => {
|
||||
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
||||
if (!database) {
|
||||
@@ -151,11 +127,12 @@ export const login = async (serverUrl: string, {ldapOnly = false, loginId, mfaTo
|
||||
}
|
||||
|
||||
try {
|
||||
const {error, hasTeams, time} = await loginEntry({serverUrl, user});
|
||||
completeLogin(serverUrl);
|
||||
return {error: error as ClientError, failed: false, hasTeams, time};
|
||||
await addPushProxyVerificationStateFromLogin(serverUrl);
|
||||
const {error} = await loginEntry({serverUrl});
|
||||
await DatabaseManager.setActiveServerDatabase(serverUrl);
|
||||
return {error: error as ClientError, failed: false};
|
||||
} catch (error) {
|
||||
return {error: error as ClientError, failed: false, time: 0};
|
||||
return {error: error as ClientError, failed: false};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -251,7 +228,6 @@ export const sendPasswordResetEmail = async (serverUrl: string, email: string) =
|
||||
};
|
||||
|
||||
export const ssoLogin = async (serverUrl: string, serverDisplayName: string, serverIdentifier: string, bearerToken: string, csrfToken: string): Promise<LoginActionResponse> => {
|
||||
let deviceToken;
|
||||
let user;
|
||||
|
||||
const database = DatabaseManager.appDatabase?.database;
|
||||
@@ -279,7 +255,6 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser
|
||||
displayName: serverDisplayName,
|
||||
},
|
||||
});
|
||||
deviceToken = await getDeviceToken();
|
||||
user = await client.getMe();
|
||||
await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false});
|
||||
await server?.operator.handleSystem({
|
||||
@@ -294,11 +269,12 @@ export const ssoLogin = async (serverUrl: string, serverDisplayName: string, ser
|
||||
}
|
||||
|
||||
try {
|
||||
const {error, hasTeams, time} = await loginEntry({serverUrl, user, deviceToken});
|
||||
completeLogin(serverUrl);
|
||||
return {error: error as ClientError, failed: false, hasTeams, time};
|
||||
await addPushProxyVerificationStateFromLogin(serverUrl);
|
||||
const {error} = await loginEntry({serverUrl});
|
||||
await DatabaseManager.setActiveServerDatabase(serverUrl);
|
||||
return {error: error as ClientError, failed: false};
|
||||
} catch (error) {
|
||||
return {error: error as ClientError, failed: false, time: 0};
|
||||
return {error: error as ClientError, failed: false};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {markChannelAsViewed} from '@actions/local/channel';
|
||||
import {dataRetentionCleanup} from '@actions/local/systems';
|
||||
import {markChannelAsRead} from '@actions/remote/channel';
|
||||
import {handleEntryAfterLoadNavigation} from '@actions/remote/entry/common';
|
||||
import {handleEntryAfterLoadNavigation, registerDeviceToken} from '@actions/remote/entry/common';
|
||||
import {deferredAppEntryActions, entry} from '@actions/remote/entry/gql_common';
|
||||
import {fetchPostsForChannel, fetchPostThread} from '@actions/remote/post';
|
||||
import {fetchStatusByIds} from '@actions/remote/user';
|
||||
import {autoUpdateTimezone} from '@actions/remote/user';
|
||||
import {loadConfigAndCalls} from '@calls/actions/calls';
|
||||
import {
|
||||
handleCallChannelDisabled,
|
||||
@@ -32,17 +33,15 @@ import {Screens, WebsocketEvents} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import AppsManager from '@managers/apps_manager';
|
||||
import {getCurrentChannel} from '@queries/servers/channel';
|
||||
import {getLastPostInThread} from '@queries/servers/post';
|
||||
import {
|
||||
getConfig,
|
||||
getCurrentChannelId,
|
||||
getCurrentUserId,
|
||||
getCurrentTeamId,
|
||||
getLicense,
|
||||
getWebSocketLastDisconnected,
|
||||
resetWebSocketLastDisconnected,
|
||||
} from '@queries/servers/system';
|
||||
import {getCurrentTeam} from '@queries/servers/team';
|
||||
import {getIsCRTEnabled} from '@queries/servers/thread';
|
||||
import {getCurrentUser} from '@queries/servers/user';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
@@ -72,36 +71,14 @@ import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent,
|
||||
import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads';
|
||||
import {handleUserUpdatedEvent, handleUserTypingEvent} from './users';
|
||||
|
||||
// ESR: 5.37
|
||||
const alreadyConnected = new Set<string>();
|
||||
|
||||
export async function handleFirstConnect(serverUrl: string) {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return;
|
||||
}
|
||||
const {database} = operator;
|
||||
const config = await getConfig(database);
|
||||
const lastDisconnect = await getWebSocketLastDisconnected(database);
|
||||
|
||||
// ESR: 5.37
|
||||
if (lastDisconnect && config?.EnableReliableWebSockets !== 'true' && alreadyConnected.has(serverUrl)) {
|
||||
await handleReconnect(serverUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
alreadyConnected.add(serverUrl);
|
||||
resetWebSocketLastDisconnected(operator);
|
||||
fetchStatusByIds(serverUrl, ['me']);
|
||||
|
||||
if (isSupportedServerCalls(config?.Version)) {
|
||||
const currentUserId = await getCurrentUserId(database);
|
||||
loadConfigAndCalls(serverUrl, currentUserId);
|
||||
}
|
||||
registerDeviceToken(serverUrl);
|
||||
autoUpdateTimezone(serverUrl);
|
||||
return doReconnect(serverUrl);
|
||||
}
|
||||
|
||||
export async function handleReconnect(serverUrl: string) {
|
||||
await doReconnect(serverUrl);
|
||||
return doReconnect(serverUrl);
|
||||
}
|
||||
|
||||
export async function handleClose(serverUrl: string, lastDisconnect: number) {
|
||||
@@ -123,12 +100,12 @@ export async function handleClose(serverUrl: string, lastDisconnect: number) {
|
||||
async function doReconnect(serverUrl: string) {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return;
|
||||
return new Error('cannot find server database');
|
||||
}
|
||||
|
||||
const appDatabase = DatabaseManager.appDatabase?.database;
|
||||
if (!appDatabase) {
|
||||
return;
|
||||
return new Error('cannot find app database');
|
||||
}
|
||||
|
||||
const {database} = operator;
|
||||
@@ -136,21 +113,30 @@ async function doReconnect(serverUrl: string) {
|
||||
const lastDisconnectedAt = await getWebSocketLastDisconnected(database);
|
||||
resetWebSocketLastDisconnected(operator);
|
||||
|
||||
const currentTeam = await getCurrentTeam(database);
|
||||
const currentChannel = await getCurrentChannel(database);
|
||||
const currentTeamId = await getCurrentTeamId(database);
|
||||
const currentChannelId = await getCurrentChannelId(database);
|
||||
|
||||
setTeamLoading(serverUrl, true);
|
||||
const entryData = await entry(serverUrl, currentTeam?.id, currentChannel?.id, lastDisconnectedAt);
|
||||
const entryData = await entry(serverUrl, currentTeamId, currentChannelId, lastDisconnectedAt);
|
||||
if ('error' in entryData) {
|
||||
setTeamLoading(serverUrl, false);
|
||||
return;
|
||||
return entryData.error;
|
||||
}
|
||||
const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData;
|
||||
|
||||
await handleEntryAfterLoadNavigation(serverUrl, teamData.memberships || [], chData?.memberships || [], currentTeam?.id || '', currentChannel?.id || '', initialTeamId, initialChannelId);
|
||||
await handleEntryAfterLoadNavigation(serverUrl, teamData.memberships || [], chData?.memberships || [], currentTeamId || '', currentChannelId || '', initialTeamId, initialChannelId);
|
||||
|
||||
const dt = Date.now();
|
||||
await operator.batchRecords(models, 'doReconnect');
|
||||
if (models?.length) {
|
||||
await operator.batchRecords(models, 'doReconnect');
|
||||
}
|
||||
|
||||
const tabletDevice = await isTablet();
|
||||
if (tabletDevice && initialChannelId === currentChannelId) {
|
||||
await markChannelAsRead(serverUrl, initialChannelId);
|
||||
markChannelAsViewed(serverUrl, initialChannelId);
|
||||
}
|
||||
|
||||
logInfo('WEBSOCKET RECONNECT MODELS BATCHING TOOK', `${Date.now() - dt}ms`);
|
||||
setTeamLoading(serverUrl, false);
|
||||
|
||||
@@ -166,7 +152,10 @@ async function doReconnect(serverUrl: string) {
|
||||
|
||||
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId);
|
||||
|
||||
dataRetentionCleanup(serverUrl);
|
||||
|
||||
AppsManager.refreshAppBindings(serverUrl);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
|
||||
|
||||
@@ -1,48 +1,32 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {updateDmGmDisplayName} from '@actions/local/channel';
|
||||
import {appEntry} from '@actions/remote/entry';
|
||||
import {fetchPostById} from '@actions/remote/post';
|
||||
import {Events, Preferences} from '@constants';
|
||||
import {handleCRTToggled} from '@actions/remote/preference';
|
||||
import {Preferences} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {truncateCrtRelatedTables} from '@queries/servers/entry';
|
||||
import {getPostById} from '@queries/servers/post';
|
||||
import {deletePreferences, differsFromLocalNameFormat, getHasCRTChanged} from '@queries/servers/preference';
|
||||
|
||||
async function handleCRTToggled(serverUrl: string) {
|
||||
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
|
||||
await truncateCrtRelatedTables(serverUrl);
|
||||
appEntry(serverUrl);
|
||||
DeviceEventEmitter.emit(Events.CRT_TOGGLED, serverUrl === currentServerUrl);
|
||||
}
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
|
||||
export async function handlePreferenceChangedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
|
||||
let database;
|
||||
let operator;
|
||||
try {
|
||||
const result = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
database = result.database;
|
||||
operator = result.operator;
|
||||
} catch (e) {
|
||||
if (EphemeralStore.isEnablingCRT()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const preference: PreferenceType = JSON.parse(msg.data.preference);
|
||||
handleSavePostAdded(serverUrl, [preference]);
|
||||
|
||||
const hasDiffNameFormatPref = await differsFromLocalNameFormat(database, [preference]);
|
||||
const crtToggled = await getHasCRTChanged(database, [preference]);
|
||||
|
||||
if (operator) {
|
||||
await operator.handlePreferences({
|
||||
prepareRecordsOnly: false,
|
||||
preferences: [preference],
|
||||
});
|
||||
}
|
||||
await operator.handlePreferences({
|
||||
prepareRecordsOnly: false,
|
||||
preferences: [preference],
|
||||
});
|
||||
|
||||
if (hasDiffNameFormatPref) {
|
||||
updateDmGmDisplayName(serverUrl);
|
||||
@@ -57,22 +41,22 @@ export async function handlePreferenceChangedEvent(serverUrl: string, msg: WebSo
|
||||
}
|
||||
|
||||
export async function handlePreferencesChangedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
if (EphemeralStore.isEnablingCRT()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const preferences: PreferenceType[] = JSON.parse(msg.data.preferences);
|
||||
handleSavePostAdded(serverUrl, preferences);
|
||||
|
||||
const hasDiffNameFormatPref = await differsFromLocalNameFormat(operator.database, preferences);
|
||||
const crtToggled = await getHasCRTChanged(operator.database, preferences);
|
||||
if (operator) {
|
||||
await operator.handlePreferences({
|
||||
prepareRecordsOnly: false,
|
||||
preferences,
|
||||
});
|
||||
}
|
||||
const hasDiffNameFormatPref = await differsFromLocalNameFormat(database, preferences);
|
||||
const crtToggled = await getHasCRTChanged(database, preferences);
|
||||
|
||||
await operator.handlePreferences({
|
||||
prepareRecordsOnly: false,
|
||||
preferences,
|
||||
});
|
||||
|
||||
if (hasDiffNameFormatPref) {
|
||||
updateDmGmDisplayName(serverUrl);
|
||||
@@ -87,14 +71,10 @@ export async function handlePreferencesChangedEvent(serverUrl: string, msg: WebS
|
||||
}
|
||||
|
||||
export async function handlePreferencesDeletedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
|
||||
const database = DatabaseManager.serverDatabases[serverUrl];
|
||||
if (!database) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const databaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const preferences: PreferenceType[] = JSON.parse(msg.data.preferences);
|
||||
deletePreferences(database, preferences);
|
||||
deletePreferences(databaseAndOperator, preferences);
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
@@ -102,16 +82,17 @@ export async function handlePreferencesDeletedEvent(serverUrl: string, msg: WebS
|
||||
|
||||
// If preferences include new save posts we fetch them
|
||||
async function handleSavePostAdded(serverUrl: string, preferences: PreferenceType[]) {
|
||||
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
||||
if (!database) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const savedPosts = preferences.filter((p) => p.category === Preferences.CATEGORIES.SAVED_POST);
|
||||
|
||||
const savedPosts = preferences.filter((p) => p.category === Preferences.CATEGORIES.SAVED_POST);
|
||||
for await (const saved of savedPosts) {
|
||||
const post = await getPostById(database, saved.name);
|
||||
if (!post) {
|
||||
await fetchPostById(serverUrl, saved.name, false);
|
||||
for await (const saved of savedPosts) {
|
||||
const post = await getPostById(database, saved.name);
|
||||
if (!post) {
|
||||
await fetchPostById(serverUrl, saved.name, false);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {markTeamThreadsAsRead, processReceivedThreads, updateThread} from '@actions/local/thread';
|
||||
import {getCurrentTeamId} from '@app/queries/servers/system';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getCurrentTeamId} from '@queries/servers/system';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
|
||||
export async function handleThreadUpdatedEvent(serverUrl: string, msg: WebSocketMessage): Promise<void> {
|
||||
|
||||
@@ -6,17 +6,19 @@ import {Platform} from 'react-native';
|
||||
|
||||
import {WebsocketEvents} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getConfig} from '@queries/servers/system';
|
||||
import {getConfigValue} from '@queries/servers/system';
|
||||
import {hasReliableWebsocket} from '@utils/config';
|
||||
import {toMilliseconds} from '@utils/datetime';
|
||||
import {logError, logInfo, logWarning} from '@utils/log';
|
||||
|
||||
const MAX_WEBSOCKET_FAILS = 7;
|
||||
const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec
|
||||
|
||||
const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins
|
||||
const WEBSOCKET_TIMEOUT = toMilliseconds({seconds: 30});
|
||||
const MIN_WEBSOCKET_RETRY_TIME = toMilliseconds({seconds: 3});
|
||||
const MAX_WEBSOCKET_RETRY_TIME = toMilliseconds({minutes: 5});
|
||||
|
||||
export default class WebSocketClient {
|
||||
private conn?: WebSocketClientInterface;
|
||||
private connectionTimeout: any;
|
||||
private connectionTimeout: NodeJS.Timeout | undefined;
|
||||
private connectionId: string;
|
||||
private token: string;
|
||||
|
||||
@@ -43,6 +45,7 @@ export default class WebSocketClient {
|
||||
private url = '';
|
||||
|
||||
private serverUrl: string;
|
||||
private hasReliablyReconnect = false;
|
||||
|
||||
constructor(serverUrl: string, token: string, lastDisconnect = 0) {
|
||||
this.connectionId = '';
|
||||
@@ -76,8 +79,12 @@ export default class WebSocketClient {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await getConfig(database);
|
||||
const connectionUrl = (config.WebsocketURL || this.serverUrl) + '/api/v4/websocket';
|
||||
const [websocketUrl, version, reliableWebsocketConfig] = await Promise.all([
|
||||
getConfigValue(database, 'WebsocketURL'),
|
||||
getConfigValue(database, 'Version'),
|
||||
getConfigValue(database, 'EnableReliableWebSockets'),
|
||||
]);
|
||||
const connectionUrl = (websocketUrl || this.serverUrl) + '/api/v4/websocket';
|
||||
|
||||
if (this.connectingCallback) {
|
||||
this.connectingCallback();
|
||||
@@ -98,7 +105,7 @@ export default class WebSocketClient {
|
||||
|
||||
this.url = connectionUrl;
|
||||
|
||||
const reliableWebSockets = config.EnableReliableWebSockets === 'true';
|
||||
const reliableWebSockets = hasReliableWebsocket(version, reliableWebsocketConfig);
|
||||
if (reliableWebSockets) {
|
||||
// Add connection id, and last_sequence_number to the query param.
|
||||
// We cannot also send it as part of the auth_challenge, because the session cookie is already sent with the request.
|
||||
@@ -125,7 +132,12 @@ export default class WebSocketClient {
|
||||
// iOS is using he underlying cookieJar
|
||||
headers.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
const {client} = await getOrCreateWebSocketClient(this.url, {headers});
|
||||
const {client} = await getOrCreateWebSocketClient(this.url, {headers, timeoutInterval: WEBSOCKET_TIMEOUT});
|
||||
|
||||
// Check again if the client is the same, to avoid race conditions
|
||||
if (this.conn === client) {
|
||||
return;
|
||||
}
|
||||
this.conn = client;
|
||||
} catch (error) {
|
||||
return;
|
||||
@@ -154,6 +166,7 @@ export default class WebSocketClient {
|
||||
if (this.serverSequence && this.missedEventsCallback) {
|
||||
this.missedEventsCallback();
|
||||
}
|
||||
this.hasReliablyReconnect = true;
|
||||
}
|
||||
} else if (this.firstConnectCallback) {
|
||||
logInfo('websocket connected to', this.url);
|
||||
@@ -171,6 +184,7 @@ export default class WebSocketClient {
|
||||
|
||||
this.conn = undefined;
|
||||
this.responseSequence = 1;
|
||||
this.hasReliablyReconnect = false;
|
||||
|
||||
if (this.connectFailCount === 0) {
|
||||
logInfo('websocket closed', this.url);
|
||||
@@ -203,7 +217,9 @@ export default class WebSocketClient {
|
||||
this.connectionTimeout = setTimeout(
|
||||
() => {
|
||||
if (this.stop) {
|
||||
clearTimeout(this.connectionTimeout);
|
||||
if (this.connectionTimeout) {
|
||||
clearTimeout(this.connectionTimeout);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.initialize(opts);
|
||||
@@ -214,6 +230,7 @@ export default class WebSocketClient {
|
||||
|
||||
this.conn!.onError((evt: any) => {
|
||||
if (evt.url === this.url) {
|
||||
this.hasReliablyReconnect = false;
|
||||
if (this.connectFailCount <= 1) {
|
||||
logError('websocket error', this.url);
|
||||
logError('WEBSOCKET ERROR EVENT', evt);
|
||||
@@ -243,11 +260,17 @@ export default class WebSocketClient {
|
||||
|
||||
// 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.
|
||||
// If the server is not available the first time we try to connect, we won't have a connection id
|
||||
// but still we need to sync.
|
||||
// Then we do the sync calls, and reset sequence number to 0.
|
||||
if (this.connectionId !== '' && this.connectionId !== msg.data.connection_id) {
|
||||
logInfo(this.url, 'long timeout, or server restart, or sequence number is not found.');
|
||||
if (
|
||||
(this.connectionId !== '' && this.connectionId !== msg.data.connection_id) ||
|
||||
(this.hasReliablyReconnect && this.connectionId === '')
|
||||
) {
|
||||
logInfo(this.url, 'long timeout, or server restart, or sequence number is not found, or first connect after failure.');
|
||||
this.reconnectCallback();
|
||||
this.serverSequence = 0;
|
||||
this.hasReliablyReconnect = false;
|
||||
}
|
||||
|
||||
// If it's a fresh connection, we have to set the connectionId regardless.
|
||||
@@ -315,6 +338,7 @@ export default class WebSocketClient {
|
||||
this.stop = stop;
|
||||
this.connectFailCount = 0;
|
||||
this.responseSequence = 1;
|
||||
this.hasReliablyReconnect = false;
|
||||
|
||||
if (this.conn && this.conn.readyState === WebSocketReadyState.OPEN) {
|
||||
this.conn.close();
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import type {MessageDescriptor} from '@formatjs/intl/src/types';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
header: {
|
||||
marginHorizontal: 15,
|
||||
marginBottom: 10,
|
||||
fontSize: 13,
|
||||
color: changeOpacity(theme.centerChannelColor, 0.5),
|
||||
},
|
||||
footer: {
|
||||
marginTop: 10,
|
||||
marginHorizontal: 15,
|
||||
fontSize: 12,
|
||||
color: changeOpacity(theme.centerChannelColor, 0.5),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export type SectionText = {
|
||||
id: string;
|
||||
defaultMessage: string;
|
||||
values?: MessageDescriptor;
|
||||
}
|
||||
|
||||
export type BlockProps = {
|
||||
children: React.ReactNode;
|
||||
disableFooter?: boolean;
|
||||
disableHeader?: boolean;
|
||||
footerText?: SectionText;
|
||||
headerText?: SectionText;
|
||||
containerStyles?: StyleProp<ViewStyle>;
|
||||
headerStyles?: StyleProp<TextStyle>;
|
||||
footerStyles?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
const Block = ({
|
||||
children,
|
||||
containerStyles,
|
||||
disableFooter,
|
||||
disableHeader,
|
||||
footerText,
|
||||
headerStyles,
|
||||
headerText,
|
||||
footerStyles,
|
||||
}: BlockProps) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{(headerText && !disableHeader) &&
|
||||
<FormattedText
|
||||
defaultMessage={headerText.defaultMessage}
|
||||
id={headerText.id}
|
||||
values={headerText.values}
|
||||
style={[styles.header, headerStyles]}
|
||||
/>
|
||||
}
|
||||
<View style={containerStyles}>
|
||||
{children}
|
||||
</View>
|
||||
{(footerText && !disableFooter) &&
|
||||
<FormattedText
|
||||
defaultMessage={footerText.defaultMessage}
|
||||
id={footerText.id}
|
||||
style={[styles.footer, footerStyles]}
|
||||
values={footerText.values}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Block;
|
||||
@@ -2,12 +2,15 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {StyleProp, Text, TextStyle, ViewStyle} from 'react-native';
|
||||
import {StyleProp, StyleSheet, Text, TextStyle, View, ViewStyle} from 'react-native';
|
||||
import RNButton from 'react-native-button';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
|
||||
|
||||
type Props = {
|
||||
type ConditionalProps = | {iconName: string; iconSize: number} | {iconName?: never; iconSize?: never}
|
||||
|
||||
type Props = ConditionalProps & {
|
||||
theme: Theme;
|
||||
backgroundStyle?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
@@ -20,6 +23,11 @@ type Props = {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {flexDirection: 'row'},
|
||||
icon: {marginRight: 7},
|
||||
});
|
||||
|
||||
const Button = ({
|
||||
theme,
|
||||
backgroundStyle,
|
||||
@@ -31,6 +39,8 @@ const Button = ({
|
||||
onPress,
|
||||
text,
|
||||
testID,
|
||||
iconName,
|
||||
iconSize,
|
||||
}: Props) => {
|
||||
const bgStyle = useMemo(() => [
|
||||
buttonBackgroundStyle(theme, size, emphasis, buttonType, buttonState),
|
||||
@@ -48,12 +58,22 @@ const Button = ({
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
>
|
||||
<Text
|
||||
style={txtStyle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
<View style={styles.container}>
|
||||
{Boolean(iconName) &&
|
||||
<CompassIcon
|
||||
name={iconName!}
|
||||
size={iconSize}
|
||||
color={StyleSheet.flatten(txtStyle).color}
|
||||
style={styles.icon}
|
||||
/>
|
||||
}
|
||||
<Text
|
||||
style={txtStyle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
</RNButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import {switchMap, distinctUntilChanged} from 'rxjs/operators';
|
||||
import {observeChannelsWithCalls} from '@calls/state';
|
||||
import {General} from '@constants';
|
||||
import {withServerUrl} from '@context/server';
|
||||
import {observeChannelSettings, observeMyChannel, queryChannelMembers} from '@queries/servers/channel';
|
||||
import {observeIsMutedSetting, observeMyChannel, queryChannelMembers} from '@queries/servers/channel';
|
||||
import {queryDraft} from '@queries/servers/drafts';
|
||||
import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system';
|
||||
import {observeTeam} from '@queries/servers/team';
|
||||
@@ -19,7 +19,6 @@ import ChannelItem from './channel_item';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
||||
|
||||
type EnhanceProps = WithDatabaseArgs & {
|
||||
channel: ChannelModel;
|
||||
@@ -27,8 +26,6 @@ type EnhanceProps = WithDatabaseArgs & {
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
const observeIsMutedSetting = (mc: MyChannelModel) => observeChannelSettings(mc.database, mc.id).pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION)));
|
||||
|
||||
const enhance = withObservables(['channel', 'showTeamName'], ({
|
||||
channel,
|
||||
database,
|
||||
@@ -53,7 +50,7 @@ const enhance = withObservables(['channel', 'showTeamName'], ({
|
||||
if (!mc) {
|
||||
return of$(false);
|
||||
}
|
||||
return observeIsMutedSetting(mc);
|
||||
return observeIsMutedSetting(database, mc.id);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {Events, Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {useInputPropagation} from '@hooks/input';
|
||||
import {t} from '@i18n';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
import {extractFileInfo} from '@utils/file';
|
||||
@@ -120,6 +121,7 @@ export default function PostInput({
|
||||
const style = getStyleSheet(theme);
|
||||
const serverUrl = useServerUrl();
|
||||
const managedConfig = useManagedConfig<ManagedConfig>();
|
||||
const [propagateValue, shouldProcessEvent] = useInputPropagation();
|
||||
|
||||
const lastTypingEventSent = useRef(0);
|
||||
|
||||
@@ -180,6 +182,9 @@ export default function PostInput({
|
||||
}, [updateCursorPosition, cursorPosition]);
|
||||
|
||||
const handleTextChange = useCallback((newValue: string) => {
|
||||
if (!shouldProcessEvent(newValue)) {
|
||||
return;
|
||||
}
|
||||
updateValue(newValue);
|
||||
lastNativeValue.current = newValue;
|
||||
|
||||
@@ -224,10 +229,16 @@ export default function PostInput({
|
||||
case 'enter':
|
||||
sendMessage();
|
||||
break;
|
||||
case 'shift-enter':
|
||||
updateValue((v) => v.substring(0, cursorPosition) + '\n' + v.substring(cursorPosition));
|
||||
case 'shift-enter': {
|
||||
let newValue: string;
|
||||
updateValue((v) => {
|
||||
newValue = v.substring(0, cursorPosition) + '\n' + v.substring(cursorPosition);
|
||||
return newValue;
|
||||
});
|
||||
updateCursorPosition((pos) => pos + 1);
|
||||
propagateValue(newValue!);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [sendMessage, updateValue, cursorPosition, isTablet]);
|
||||
@@ -266,18 +277,19 @@ export default function PostInput({
|
||||
const draft = value ? `${value} ${text} ` : `${text} `;
|
||||
updateValue(draft);
|
||||
updateCursorPosition(draft.length);
|
||||
propagateValue(draft);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
});
|
||||
return () => listener.remove();
|
||||
}, [updateValue, value, channelId, rootId]);
|
||||
return () => {
|
||||
listener.remove();
|
||||
updateDraftMessage(serverUrl, channelId, rootId, lastNativeValue.current); // safe draft on unmount
|
||||
};
|
||||
}, [updateValue, channelId, rootId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== lastNativeValue.current) {
|
||||
// May change when we implement Fabric
|
||||
inputRef.current?.setNativeProps({
|
||||
text: value,
|
||||
});
|
||||
propagateValue(value);
|
||||
lastNativeValue.current = value;
|
||||
}
|
||||
}, [value]);
|
||||
@@ -310,6 +322,7 @@ export default function PostInput({
|
||||
testID={testID}
|
||||
underlineColorAndroid='transparent'
|
||||
textContentType='none'
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ const CombinedUserActivity = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const passProps = {post};
|
||||
const passProps = {post, sourceScreen: location};
|
||||
Keyboard.dismiss();
|
||||
const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : '';
|
||||
|
||||
@@ -117,7 +117,7 @@ const CombinedUserActivity = ({
|
||||
} else {
|
||||
showModalOverCurrentContext(Screens.POST_OPTIONS, passProps, bottomSheetModalOptions(theme));
|
||||
}
|
||||
}, [post, canDelete, isTablet, intl]);
|
||||
}, [post, canDelete, isTablet, intl, location]);
|
||||
|
||||
const renderMessage = (postType: string, userIds: string[], actorId: string) => {
|
||||
let actor = '';
|
||||
|
||||
@@ -19,21 +19,25 @@ import PostList from './post_list';
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
const enhanced = withObservables(['posts'], ({database, posts}: {posts: PostModel[]} & WithDatabaseArgs) => {
|
||||
const enhancedWithoutPosts = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
const currentUser = observeCurrentUser(database);
|
||||
const postIds = posts.map((p) => p.id);
|
||||
|
||||
return {
|
||||
appsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'),
|
||||
isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'),
|
||||
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))),
|
||||
currentUserId: currentUser.pipe((switchMap((user) => of$(user?.id)))),
|
||||
currentUsername: currentUser.pipe((switchMap((user) => of$(user?.username)))),
|
||||
savedPostIds: observeSavedPostsByIds(database, postIds),
|
||||
customEmojiNames: queryAllCustomEmojis(database).observe().pipe(
|
||||
customEmojiNames: queryAllCustomEmojis(database).observeWithColumns(['name']).pipe(
|
||||
switchMap((customEmojis) => of$(mapCustomEmojiNames(customEmojis))),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
export default React.memo(withDatabase(enhanced(PostList)));
|
||||
const enhanced = withObservables(['posts'], ({database, posts}: {posts: PostModel[]} & WithDatabaseArgs) => {
|
||||
const postIds = posts.map((p) => p.id);
|
||||
return {
|
||||
savedPostIds: observeSavedPostsByIds(database, postIds),
|
||||
};
|
||||
});
|
||||
|
||||
export default React.memo(withDatabase(enhancedWithoutPosts(enhanced(PostList))));
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import React from 'react';
|
||||
import {of as of$, first as first$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {observeMyChannel} from '@queries/servers/channel';
|
||||
import {observeThreadById} from '@queries/servers/thread';
|
||||
@@ -31,8 +32,14 @@ const enhanced = withObservables(['channelId', 'isCRTEnabled', 'rootId'], ({chan
|
||||
}
|
||||
|
||||
const myChannel = observeMyChannel(database, channelId);
|
||||
const isManualUnread = myChannel.pipe(switchMap((ch) => of$(ch?.manuallyUnread)));
|
||||
const unreadCount = myChannel.pipe(switchMap((ch) => of$(ch?.messageCount)));
|
||||
const isManualUnread = myChannel.pipe(
|
||||
switchMap((ch) => of$(ch?.manuallyUnread)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const unreadCount = myChannel.pipe(
|
||||
switchMap((ch) => of$(ch?.messageCount)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
return {
|
||||
isManualUnread,
|
||||
@@ -40,4 +47,4 @@ const enhanced = withObservables(['channelId', 'isCRTEnabled', 'rootId'], ({chan
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(MoreMessages));
|
||||
export default React.memo(withDatabase(enhanced(MoreMessages)));
|
||||
|
||||
@@ -86,11 +86,11 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
|
||||
if (reaction) {
|
||||
const emojiAlias = getEmojiFirstAlias(reaction.emojiName);
|
||||
if (acc.has(emojiAlias)) {
|
||||
const rs = acc.get(emojiAlias);
|
||||
const rs = acc.get(emojiAlias)!;
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
const present = rs!.findIndex((r) => r.userId === reaction.userId) > -1;
|
||||
const present = rs.findIndex((r) => r.userId === reaction.userId) > -1;
|
||||
if (!present) {
|
||||
rs!.push(reaction);
|
||||
rs.push(reaction);
|
||||
}
|
||||
} else {
|
||||
acc.set(emojiAlias, [reaction]);
|
||||
@@ -105,7 +105,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
|
||||
}, new Map<string, ReactionModel[]>());
|
||||
|
||||
return {reactionsByName, highlightedReactions};
|
||||
}, [sortedReactions]);
|
||||
}, [sortedReactions, reactions]);
|
||||
|
||||
const handleAddReactionToPost = (emoji: string) => {
|
||||
addReaction(serverUrl, postId, emoji);
|
||||
@@ -178,7 +178,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
|
||||
return (
|
||||
<Reaction
|
||||
key={r}
|
||||
count={reaction!.length}
|
||||
count={reaction?.length || 1}
|
||||
emojiName={r}
|
||||
highlight={highlightedReactions.includes(r)}
|
||||
onPress={handleReactionPress}
|
||||
|
||||
@@ -150,6 +150,7 @@ export default function SelectedUsers({
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const numberSelectedIds = Object.keys(selectedIds).length;
|
||||
const bottomSpace = (dimensions.height - containerHeight - modalPosition);
|
||||
const bottomPaddingBottom = isTablet ? CHIP_HEIGHT_WITH_MARGIN : 0;
|
||||
|
||||
const users = useMemo(() => {
|
||||
const u = [];
|
||||
@@ -172,8 +173,8 @@ export default function SelectedUsers({
|
||||
}, [selectedIds, teammateNameDisplay, onRemove]);
|
||||
|
||||
const totalPanelHeight = useDerivedValue(() => (
|
||||
isVisible ? panelHeight.value + BUTTON_HEIGHT : 0
|
||||
), [isVisible, isTablet]);
|
||||
isVisible ? panelHeight.value + BUTTON_HEIGHT + bottomPaddingBottom : 0
|
||||
), [isVisible, isTablet, bottomPaddingBottom]);
|
||||
|
||||
const marginBottom = useMemo(() => {
|
||||
let margin = keyboard.height && Platform.OS === 'ios' ? keyboard.height - insets.bottom : 0;
|
||||
@@ -208,7 +209,7 @@ export default function SelectedUsers({
|
||||
}, [onPress]);
|
||||
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
panelHeight.value = Math.min(PANEL_MAX_HEIGHT, e.nativeEvent.layout.height);
|
||||
panelHeight.value = Math.min(PANEL_MAX_HEIGHT + bottomPaddingBottom, e.nativeEvent.layout.height);
|
||||
}, []);
|
||||
|
||||
const androidMaxHeight = Platform.select({
|
||||
@@ -235,8 +236,8 @@ export default function SelectedUsers({
|
||||
const animatedViewStyle = useAnimatedStyle(() => ({
|
||||
height: withTiming(totalPanelHeight.value + insets.bottom, {duration: 250}),
|
||||
borderWidth: isVisible ? 1 : 0,
|
||||
maxHeight: isVisible ? PANEL_MAX_HEIGHT + BUTTON_HEIGHT + insets.bottom : 0,
|
||||
}), [isVisible, insets]);
|
||||
maxHeight: isVisible ? PANEL_MAX_HEIGHT + BUTTON_HEIGHT + bottomPaddingBottom + insets.bottom : 0,
|
||||
}), [isVisible, insets, bottomPaddingBottom]);
|
||||
|
||||
const animatedButtonStyle = useAnimatedStyle(() => ({
|
||||
opacity: withTiming(isVisible ? 1 : 0, {duration: isVisible ? 500 : 100}),
|
||||
|
||||
91
app/components/settings/block.tsx
Normal file
91
app/components/settings/block.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {LayoutChangeEvent, StyleProp, TextStyle, View, ViewStyle} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type {MessageDescriptor} from 'react-intl';
|
||||
|
||||
type SectionText = {
|
||||
id: string;
|
||||
defaultMessage: string;
|
||||
values?: MessageDescriptor;
|
||||
}
|
||||
|
||||
type SettingBlockProps = {
|
||||
children: React.ReactNode;
|
||||
containerStyles?: StyleProp<ViewStyle>;
|
||||
disableFooter?: boolean;
|
||||
disableHeader?: boolean;
|
||||
footerStyles?: StyleProp<TextStyle>;
|
||||
footerText?: SectionText;
|
||||
headerStyles?: StyleProp<TextStyle>;
|
||||
headerText?: SectionText;
|
||||
onLayout?: (event: LayoutChangeEvent) => void;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
contentContainerStyle: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
header: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Heading', 300, 'SemiBold'),
|
||||
marginBottom: 8,
|
||||
marginLeft: 20,
|
||||
marginTop: 12,
|
||||
marginRight: 15,
|
||||
},
|
||||
footer: {
|
||||
marginTop: 10,
|
||||
marginHorizontal: 15,
|
||||
fontSize: 12,
|
||||
color: changeOpacity(theme.centerChannelColor, 0.5),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SettingBlock = ({
|
||||
children, containerStyles, disableFooter, disableHeader,
|
||||
footerStyles, footerText, headerStyles, headerText, onLayout,
|
||||
}: SettingBlockProps) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{(headerText && !disableHeader) &&
|
||||
<FormattedText
|
||||
defaultMessage={headerText.defaultMessage}
|
||||
id={headerText.id}
|
||||
values={headerText.values}
|
||||
style={[styles.header, headerStyles]}
|
||||
/>
|
||||
}
|
||||
<View style={[styles.contentContainerStyle, containerStyles]}>
|
||||
{children}
|
||||
</View>
|
||||
{(footerText && !disableFooter) &&
|
||||
<FormattedText
|
||||
defaultMessage={footerText.defaultMessage}
|
||||
id={footerText.id}
|
||||
style={[styles.footer, footerStyles]}
|
||||
values={footerText.values}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingBlock;
|
||||
@@ -7,11 +7,12 @@ import {Platform} from 'react-native';
|
||||
|
||||
import OptionItem, {OptionItemProps} from '@components/option_item';
|
||||
import {useTheme} from '@context/theme';
|
||||
import SettingSeparator from '@screens/settings/settings_separator';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import Options, {DisplayOptionConfig, NotificationsOptionConfig, SettingOptionConfig} from './config';
|
||||
import Options, {DisplayOptionConfig, NotificationsOptionConfig, SettingOptionConfig} from '../../screens/settings/config';
|
||||
|
||||
import SettingSeparator from './separator';
|
||||
|
||||
type SettingsConfig = keyof typeof SettingOptionConfig | keyof typeof NotificationsOptionConfig| keyof typeof DisplayOptionConfig
|
||||
type SettingOptionProps = {
|
||||
@@ -14,7 +14,7 @@ import TeamList from './team_list';
|
||||
type Props = {
|
||||
iconPad?: boolean;
|
||||
canJoinOtherTeams: boolean;
|
||||
teamsCount: number;
|
||||
hasMoreThanOneTeam: boolean;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
@@ -36,8 +36,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
};
|
||||
});
|
||||
|
||||
export default function TeamSidebar({iconPad, canJoinOtherTeams, teamsCount}: Props) {
|
||||
const initialWidth = teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0;
|
||||
export default function TeamSidebar({iconPad, canJoinOtherTeams, hasMoreThanOneTeam}: Props) {
|
||||
const initialWidth = hasMoreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0;
|
||||
const width = useSharedValue(initialWidth);
|
||||
const marginTop = useSharedValue(iconPad ? 44 : 0);
|
||||
const theme = useTheme();
|
||||
@@ -58,8 +58,8 @@ export default function TeamSidebar({iconPad, canJoinOtherTeams, teamsCount}: Pr
|
||||
}, [iconPad]);
|
||||
|
||||
useEffect(() => {
|
||||
width.value = teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0;
|
||||
}, [teamsCount]);
|
||||
width.value = hasMoreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0;
|
||||
}, [hasMoreThanOneTeam]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, transform]}>
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {StyleProp, View, ViewStyle} from 'react-native';
|
||||
|
||||
import {useEmojiSkinTone} from '@app/hooks/emoji_category_bar';
|
||||
import {preventDoubleTap} from '@app/utils/tap';
|
||||
import Emoji from '@components/emoji';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {useEmojiSkinTone} from '@hooks/emoji_category_bar';
|
||||
import {skinCodes} from '@utils/emoji';
|
||||
import {isValidNamedEmoji} from '@utils/emoji/helpers';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
export const CATEGORIES_TO_KEEP: Record<string, string> = {
|
||||
ADVANCED_SETTINGS: 'advanced_settings',
|
||||
CHANNEL_APPROXIMATE_VIEW_TIME: 'channel_approximate_view_time',
|
||||
CHANNEL_OPEN_TIME: 'channel_open_time',
|
||||
DIRECT_CHANNEL_SHOW: 'direct_channel_show',
|
||||
GROUP_CHANNEL_SHOW: 'group_channel_show',
|
||||
DISPLAY_SETTINGS: 'display_settings',
|
||||
|
||||
@@ -10,7 +10,7 @@ export const CALL = 'Call';
|
||||
export const CHANNEL = 'Channel';
|
||||
export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople';
|
||||
export const CHANNEL_INFO = 'ChannelInfo';
|
||||
export const CHANNEL_MENTION = 'ChannelMention';
|
||||
export const CHANNEL_NOTIFICATION_PREFERENCES = 'ChannelNotificationPreferences';
|
||||
export const CODE = 'Code';
|
||||
export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage';
|
||||
export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel';
|
||||
@@ -79,7 +79,7 @@ export default {
|
||||
CHANNEL,
|
||||
CHANNEL_ADD_PEOPLE,
|
||||
CHANNEL_INFO,
|
||||
CHANNEL_MENTION,
|
||||
CHANNEL_NOTIFICATION_PREFERENCES,
|
||||
CODE,
|
||||
CREATE_DIRECT_MESSAGE,
|
||||
CREATE_OR_EDIT_CHANNEL,
|
||||
@@ -172,6 +172,5 @@ export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
|
||||
|
||||
export const NOT_READY = [
|
||||
CHANNEL_ADD_PEOPLE,
|
||||
CHANNEL_MENTION,
|
||||
CREATE_TEAM,
|
||||
];
|
||||
|
||||
@@ -246,10 +246,11 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(super
|
||||
const totalMsg = isCRT ? channel.total_msg_count_root! : channel.total_msg_count;
|
||||
const myMsgCount = isCRT ? my.msg_count_root! : my.msg_count;
|
||||
const msgCount = Math.max(0, totalMsg - myMsgCount);
|
||||
const lastPostAt = isCRT ? (channel.last_root_post_at || channel.last_post_at) : channel.last_post_at;
|
||||
my.msg_count = msgCount;
|
||||
my.mention_count = isCRT ? my.mention_count_root! : my.mention_count;
|
||||
my.is_unread = msgCount > 0;
|
||||
my.last_post_at = (isCRT ? (channel.last_root_post_at || channel.last_post_at) : channel.last_post_at) || 0;
|
||||
my.last_post_at = lastPostAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +272,7 @@ const ChannelHandler = <TBase extends Constructor<ServerDataOperatorBase>>(super
|
||||
}
|
||||
|
||||
const chan = channelMap[my.channel_id];
|
||||
const lastPostAt = (isCRT ? chan.last_root_post_at : chan.last_post_at) || 0;
|
||||
const lastPostAt = isCRT ? (chan.last_root_post_at || chan.last_post_at) : chan.last_post_at;
|
||||
if ((chan && e.lastPostAt < lastPostAt) ||
|
||||
e.isUnread !== my.is_unread || e.lastViewedAt < my.last_viewed_at ||
|
||||
e.roles !== my.roles
|
||||
|
||||
25
app/hooks/input.ts
Normal file
25
app/hooks/input.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {useCallback, useRef} from 'react';
|
||||
import {Platform} from 'react-native';
|
||||
|
||||
export function useInputPropagation(): [(v: string) => void, (v: string) => boolean] {
|
||||
const waitForValue = useRef<string>();
|
||||
const waitToPropagate = useCallback((value: string) => {
|
||||
waitForValue.current = value;
|
||||
}, []);
|
||||
const shouldProcessEvent = useCallback((newValue: string) => {
|
||||
if (Platform.OS === 'android') {
|
||||
return true;
|
||||
}
|
||||
if (waitForValue.current === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (newValue === waitForValue.current) {
|
||||
waitForValue.current = undefined;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
return [waitToPropagate, shouldProcessEvent];
|
||||
}
|
||||
@@ -52,6 +52,6 @@ export async function start() {
|
||||
|
||||
registerNavigationListeners();
|
||||
registerScreens();
|
||||
await initialLaunch();
|
||||
WebsocketManager.init(serverCredentials);
|
||||
await WebsocketManager.init(serverCredentials);
|
||||
initialLaunch();
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ const initialNotificationTypes = [PushNotification.NOTIFICATION_TYPE.MESSAGE, Pu
|
||||
export const initialLaunch = async () => {
|
||||
const deepLinkUrl = await Linking.getInitialURL();
|
||||
if (deepLinkUrl) {
|
||||
await launchAppFromDeepLink(deepLinkUrl, true);
|
||||
return;
|
||||
return launchAppFromDeepLink(deepLinkUrl, true);
|
||||
}
|
||||
|
||||
const notification = await Notifications.getInitialNotification();
|
||||
@@ -43,11 +42,10 @@ export const initialLaunch = async () => {
|
||||
tapped = delivered.find((d) => (d as unknown as NotificationData).ack_id === notification?.payload.ack_id) == null;
|
||||
}
|
||||
if (initialNotificationTypes.includes(notification?.payload?.type) && tapped) {
|
||||
await launchAppFromNotification(convertToNotificationData(notification!), true);
|
||||
return;
|
||||
return launchAppFromNotification(convertToNotificationData(notification!), true);
|
||||
}
|
||||
|
||||
await launchApp({launchType: Launch.Normal, coldStart: true});
|
||||
return launchApp({launchType: Launch.Normal, coldStart: notification ? tapped : true});
|
||||
};
|
||||
|
||||
const launchAppFromDeepLink = async (deepLinkUrl: string, coldStart = false) => {
|
||||
@@ -162,14 +160,17 @@ const launchToHome = async (props: LaunchProps) => {
|
||||
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!);
|
||||
await resetToHome(props);
|
||||
return pushNotificationEntry(props.serverUrl!, extra.payload!);
|
||||
}
|
||||
|
||||
appEntry(props.serverUrl!);
|
||||
break;
|
||||
}
|
||||
case Launch.Normal:
|
||||
appEntry(props.serverUrl!);
|
||||
if (props.coldStart) {
|
||||
appEntry(props.serverUrl!);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -202,16 +203,21 @@ export const getLaunchPropsFromNotification = async (notification: NotificationW
|
||||
|
||||
const {payload} = notification;
|
||||
launchProps.extra = notification;
|
||||
let serverUrl: string | undefined;
|
||||
|
||||
if (payload?.server_url) {
|
||||
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;
|
||||
try {
|
||||
if (payload?.server_url) {
|
||||
DatabaseManager.getServerDatabaseAndOperator(payload.server_url);
|
||||
serverUrl = payload.server_url;
|
||||
} else if (payload?.server_id) {
|
||||
serverUrl = await DatabaseManager.getServerUrlFromIdentifier(payload.server_id);
|
||||
}
|
||||
} catch {
|
||||
launchProps.launchError = true;
|
||||
}
|
||||
|
||||
if (serverUrl) {
|
||||
launchProps.serverUrl = serverUrl;
|
||||
} else {
|
||||
launchProps.launchError = true;
|
||||
}
|
||||
|
||||
@@ -37,22 +37,22 @@ class PushNotifications {
|
||||
configured = false;
|
||||
|
||||
init(register: boolean) {
|
||||
if (register) {
|
||||
this.registerIfNeeded();
|
||||
}
|
||||
|
||||
Notifications.events().registerNotificationOpened(this.onNotificationOpened);
|
||||
Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered);
|
||||
Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground);
|
||||
Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground);
|
||||
|
||||
if (register) {
|
||||
this.registerIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
async registerIfNeeded() {
|
||||
const isRegistered = await Notifications.isRegisteredForRemoteNotifications();
|
||||
if (!isRegistered) {
|
||||
await requestNotifications(['alert', 'sound', 'badge']);
|
||||
Notifications.registerRemoteNotifications();
|
||||
}
|
||||
Notifications.registerRemoteNotifications();
|
||||
}
|
||||
|
||||
createReplyCategory = () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ class WebsocketManager {
|
||||
private previousActiveState: boolean;
|
||||
private statusUpdatesIntervalIDs: Record<string, NodeJS.Timer> = {};
|
||||
private backgroundIntervalId: number | undefined;
|
||||
private firstConnectionSynced: Record<string, boolean> = {};
|
||||
|
||||
constructor() {
|
||||
this.previousActiveState = AppState.currentState === 'active';
|
||||
@@ -40,21 +41,15 @@ class WebsocketManager {
|
||||
|
||||
public init = async (serverCredentials: ServerCredential[]) => {
|
||||
this.netConnected = Boolean((await NetInfo.fetch()).isConnected);
|
||||
await Promise.all(
|
||||
serverCredentials.map(
|
||||
async ({serverUrl, token}) => {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.createClient(serverUrl, token, 0);
|
||||
} catch (error) {
|
||||
logError('WebsocketManager init error', error);
|
||||
}
|
||||
},
|
||||
),
|
||||
serverCredentials.forEach(
|
||||
({serverUrl, token}) => {
|
||||
try {
|
||||
DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
this.createClient(serverUrl, token, 0);
|
||||
} catch (error) {
|
||||
logError('WebsocketManager init error', error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
AppState.addEventListener('change', this.onAppStateChange);
|
||||
@@ -68,6 +63,7 @@ class WebsocketManager {
|
||||
this.connectionTimerIDs[serverUrl].cancel();
|
||||
}
|
||||
delete this.clients[serverUrl];
|
||||
delete this.firstConnectionSynced[serverUrl];
|
||||
|
||||
this.getConnectedSubject(serverUrl).next('not_connected');
|
||||
delete this.connectedSubjects[serverUrl];
|
||||
@@ -84,9 +80,6 @@ class WebsocketManager {
|
||||
client.setReliableReconnectCallback(() => this.onReliableReconnect(serverUrl));
|
||||
client.setCloseCallback((connectFailCount: number, lastDisconnect: number) => this.onWebsocketClose(serverUrl, connectFailCount, lastDisconnect));
|
||||
|
||||
if (this.netConnected && ['unknown', 'active'].includes(AppState.currentState)) {
|
||||
client.initialize();
|
||||
}
|
||||
this.clients[serverUrl] = client;
|
||||
|
||||
return this.clients[serverUrl];
|
||||
@@ -143,25 +136,34 @@ class WebsocketManager {
|
||||
}
|
||||
};
|
||||
|
||||
private initializeClient = (serverUrl: string) => {
|
||||
public initializeClient = async (serverUrl: string) => {
|
||||
const client: WebSocketClient = this.clients[serverUrl];
|
||||
if (!client?.isConnected()) {
|
||||
client.initialize();
|
||||
}
|
||||
this.connectionTimerIDs[serverUrl]?.cancel();
|
||||
delete this.connectionTimerIDs[serverUrl];
|
||||
if (!client?.isConnected()) {
|
||||
client.initialize();
|
||||
if (!this.firstConnectionSynced[serverUrl]) {
|
||||
const error = await handleFirstConnect(serverUrl);
|
||||
if (error) {
|
||||
client.close(false);
|
||||
}
|
||||
this.firstConnectionSynced[serverUrl] = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onFirstConnect = (serverUrl: string) => {
|
||||
this.startPeriodicStatusUpdates(serverUrl);
|
||||
this.getConnectedSubject(serverUrl).next('connected');
|
||||
handleFirstConnect(serverUrl);
|
||||
};
|
||||
|
||||
private onReconnect = async (serverUrl: string) => {
|
||||
this.startPeriodicStatusUpdates(serverUrl);
|
||||
this.getConnectedSubject(serverUrl).next('connected');
|
||||
await handleReconnect(serverUrl);
|
||||
const error = await handleReconnect(serverUrl);
|
||||
if (error) {
|
||||
this.getClient(serverUrl)?.close(false);
|
||||
}
|
||||
};
|
||||
|
||||
private onReliableReconnect = async (serverUrl: string) => {
|
||||
|
||||
@@ -234,8 +234,10 @@ export async function newConnection(
|
||||
});
|
||||
|
||||
peer.on('stream', (remoteStream: MediaStream) => {
|
||||
logDebug('new remote stream received', remoteStream);
|
||||
logDebug('remote tracks', remoteStream.getTracks());
|
||||
logDebug('new remote stream received', remoteStream.id);
|
||||
for (const track of remoteStream.getTracks()) {
|
||||
logDebug('remote track', track.id);
|
||||
}
|
||||
|
||||
streams.push(remoteStream);
|
||||
if (remoteStream.getVideoTracks().length > 0) {
|
||||
|
||||
@@ -18,7 +18,6 @@ import {Navigation} from 'react-native-navigation';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
import {RTCView} from 'react-native-webrtc';
|
||||
|
||||
import {appEntry} from '@actions/remote/entry';
|
||||
import {leaveCall, muteMyself, setSpeakerphoneOn, unmuteMyself} from '@calls/actions';
|
||||
import {startCallRecording, stopCallRecording} from '@calls/actions/calls';
|
||||
import {recordingAlert, recordingWillBePostedAlert, recordingErrorAlert} from '@calls/alerts';
|
||||
@@ -41,6 +40,7 @@ import {useTheme} from '@context/theme';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import WebsocketManager from '@managers/websocket_manager';
|
||||
import {
|
||||
bottomSheet,
|
||||
dismissAllModalsAndPopToScreen,
|
||||
@@ -357,7 +357,7 @@ const CallScreen = ({
|
||||
await popTopScreen(Screens.THREAD);
|
||||
}
|
||||
await DatabaseManager.setActiveServerDatabase(currentCall.serverUrl);
|
||||
await appEntry(currentCall.serverUrl, Date.now());
|
||||
WebsocketManager.initializeClient(currentCall.serverUrl);
|
||||
await goToScreen(Screens.THREAD, callThreadOptionTitle, {rootId: currentCall.threadId});
|
||||
}, [currentCall?.serverUrl, currentCall?.threadId, fromThreadScreen, componentId, callThreadOptionTitle]);
|
||||
|
||||
|
||||
@@ -11,14 +11,11 @@ import {makeCategoryChannelId} from '@utils/categories';
|
||||
import {pluckUnique} from '@utils/helpers';
|
||||
import {logDebug} from '@utils/log';
|
||||
|
||||
import {observeChannelsByLastPostAt} from './channel';
|
||||
|
||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
import type CategoryModel from '@typings/database/models/servers/category';
|
||||
import type CategoryChannelModel from '@typings/database/models/servers/category_channel';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
|
||||
const {SERVER: {CATEGORY, CATEGORY_CHANNEL, CHANNEL}} = MM_TABLES;
|
||||
const {SERVER: {CATEGORY, CATEGORY_CHANNEL}} = MM_TABLES;
|
||||
|
||||
export const getCategoryById = async (database: Database, categoryId: string) => {
|
||||
try {
|
||||
@@ -144,24 +141,3 @@ export const observeIsChannelFavorited = (database: Database, teamId: string, ch
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
};
|
||||
|
||||
export const observeChannelsByCategoryChannelSortOrder = (database: Database, category: CategoryModel, excludeIds?: string[]) => {
|
||||
return category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']).pipe(
|
||||
switchMap((categoryChannels) => {
|
||||
const ids = categoryChannels.map((cc) => cc.channelId);
|
||||
const idsStr = `'${ids.join("','")}'`;
|
||||
const exclude = excludeIds?.length ? `AND c.id NOT IN ('${excludeIds.join("','")}')` : '';
|
||||
return database.get<ChannelModel>(CHANNEL).query(
|
||||
Q.unsafeSqlQuery(`SELECT DISTINCT c.* FROM ${CHANNEL} c INNER JOIN
|
||||
${CATEGORY_CHANNEL} cc ON cc.channel_id=c.id AND c.id IN (${idsStr}) ${exclude}
|
||||
ORDER BY cc.sort_order`),
|
||||
).observe();
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const observeChannelsByLastPostAtInCategory = (database: Database, category: CategoryModel, excludeIds?: string[]) => {
|
||||
return category.myChannels.observeWithColumns(['last_post_at']).pipe(
|
||||
switchMap((myChannels) => observeChannelsByLastPostAt(database, myChannels, excludeIds)),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {General, Permissions} from '@constants';
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
import {sanitizeLikeString} from '@helpers/database';
|
||||
import {hasPermission} from '@utils/role';
|
||||
import {getUserIdFromChannelName} from '@utils/user';
|
||||
|
||||
import {prepareDeletePost} from './post';
|
||||
import {queryRoles} from './role';
|
||||
@@ -211,6 +212,13 @@ export const observeMyChannel = (database: Database, channelId: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const observeMyChannelRoles = (database: Database, channelId: string) => {
|
||||
return observeMyChannel(database, channelId).pipe(
|
||||
switchMap((v) => of$(v?.roles)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
};
|
||||
|
||||
export const getChannelById = async (database: Database, channelId: string) => {
|
||||
try {
|
||||
const channel = await database.get<ChannelModel>(CHANNEL).find(channelId);
|
||||
@@ -430,10 +438,6 @@ export const observeNotifyPropsByChannels = (database: Database, channels: Chann
|
||||
);
|
||||
};
|
||||
|
||||
export const queryChannelsByNames = (database: Database, names: string[]) => {
|
||||
return database.get<ChannelModel>(CHANNEL).query(Q.where('name', Q.oneOf(names)));
|
||||
};
|
||||
|
||||
export const queryMyChannelUnreads = (database: Database, currentTeamId: string) => {
|
||||
return database.get<MyChannelModel>(MY_CHANNEL).query(
|
||||
Q.on(
|
||||
@@ -446,40 +450,42 @@ export const queryMyChannelUnreads = (database: Database, currentTeamId: string)
|
||||
Q.where('delete_at', Q.eq(0)),
|
||||
),
|
||||
),
|
||||
Q.where('is_unread', Q.eq(true)),
|
||||
Q.or(
|
||||
Q.where('is_unread', Q.eq(true)),
|
||||
Q.where('mentions_count', Q.gte(0)),
|
||||
),
|
||||
Q.sortBy('last_post_at', Q.desc),
|
||||
);
|
||||
};
|
||||
|
||||
export const queryEmptyDirectAndGroupChannels = (database: Database) => {
|
||||
return database.get<MyChannelModel>(MY_CHANNEL).query(
|
||||
Q.on(
|
||||
CHANNEL,
|
||||
Q.where('team_id', Q.eq('')),
|
||||
),
|
||||
Q.where('last_post_at', Q.eq(0)),
|
||||
);
|
||||
};
|
||||
|
||||
export const observeArchivedDirectChannels = (database: Database, currentUserId: string) => {
|
||||
const deactivatedIds = database.get<UserModel>(USER).query(
|
||||
const deactivated = database.get<UserModel>(USER).query(
|
||||
Q.where('delete_at', Q.gt(0)),
|
||||
).observe().pipe(
|
||||
switchMap((users) => of$(users.map((u) => u.id))),
|
||||
);
|
||||
).observe();
|
||||
|
||||
return deactivatedIds.pipe(
|
||||
switchMap((dIds) => {
|
||||
return deactivated.pipe(
|
||||
switchMap((users) => {
|
||||
const usersMap = new Map(users.map((u) => [u.id, u]));
|
||||
return database.get<ChannelModel>(CHANNEL).query(
|
||||
Q.on(
|
||||
CHANNEL_MEMBERSHIP,
|
||||
Q.and(
|
||||
Q.where('user_id', Q.notEq(currentUserId)),
|
||||
Q.where('user_id', Q.oneOf(dIds)),
|
||||
Q.where('user_id', Q.oneOf(Array.from(usersMap.keys()))),
|
||||
),
|
||||
),
|
||||
Q.where('type', 'D'),
|
||||
).observe();
|
||||
).observe().pipe(
|
||||
switchMap((channels) => {
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
return of$(new Map(channels.map((c) => {
|
||||
const teammateId = getUserIdFromChannelName(currentUserId, c.name);
|
||||
const user = usersMap.get(teammateId);
|
||||
|
||||
return [c.id, user];
|
||||
})));
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -628,13 +634,17 @@ export const observeChannelSettings = (database: Database, channelId: string) =>
|
||||
);
|
||||
};
|
||||
|
||||
export const observeChannelsByLastPostAt = (database: Database, myChannels: MyChannelModel[], excludeIds?: string[]) => {
|
||||
export const observeIsMutedSetting = (database: Database, channelId: string) => {
|
||||
return observeChannelSettings(database, channelId).pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION)));
|
||||
};
|
||||
|
||||
export const observeChannelsByLastPostAt = (database: Database, myChannels: MyChannelModel[]) => {
|
||||
const ids = myChannels.map((c) => c.id);
|
||||
const idsStr = `'${ids.join("','")}'`;
|
||||
const exclude = excludeIds?.length ? `AND c.id NOT IN ('${excludeIds.join("','")}')` : '';
|
||||
|
||||
return database.get<ChannelModel>(CHANNEL).query(
|
||||
Q.unsafeSqlQuery(`SELECT DISTINCT c.* FROM ${CHANNEL} c INNER JOIN
|
||||
${MY_CHANNEL} mc ON mc.id=c.id AND c.id IN (${idsStr}) ${exclude}
|
||||
${MY_CHANNEL} mc ON mc.id=c.id AND c.id IN (${idsStr})
|
||||
ORDER BY CASE mc.last_post_at WHEN 0 THEN c.create_at ELSE mc.last_post_at END DESC`),
|
||||
).observe();
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ const {
|
||||
THREAD,
|
||||
THREADS_IN_TEAM,
|
||||
THREAD_PARTICIPANT,
|
||||
TEAM_THREADS_SYNC,
|
||||
MY_CHANNEL,
|
||||
} = MM_TABLES.SERVER;
|
||||
|
||||
@@ -99,6 +100,7 @@ export async function truncateCrtRelatedTables(serverUrl: string): Promise<{erro
|
||||
[`DELETE FROM ${THREAD}`, []],
|
||||
[`DELETE FROM ${THREADS_IN_TEAM}`, []],
|
||||
[`DELETE FROM ${THREAD_PARTICIPANT}`, []],
|
||||
[`DELETE FROM ${TEAM_THREADS_SYNC}`, []],
|
||||
[`DELETE FROM ${MY_CHANNEL}`, []],
|
||||
],
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES;
|
||||
|
||||
export const prepareDeletePost = async (post: PostModel): Promise<Model[]> => {
|
||||
const preparedModels: Model[] = [post.prepareDestroyPermanently()];
|
||||
const relations: Array<Query<Model>> = [post.drafts, post.postsInThread, post.files, post.reactions];
|
||||
const relations: Array<Query<Model>> = [post.drafts, post.files, post.reactions];
|
||||
for await (const models of relations) {
|
||||
try {
|
||||
models.forEach((m) => {
|
||||
@@ -29,6 +29,20 @@ export const prepareDeletePost = async (post: PostModel): Promise<Model[]> => {
|
||||
}
|
||||
}
|
||||
|
||||
// If the post is a root post, delete the postsInThread model
|
||||
if (!post.rootId) {
|
||||
try {
|
||||
const postsInThread = await post.postsInThread.fetch();
|
||||
if (postsInThread) {
|
||||
postsInThread.forEach((m) => {
|
||||
preparedModels.push(m.prepareDestroyPermanently());
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Record not found, do nothing
|
||||
}
|
||||
}
|
||||
|
||||
// If thread exists, delete thread, participants and threadsInTeam
|
||||
try {
|
||||
const thread = await post.thread.fetch();
|
||||
|
||||
@@ -9,8 +9,8 @@ import {Database as DatabaseConstants, General, Permissions} from '@constants';
|
||||
import {isDMorGM} from '@utils/channel';
|
||||
import {hasPermission} from '@utils/role';
|
||||
|
||||
import {observeChannel, observeMyChannel} from './channel';
|
||||
import {observeMyTeam} from './team';
|
||||
import {observeChannel, observeMyChannelRoles} from './channel';
|
||||
import {observeMyTeam, observeMyTeamRoles} from './team';
|
||||
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
@@ -41,16 +41,16 @@ export function observePermissionForChannel(database: Database, channel: Channel
|
||||
if (!user || !channel) {
|
||||
return of$(defaultValue);
|
||||
}
|
||||
const myChannel = observeMyChannel(database, channel.id);
|
||||
const myTeam = channel.teamId ? observeMyTeam(database, channel.teamId) : of$(undefined);
|
||||
const myChannelRoles = observeMyChannelRoles(database, channel.id);
|
||||
const myTeamRoles = channel.teamId ? observeMyTeamRoles(database, channel.teamId) : of$(undefined);
|
||||
|
||||
return combineLatest([myChannel, myTeam]).pipe(switchMap(([mc, mt]) => {
|
||||
return combineLatest([myChannelRoles, myTeamRoles]).pipe(switchMap(([mc, mt]) => {
|
||||
const rolesArray = [...user.roles.split(' ')];
|
||||
if (mc) {
|
||||
rolesArray.push(...mc.roles.split(' '));
|
||||
rolesArray.push(...mc.split(' '));
|
||||
}
|
||||
if (mt) {
|
||||
rolesArray.push(...mt.roles.split(' '));
|
||||
rolesArray.push(...mt.split(' '));
|
||||
}
|
||||
return queryRolesByNames(database, rolesArray).observeWithColumns(['permissions']).pipe(
|
||||
switchMap((r) => of$(hasPermission(r, permission))),
|
||||
|
||||
@@ -296,6 +296,13 @@ export const observeMyTeam = (database: Database, teamId: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const observeMyTeamRoles = (database: Database, teamId: string) => {
|
||||
return observeMyTeam(database, teamId).pipe(
|
||||
switchMap((v) => of$(v?.roles)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
};
|
||||
|
||||
export const getTeamById = async (database: Database, teamId: string) => {
|
||||
try {
|
||||
const team = (await database.get<TeamModel>(TEAM).find(teamId));
|
||||
|
||||
@@ -48,6 +48,13 @@ export const observeCurrentUser = (database: Database) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const observeCurrentUserRoles = (database: Database) => {
|
||||
return observeCurrentUser(database).pipe(
|
||||
switchMap((v) => of$(v?.roles)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
};
|
||||
|
||||
export const queryAllUsers = (database: Database) => {
|
||||
return database.get<UserModel>(USER).query();
|
||||
};
|
||||
|
||||
@@ -5,11 +5,11 @@ import BottomSheetM, {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheet
|
||||
import React, {ReactNode, useCallback, useEffect, useMemo, useRef} from 'react';
|
||||
import {DeviceEventEmitter, Handle, InteractionManager, Keyboard, StyleProp, View, ViewStyle} from 'react-native';
|
||||
|
||||
import useNavButtonPressed from '@app/hooks/navigation_button_pressed';
|
||||
import {Events} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import useNavButtonPressed from '@hooks/navigation_button_pressed';
|
||||
import {dismissModal} from '@screens/navigation';
|
||||
import {hapticFeedback} from '@utils/general';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
@@ -6,9 +6,9 @@ import withObservables from '@nozbe/with-observables';
|
||||
import {combineLatest} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {observeChannel, observeMyChannel} from '@queries/servers/channel';
|
||||
import {observeChannel, observeMyChannelRoles} from '@queries/servers/channel';
|
||||
import {queryRolesByNames} from '@queries/servers/role';
|
||||
import {observeCurrentUser} from '@queries/servers/user';
|
||||
import {observeCurrentUserRoles} from '@queries/servers/user';
|
||||
|
||||
import Intro from './intro';
|
||||
|
||||
@@ -16,13 +16,13 @@ import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
const enhanced = withObservables(['channelId'], ({channelId, database}: {channelId: string} & WithDatabaseArgs) => {
|
||||
const channel = observeChannel(database, channelId);
|
||||
const myChannel = observeMyChannel(database, channelId);
|
||||
const me = observeCurrentUser(database);
|
||||
const myChannelRoles = observeMyChannelRoles(database, channelId);
|
||||
const meRoles = observeCurrentUserRoles(database);
|
||||
|
||||
const roles = combineLatest([me, myChannel]).pipe(
|
||||
const roles = combineLatest([meRoles, myChannelRoles]).pipe(
|
||||
switchMap(([user, member]) => {
|
||||
const userRoles = user?.roles.split(' ');
|
||||
const memberRoles = member?.roles.split(' ');
|
||||
const userRoles = user?.split(' ');
|
||||
const memberRoles = member?.split(' ');
|
||||
const combinedRoles = [];
|
||||
if (userRoles) {
|
||||
combinedRoles.push(...userRoles);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {useIntl} from 'react-intl';
|
||||
import {Keyboard, Platform, Text, View} from 'react-native';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {bottomSheetSnapPoint} from '@app/utils/helpers';
|
||||
import {CHANNEL_ACTIONS_OPTIONS_HEIGHT} from '@components/channel_actions/channel_actions';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
|
||||
@@ -19,6 +18,7 @@ import {useIsTablet} from '@hooks/device';
|
||||
import {useDefaultHeaderHeight} from '@hooks/header';
|
||||
import {bottomSheet, popTopScreen, showModal} from '@screens/navigation';
|
||||
import {isTypeDMorGM} from '@utils/channel';
|
||||
import {bottomSheetSnapPoint} from '@utils/helpers';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
@@ -10,6 +10,7 @@ import {isTypeDMorGM} from '@utils/channel';
|
||||
import EditChannel from './edit_channel';
|
||||
import IgnoreMentions from './ignore_mentions';
|
||||
import Members from './members';
|
||||
import NotificationPreference from './notification_preference';
|
||||
import PinnedMessages from './pinned_messages';
|
||||
|
||||
type Props = {
|
||||
@@ -26,7 +27,7 @@ const Options = ({channelId, type, callsEnabled}: Props) => {
|
||||
{type !== General.DM_CHANNEL &&
|
||||
<IgnoreMentions channelId={channelId}/>
|
||||
}
|
||||
{/*<NotificationPreference channelId={channelId}/>*/}
|
||||
<NotificationPreference channelId={channelId}/>
|
||||
<PinnedMessages channelId={channelId}/>
|
||||
{type !== General.DM_CHANNEL &&
|
||||
<Members channelId={channelId}/>
|
||||
|
||||
@@ -6,7 +6,9 @@ import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {observeChannelSettings} from '@queries/servers/channel';
|
||||
import {observeChannel, observeChannelSettings} from '@queries/servers/channel';
|
||||
import {observeCurrentUser} from '@queries/servers/user';
|
||||
import {getNotificationProps} from '@utils/user';
|
||||
|
||||
import NotificationPreference from './notification_preference';
|
||||
|
||||
@@ -17,13 +19,17 @@ type Props = WithDatabaseArgs & {
|
||||
}
|
||||
|
||||
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
|
||||
const displayName = observeChannel(database, channelId).pipe(switchMap((c) => of$(c?.displayName)));
|
||||
const settings = observeChannelSettings(database, channelId);
|
||||
const userNotifyLevel = observeCurrentUser(database).pipe(switchMap((u) => of$(getNotificationProps(u).push)));
|
||||
const notifyLevel = settings.pipe(
|
||||
switchMap((s) => of$(s?.notifyProps.push)),
|
||||
);
|
||||
|
||||
return {
|
||||
displayName,
|
||||
notifyLevel,
|
||||
userNotifyLevel,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -7,54 +7,85 @@ import {Platform} from 'react-native';
|
||||
|
||||
import OptionItem from '@components/option_item';
|
||||
import {NotificationLevel, Screens} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {t} from '@i18n';
|
||||
import {goToScreen} from '@screens/navigation';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
import type {Options} from 'react-native-navigation';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
displayName: string;
|
||||
notifyLevel: NotificationLevel;
|
||||
userNotifyLevel: NotificationLevel;
|
||||
}
|
||||
|
||||
const NotificationPreference = ({channelId, notifyLevel}: Props) => {
|
||||
const notificationLevel = (notifyLevel: NotificationLevel) => {
|
||||
let id = '';
|
||||
let defaultMessage = '';
|
||||
switch (notifyLevel) {
|
||||
case NotificationLevel.ALL: {
|
||||
id = t('channel_info.notification.all');
|
||||
defaultMessage = 'All';
|
||||
break;
|
||||
}
|
||||
case NotificationLevel.MENTION: {
|
||||
id = t('channel_info.notification.mention');
|
||||
defaultMessage = 'Mentions';
|
||||
break;
|
||||
}
|
||||
case NotificationLevel.NONE: {
|
||||
id = t('channel_info.notification.none');
|
||||
defaultMessage = 'Never';
|
||||
break;
|
||||
}
|
||||
default:
|
||||
id = t('channel_info.notification.default');
|
||||
defaultMessage = 'Default';
|
||||
break;
|
||||
}
|
||||
|
||||
return {id, defaultMessage};
|
||||
};
|
||||
|
||||
const NotificationPreference = ({channelId, displayName, notifyLevel, userNotifyLevel}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const theme = useTheme();
|
||||
const title = formatMessage({id: 'channel_info.mobile_notifications', defaultMessage: 'Mobile Notifications'});
|
||||
|
||||
const goToMentions = preventDoubleTap(() => {
|
||||
goToScreen(Screens.CHANNEL_MENTION, title, {channelId});
|
||||
const goToChannelNotificationPreferences = preventDoubleTap(() => {
|
||||
const options: Options = {
|
||||
topBar: {
|
||||
title: {
|
||||
text: title,
|
||||
},
|
||||
subtitle: {
|
||||
color: changeOpacity(theme.sidebarHeaderTextColor, 0.72),
|
||||
text: displayName,
|
||||
},
|
||||
backButton: {
|
||||
popStackOnPress: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
goToScreen(Screens.CHANNEL_NOTIFICATION_PREFERENCES, title, {channelId}, options);
|
||||
});
|
||||
|
||||
const notificationLevelToText = () => {
|
||||
let id = '';
|
||||
let defaultMessage = '';
|
||||
switch (notifyLevel) {
|
||||
case NotificationLevel.ALL: {
|
||||
id = t('channel_info.notification.all');
|
||||
defaultMessage = 'All';
|
||||
break;
|
||||
}
|
||||
case NotificationLevel.MENTION: {
|
||||
id = t('channel_info.notification.mention');
|
||||
defaultMessage = 'Mentions';
|
||||
break;
|
||||
}
|
||||
case NotificationLevel.NONE: {
|
||||
id = t('channel_info.notification.none');
|
||||
defaultMessage = 'Never';
|
||||
break;
|
||||
}
|
||||
default:
|
||||
id = t('channel_info.notification.default');
|
||||
defaultMessage = 'Default';
|
||||
break;
|
||||
if (notifyLevel === NotificationLevel.DEFAULT) {
|
||||
const userLevel = notificationLevel(userNotifyLevel);
|
||||
return formatMessage(userLevel);
|
||||
}
|
||||
|
||||
return formatMessage({id, defaultMessage});
|
||||
const channelLevel = notificationLevel(notifyLevel);
|
||||
return formatMessage(channelLevel);
|
||||
};
|
||||
|
||||
return (
|
||||
<OptionItem
|
||||
action={goToMentions}
|
||||
action={goToChannelNotificationPreferences}
|
||||
label={title}
|
||||
icon='cellphone'
|
||||
type={Platform.select({ios: 'arrow', default: 'default'})}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import {LayoutAnimation} from 'react-native';
|
||||
import {useSharedValue} from 'react-native-reanimated';
|
||||
|
||||
import {updateChannelNotifyProps} from '@actions/remote/channel';
|
||||
import SettingsContainer from '@components/settings/container';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import useDidUpdate from '@hooks/did_update';
|
||||
import useBackNavigation from '@hooks/navigate_back';
|
||||
|
||||
import {popTopScreen} from '../navigation';
|
||||
|
||||
import MutedBanner, {MUTED_BANNER_HEIGHT} from './muted_banner';
|
||||
import NotifyAbout, {BLOCK_TITLE_HEIGHT} from './notify_about';
|
||||
import ResetToDefault from './reset';
|
||||
import ThreadReplies from './thread_replies';
|
||||
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
componentId: AvailableScreens;
|
||||
defaultLevel: NotificationLevel;
|
||||
defaultThreadReplies: 'all' | 'mention';
|
||||
isCRTEnabled: boolean;
|
||||
isMuted: boolean;
|
||||
notifyLevel?: NotificationLevel;
|
||||
notifyThreadReplies?: 'all' | 'mention';
|
||||
}
|
||||
|
||||
const ChannelNotificationPreferences = ({channelId, componentId, defaultLevel, defaultThreadReplies, isCRTEnabled, isMuted, notifyLevel, notifyThreadReplies}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const defaultNotificationReplies = defaultThreadReplies === 'all';
|
||||
const diffNotificationLevel = notifyLevel !== 'default' && notifyLevel !== defaultLevel;
|
||||
const notifyTitleTop = useSharedValue((isMuted ? MUTED_BANNER_HEIGHT : 0) + BLOCK_TITLE_HEIGHT);
|
||||
const [notifyAbout, setNotifyAbout] = useState<NotificationLevel>((notifyLevel === undefined || notifyLevel === 'default') ? defaultLevel : notifyLevel);
|
||||
const [threadReplies, setThreadReplies] = useState<boolean>((notifyThreadReplies || defaultThreadReplies) === 'all');
|
||||
const [resetDefaultVisible, setResetDefaultVisible] = useState(diffNotificationLevel || defaultNotificationReplies !== threadReplies);
|
||||
|
||||
useDidUpdate(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
}, [isMuted]);
|
||||
|
||||
const onResetPressed = useCallback(() => {
|
||||
setResetDefaultVisible(false);
|
||||
setNotifyAbout(defaultLevel);
|
||||
setThreadReplies(defaultNotificationReplies);
|
||||
}, [defaultLevel, defaultNotificationReplies]);
|
||||
|
||||
const onNotificationLevel = useCallback((level: NotificationLevel) => {
|
||||
setNotifyAbout(level);
|
||||
setResetDefaultVisible(level !== defaultLevel || defaultNotificationReplies !== threadReplies);
|
||||
}, [defaultLevel, defaultNotificationReplies, threadReplies]);
|
||||
|
||||
const onSetThreadReplies = useCallback((value: boolean) => {
|
||||
setThreadReplies(value);
|
||||
setResetDefaultVisible(defaultNotificationReplies !== value || notifyAbout !== defaultLevel);
|
||||
}, [defaultLevel, defaultNotificationReplies, notifyAbout]);
|
||||
|
||||
const save = useCallback(() => {
|
||||
const pushThreads = threadReplies ? 'all' : 'mention';
|
||||
|
||||
if (notifyLevel !== notifyAbout || (isCRTEnabled && pushThreads !== notifyThreadReplies)) {
|
||||
const props: Partial<ChannelNotifyProps> = {push: notifyAbout};
|
||||
if (isCRTEnabled) {
|
||||
props.push_threads = pushThreads;
|
||||
}
|
||||
|
||||
updateChannelNotifyProps(serverUrl, channelId, props);
|
||||
}
|
||||
popTopScreen(componentId);
|
||||
}, [channelId, componentId, isCRTEnabled, notifyAbout, notifyLevel, notifyThreadReplies, serverUrl, threadReplies]);
|
||||
|
||||
useBackNavigation(save);
|
||||
useAndroidHardwareBackHandler(componentId, save);
|
||||
|
||||
return (
|
||||
<SettingsContainer testID='push_notification_settings'>
|
||||
{isMuted && <MutedBanner channelId={channelId}/>}
|
||||
{resetDefaultVisible &&
|
||||
<ResetToDefault
|
||||
onPress={onResetPressed}
|
||||
topPosition={notifyTitleTop}
|
||||
/>
|
||||
}
|
||||
<NotifyAbout
|
||||
defaultLevel={defaultLevel}
|
||||
isMuted={isMuted}
|
||||
notifyLevel={notifyAbout}
|
||||
notifyTitleTop={notifyTitleTop}
|
||||
onPress={onNotificationLevel}
|
||||
/>
|
||||
{isCRTEnabled &&
|
||||
<ThreadReplies
|
||||
isSelected={threadReplies}
|
||||
onPress={onSetThreadReplies}
|
||||
notifyLevel={notifyAbout}
|
||||
/>
|
||||
}
|
||||
</SettingsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelNotificationPreferences;
|
||||
53
app/screens/channel_notification_preferences/index.ts
Normal file
53
app/screens/channel_notification_preferences/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {observeChannelSettings, observeIsMutedSetting} from '@queries/servers/channel';
|
||||
import {observeIsCRTEnabled} from '@queries/servers/thread';
|
||||
import {observeCurrentUser} from '@queries/servers/user';
|
||||
import {getNotificationProps} from '@utils/user';
|
||||
|
||||
import ChannelNotificationPreferences from './channel_notification_preferences';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
type EnhancedProps = WithDatabaseArgs & {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
const enhanced = withObservables([], ({channelId, database}: EnhancedProps) => {
|
||||
const settings = observeChannelSettings(database, channelId);
|
||||
const isCRTEnabled = observeIsCRTEnabled(database);
|
||||
const isMuted = observeIsMutedSetting(database, channelId);
|
||||
const notifyProps = observeCurrentUser(database).pipe(switchMap((u) => of$(getNotificationProps(u))));
|
||||
|
||||
const notifyLevel = settings.pipe(
|
||||
switchMap((s) => of$(s?.notifyProps.push)),
|
||||
);
|
||||
|
||||
const notifyThreadReplies = settings.pipe(
|
||||
switchMap((s) => of$(s?.notifyProps.push_threads)),
|
||||
);
|
||||
|
||||
const defaultLevel = notifyProps.pipe(
|
||||
switchMap((n) => of$(n?.push)),
|
||||
);
|
||||
const defaultThreadReplies = notifyProps.pipe(
|
||||
switchMap((n) => of$(n?.push_threads)),
|
||||
);
|
||||
|
||||
return {
|
||||
isCRTEnabled,
|
||||
isMuted,
|
||||
notifyLevel,
|
||||
notifyThreadReplies,
|
||||
defaultLevel,
|
||||
defaultThreadReplies,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(ChannelNotificationPreferences));
|
||||
102
app/screens/channel_notification_preferences/muted_banner.tsx
Normal file
102
app/screens/channel_notification_preferences/muted_banner.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {View} from 'react-native';
|
||||
import Animated, {FlipOutXUp} from 'react-native-reanimated';
|
||||
|
||||
import {toggleMuteChannel} from '@actions/remote/channel';
|
||||
import Button from '@app/components/button';
|
||||
import CompassIcon from '@app/components/compass_icon';
|
||||
import FormattedText from '@app/components/formatted_text';
|
||||
import {useServerUrl} from '@app/context/server';
|
||||
import {useTheme} from '@app/context/theme';
|
||||
import {preventDoubleTap} from '@app/utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export const MUTED_BANNER_HEIGHT = 200;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
button: {width: '55%'},
|
||||
container: {
|
||||
backgroundColor: changeOpacity(theme.sidebarTextActiveBorder, 0.16),
|
||||
borderRadius: 4,
|
||||
marginHorizontal: 20,
|
||||
marginVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
height: MUTED_BANNER_HEIGHT,
|
||||
},
|
||||
contentText: {
|
||||
...typography('Body', 200),
|
||||
color: theme.centerChannelColor,
|
||||
marginTop: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginTop: 16,
|
||||
},
|
||||
title: {
|
||||
...typography('Heading', 200),
|
||||
color: theme.centerChannelColor,
|
||||
marginLeft: 10,
|
||||
paddingTop: 5,
|
||||
},
|
||||
}));
|
||||
|
||||
const MutedBanner = ({channelId}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const onPress = useCallback(preventDoubleTap(() => {
|
||||
toggleMuteChannel(serverUrl, channelId, false);
|
||||
}), [channelId, serverUrl]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
exiting={FlipOutXUp}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.titleContainer}>
|
||||
<CompassIcon
|
||||
name='bell-off-outline'
|
||||
size={24}
|
||||
color={theme.linkColor}
|
||||
/>
|
||||
<FormattedText
|
||||
id='channel_notification_preferences.muted_title'
|
||||
defaultMessage='This channel is muted'
|
||||
style={styles.title}
|
||||
/>
|
||||
</View>
|
||||
<FormattedText
|
||||
id='channel_notification_preferences.muted_content'
|
||||
defaultMessage='You can change the notification settings, but you will not receive notifications until the channel is unmuted.'
|
||||
style={styles.contentText}
|
||||
/>
|
||||
<Button
|
||||
buttonType='default'
|
||||
onPress={onPress}
|
||||
text={formatMessage({
|
||||
id: 'channel_notification_preferences.unmute_content',
|
||||
defaultMessage: 'Unmute channel',
|
||||
})}
|
||||
theme={theme}
|
||||
backgroundStyle={styles.button}
|
||||
iconName='bell-outline'
|
||||
iconSize={18}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MutedBanner;
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {LayoutChangeEvent, View} from 'react-native';
|
||||
|
||||
import SettingBlock from '@components/settings/block';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {NotificationLevel} from '@constants';
|
||||
import {t} from '@i18n';
|
||||
|
||||
import type {SharedValue} from 'react-native-reanimated';
|
||||
|
||||
type Props = {
|
||||
isMuted: boolean;
|
||||
defaultLevel: NotificationLevel;
|
||||
notifyLevel: NotificationLevel;
|
||||
notifyTitleTop: SharedValue<number>;
|
||||
onPress: (level: NotificationLevel) => void;
|
||||
}
|
||||
|
||||
type NotifPrefOptions = {
|
||||
defaultMessage: string;
|
||||
id: string;
|
||||
testID: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const BLOCK_TITLE_HEIGHT = 13;
|
||||
|
||||
const NOTIFY_ABOUT = {id: t('channel_notification_preferences.notify_about'), defaultMessage: 'Notify me about...'};
|
||||
|
||||
const NOTIFY_OPTIONS: Record<string, NotifPrefOptions> = {
|
||||
[NotificationLevel.ALL]: {
|
||||
defaultMessage: 'All new messages',
|
||||
id: t('channel_notification_preferences.notification.all'),
|
||||
testID: 'channel_notification_preferences.notification.all',
|
||||
value: NotificationLevel.ALL,
|
||||
},
|
||||
[NotificationLevel.MENTION]: {
|
||||
defaultMessage: 'Mentions, direct messages only',
|
||||
id: t('channel_notification_preferences.notification.mention'),
|
||||
testID: 'channel_notification_preferences.notification.mention',
|
||||
value: NotificationLevel.MENTION,
|
||||
},
|
||||
[NotificationLevel.NONE]: {
|
||||
defaultMessage: 'Nothing',
|
||||
id: t('channel_notification_preferences.notification.none'),
|
||||
testID: 'channel_notification_preferences.notification.none',
|
||||
value: NotificationLevel.NONE,
|
||||
},
|
||||
};
|
||||
|
||||
const NotifyAbout = ({defaultLevel, isMuted, notifyLevel, notifyTitleTop, onPress}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
const {y} = e.nativeEvent.layout;
|
||||
|
||||
notifyTitleTop.value = y > 0 ? y + 10 : BLOCK_TITLE_HEIGHT;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SettingBlock
|
||||
headerText={NOTIFY_ABOUT}
|
||||
headerStyles={{marginTop: isMuted ? 8 : 12}}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{Object.keys(NOTIFY_OPTIONS).map((key) => {
|
||||
const {id, defaultMessage, value, testID} = NOTIFY_OPTIONS[key];
|
||||
const defaultOption = key === defaultLevel ? formatMessage({id: 'channel_notification_preferences.default', defaultMessage: '(default)'}) : '';
|
||||
const label = `${formatMessage({id, defaultMessage})} ${defaultOption}`;
|
||||
|
||||
return (
|
||||
<View key={`notif_pref_option${key}`}>
|
||||
<SettingOption
|
||||
action={onPress}
|
||||
label={label}
|
||||
selected={notifyLevel === key}
|
||||
testID={testID}
|
||||
type='select'
|
||||
value={value}
|
||||
/>
|
||||
<SettingSeparator/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</SettingBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotifyAbout;
|
||||
62
app/screens/channel_notification_preferences/reset.tsx
Normal file
62
app/screens/channel_notification_preferences/reset.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {TouchableOpacity} from 'react-native';
|
||||
import Animated, {SharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
type Props = {
|
||||
onPress: () => void;
|
||||
topPosition: SharedValue<number>;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
zIndex: 1,
|
||||
},
|
||||
row: {flexDirection: 'row'},
|
||||
text: {
|
||||
color: theme.linkColor,
|
||||
marginLeft: 7,
|
||||
...typography('Heading', 100),
|
||||
},
|
||||
}));
|
||||
|
||||
const ResetToDefault = ({onPress, topPosition}: Props) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
top: withTiming(topPosition.value, {duration: 100}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, animatedStyle]}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={styles.row}
|
||||
>
|
||||
<CompassIcon
|
||||
name='refresh'
|
||||
size={18}
|
||||
color={theme.linkColor}
|
||||
/>
|
||||
<FormattedText
|
||||
id='channel_notification_preferences.reset_default'
|
||||
defaultMessage='Reset to default'
|
||||
style={styles.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetToDefault;
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import SettingBlock from '@components/settings/block';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {NotificationLevel} from '@constants';
|
||||
import {t} from '@i18n';
|
||||
|
||||
type Props = {
|
||||
isSelected: boolean;
|
||||
notifyLevel: NotificationLevel;
|
||||
onPress: (selected: boolean) => void;
|
||||
}
|
||||
|
||||
type NotifPrefOptions = {
|
||||
defaultMessage: string;
|
||||
id: string;
|
||||
testID: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const THREAD_REPLIES = {id: t('channel_notification_preferences.thread_replies'), defaultMessage: 'Thread replies'};
|
||||
const NOTIFY_OPTIONS_THREAD: Record<string, NotifPrefOptions> = {
|
||||
THREAD_REPLIES: {
|
||||
defaultMessage: 'Notify me about replies to threads I’m following in this channel',
|
||||
id: t('channel_notification_preferences.notification.thread_replies'),
|
||||
testID: 'channel_notification_preferences.notification.thread_replies',
|
||||
value: 'thread_replies',
|
||||
},
|
||||
};
|
||||
|
||||
const NotifyAbout = ({isSelected, notifyLevel, onPress}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
if ([NotificationLevel.NONE, NotificationLevel.ALL].includes(notifyLevel)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingBlock headerText={THREAD_REPLIES}>
|
||||
<SettingOption
|
||||
action={onPress}
|
||||
label={formatMessage({id: NOTIFY_OPTIONS_THREAD.THREAD_REPLIES.id, defaultMessage: NOTIFY_OPTIONS_THREAD.THREAD_REPLIES.defaultMessage})}
|
||||
testID={NOTIFY_OPTIONS_THREAD.THREAD_REPLIES.testID}
|
||||
type='toggle'
|
||||
selected={isSelected}
|
||||
/>
|
||||
<SettingSeparator/>
|
||||
</SettingBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotifyAbout;
|
||||
@@ -197,8 +197,8 @@ export default function CreateDirectMessage({
|
||||
setSelectedIds((current) => removeProfileFromList(current, id));
|
||||
}, []);
|
||||
|
||||
const createDirectChannel = useCallback(async (id: string): Promise<boolean> => {
|
||||
const user = selectedIds[id];
|
||||
const createDirectChannel = useCallback(async (id: string, selectedUser?: UserProfile): Promise<boolean> => {
|
||||
const user = selectedUser || selectedIds[id];
|
||||
const displayName = displayUsername(user, intl.locale, teammateNameDisplay);
|
||||
const result = await makeDirectChannel(serverUrl, id, displayName);
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function CreateDirectMessage({
|
||||
return !result.error;
|
||||
}, [serverUrl]);
|
||||
|
||||
const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}) => {
|
||||
const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}, selectedUser?: UserProfile) => {
|
||||
if (startingConversation) {
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +233,7 @@ export default function CreateDirectMessage({
|
||||
} else if (idsToUse.length > 1) {
|
||||
success = await createGroupChannel(idsToUse);
|
||||
} else {
|
||||
success = await createDirectChannel(idsToUse[0]);
|
||||
success = await createDirectChannel(idsToUse[0], selectedUser);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
@@ -249,7 +249,7 @@ export default function CreateDirectMessage({
|
||||
[currentUserId]: true,
|
||||
};
|
||||
|
||||
startConversation(selectedId);
|
||||
startConversation(selectedId, user);
|
||||
} else {
|
||||
clearSearch();
|
||||
setSelectedIds((current) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {General, Channel} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
|
||||
import {useIsTablet, useKeyboardHeight, useModalPosition} from '@hooks/device';
|
||||
import {useInputPropagation} from '@hooks/input';
|
||||
import {t} from '@i18n';
|
||||
import {
|
||||
changeOpacity,
|
||||
@@ -126,6 +127,8 @@ export default function ChannelInfoForm({
|
||||
const dimensions = useWindowDimensions();
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
const [propagateValue, shouldProcessEvent] = useInputPropagation();
|
||||
|
||||
const keyboardHeight = useKeyboardHeight();
|
||||
const [keyboardVisible, setKeyBoardVisible] = useState(false);
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
@@ -193,6 +196,18 @@ export default function ChannelInfoForm({
|
||||
}
|
||||
}, [keyboardHeight]);
|
||||
|
||||
const onHeaderAutocompleteChange = useCallback((value: string) => {
|
||||
onHeaderChange(value);
|
||||
propagateValue(value);
|
||||
}, [onHeaderChange]);
|
||||
|
||||
const onHeaderInputChange = useCallback((value: string) => {
|
||||
if (!shouldProcessEvent(value)) {
|
||||
return;
|
||||
}
|
||||
onHeaderChange(value);
|
||||
}, [onHeaderChange]);
|
||||
|
||||
const onLayoutError = useCallback((e: LayoutChangeEvent) => {
|
||||
setErrorHeight(e.nativeEvent.layout.height);
|
||||
}, []);
|
||||
@@ -371,7 +386,7 @@ export default function ChannelInfoForm({
|
||||
enablesReturnKeyAutomatically={true}
|
||||
label={labelHeader}
|
||||
placeholder={placeholderHeader}
|
||||
onChangeText={onHeaderChange}
|
||||
onChangeText={onHeaderInputChange}
|
||||
multiline={true}
|
||||
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
|
||||
returnKeyType='next'
|
||||
@@ -397,7 +412,7 @@ export default function ChannelInfoForm({
|
||||
</KeyboardAwareScrollView>
|
||||
<Autocomplete
|
||||
position={animatedAutocompletePosition}
|
||||
updateValue={onHeaderChange}
|
||||
updateValue={onHeaderAutocompleteChange}
|
||||
cursorPosition={header.length}
|
||||
value={header}
|
||||
nestedScrollEnabled={true}
|
||||
|
||||
@@ -15,6 +15,7 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
|
||||
import {useIsTablet, useKeyboardHeight, useModalPosition} from '@hooks/device';
|
||||
import useDidUpdate from '@hooks/did_update';
|
||||
import {useInputPropagation} from '@hooks/input';
|
||||
import useNavButtonPressed from '@hooks/navigation_button_pressed';
|
||||
import PostError from '@screens/edit_post/post_error';
|
||||
import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation';
|
||||
@@ -61,6 +62,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
|
||||
const [errorExtra, setErrorExtra] = useState<string | undefined>();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const [propagateValue, shouldProcessEvent] = useInputPropagation();
|
||||
|
||||
const mainView = useRef<View>(null);
|
||||
const modalPosition = useModalPosition(mainView);
|
||||
@@ -123,10 +125,8 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
|
||||
});
|
||||
}, [componentId, intl, theme]);
|
||||
|
||||
const onChangeText = useCallback((message: string) => {
|
||||
setPostMessage(message);
|
||||
const onChangeTextCommon = useCallback((message: string) => {
|
||||
const tooLong = message.trim().length > maxPostSize;
|
||||
|
||||
if (tooLong) {
|
||||
const line = intl.formatMessage({id: 'mobile.message_length.message_split_left', defaultMessage: 'Message exceeds the character limit'});
|
||||
const extra = `${message.trim().length} / ${maxPostSize}`;
|
||||
@@ -134,7 +134,21 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
|
||||
setErrorExtra(extra);
|
||||
}
|
||||
toggleSaveButton(post.message !== message);
|
||||
}, [intl, maxPostSize, toggleSaveButton]);
|
||||
}, [intl, maxPostSize, post.message, toggleSaveButton]);
|
||||
|
||||
const onAutocompleteChangeText = useCallback((message: string) => {
|
||||
setPostMessage(message);
|
||||
propagateValue(message);
|
||||
onChangeTextCommon(message);
|
||||
}, [onChangeTextCommon]);
|
||||
|
||||
const onInputChangeText = useCallback((message: string) => {
|
||||
if (!shouldProcessEvent(message)) {
|
||||
return;
|
||||
}
|
||||
setPostMessage(message);
|
||||
onChangeTextCommon(message);
|
||||
}, [onChangeTextCommon]);
|
||||
|
||||
const handleUIUpdates = useCallback((res: {error?: unknown}) => {
|
||||
if (res.error) {
|
||||
@@ -233,7 +247,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
|
||||
<EditPostInput
|
||||
hasError={Boolean(errorLine)}
|
||||
message={postMessage}
|
||||
onChangeText={onChangeText}
|
||||
onChangeText={onInputChangeText}
|
||||
onTextSelectionChange={onTextSelectionChange}
|
||||
ref={postInputRef}
|
||||
/>
|
||||
@@ -244,7 +258,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
|
||||
hasFilesAttached={hasFilesAttached}
|
||||
nestedScrollEnabled={true}
|
||||
rootId={post.rootId}
|
||||
updateValue={onChangeText}
|
||||
updateValue={onAutocompleteChangeText}
|
||||
value={postMessage}
|
||||
cursorPosition={cursorPosition}
|
||||
position={animatedAutocompletePosition}
|
||||
|
||||
@@ -102,6 +102,7 @@ const EditProfile = ({
|
||||
popTopScreen(componentId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const enableSaveButton = useCallback((value: boolean) => {
|
||||
if (!isTablet) {
|
||||
const buttons = {
|
||||
@@ -114,18 +115,19 @@ const EditProfile = ({
|
||||
}
|
||||
setCanSave(value);
|
||||
}, [componentId, rightButton]);
|
||||
|
||||
const submitUser = useCallback(preventDoubleTap(async () => {
|
||||
enableSaveButton(false);
|
||||
setError(undefined);
|
||||
setUpdating(true);
|
||||
try {
|
||||
const newUserInfo: Partial<UserProfile> = {
|
||||
email: userInfo.email,
|
||||
first_name: userInfo.firstName,
|
||||
last_name: userInfo.lastName,
|
||||
nickname: userInfo.nickname,
|
||||
position: userInfo.position,
|
||||
username: userInfo.username,
|
||||
email: userInfo.email.trim(),
|
||||
first_name: userInfo.firstName.trim(),
|
||||
last_name: userInfo.lastName.trim(),
|
||||
nickname: userInfo.nickname.trim(),
|
||||
position: userInfo.position.trim(),
|
||||
username: userInfo.username.trim(),
|
||||
};
|
||||
const localPath = changedProfilePicture.current?.localPath;
|
||||
const profileImageRemoved = changedProfilePicture.current?.isRemoved;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {Platform, StyleSheet} from 'react-native';
|
||||
import {InteractionManager, Platform, StyleSheet} from 'react-native';
|
||||
import Animated, {
|
||||
EntryAnimationsValues, ExitAnimationsValues, FadeIn, FadeOut,
|
||||
SharedValue, useAnimatedStyle, withDelay, withTiming,
|
||||
@@ -125,13 +125,11 @@ const SkinToneSelector = ({skinTone = 'default', containerWidth, isSearching, tu
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
if (!tutorialWatched) {
|
||||
setTooltipVisible(true);
|
||||
}
|
||||
}, 750);
|
||||
|
||||
return () => clearTimeout(t);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,29 +12,9 @@ import TestHelper from '@test/test_helper';
|
||||
import CategoryBody from '.';
|
||||
|
||||
import type CategoryModel from '@typings/database/models/servers/category';
|
||||
import type CategoryChannelModel from '@typings/database/models/servers/category_channel';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
|
||||
const {SERVER: {CATEGORY}} = MM_TABLES;
|
||||
|
||||
jest.mock('@queries/servers/categories', () => {
|
||||
const Queries = jest.requireActual('@queries/servers/categories');
|
||||
const switchMap = jest.requireActual('rxjs/operators').switchMap;
|
||||
const mQ = jest.requireActual('@nozbe/watermelondb').Q;
|
||||
|
||||
return {
|
||||
...Queries,
|
||||
observeChannelsByCategoryChannelSortOrder: (database: Database, category: CategoryModel, excludeIds?: string[]) => {
|
||||
return category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']).pipe(
|
||||
switchMap((categoryChannels: CategoryChannelModel[]) => {
|
||||
const ids = categoryChannels.filter((cc) => excludeIds?.includes(cc.channelId)).map((cc) => cc.channelId);
|
||||
return database.get<ChannelModel>('Channel').query(mQ.where('id', mQ.oneOf(ids))).observe();
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('components/channel_list/categories/body', () => {
|
||||
let database: Database;
|
||||
let category: CategoryModel;
|
||||
|
||||
@@ -7,7 +7,6 @@ import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 're
|
||||
|
||||
import {fetchDirectChannelsInfo} from '@actions/remote/channel';
|
||||
import ChannelItem from '@components/channel_item';
|
||||
import {DMS_CATEGORY} from '@constants/categories';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {isDMorGM} from '@utils/channel';
|
||||
|
||||
@@ -17,7 +16,6 @@ import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
type Props = {
|
||||
sortedChannels: ChannelModel[];
|
||||
category: CategoryModel;
|
||||
limit: number;
|
||||
onChannelSwitch: (channelId: string) => void;
|
||||
unreadIds: Set<string>;
|
||||
unreadsOnTop: boolean;
|
||||
@@ -25,16 +23,13 @@ type Props = {
|
||||
|
||||
const extractKey = (item: ChannelModel) => item.id;
|
||||
|
||||
const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, limit, onChannelSwitch}: Props) => {
|
||||
const CategoryBody = ({sortedChannels, unreadIds, unreadsOnTop, category, onChannelSwitch}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const ids = useMemo(() => {
|
||||
const filteredChannels = unreadsOnTop ? sortedChannels.filter((c) => !unreadIds.has(c.id)) : sortedChannels;
|
||||
|
||||
if (category.type === DMS_CATEGORY && limit > 0) {
|
||||
return filteredChannels.slice(0, limit);
|
||||
}
|
||||
return filteredChannels;
|
||||
}, [category.type, limit, sortedChannels, unreadIds, unreadsOnTop]);
|
||||
}, [category.type, sortedChannels, unreadIds, unreadsOnTop]);
|
||||
|
||||
const unreadChannels = useMemo(() => {
|
||||
return unreadsOnTop ? [] : ids.filter((c) => unreadIds.has(c.id));
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Database, Q} from '@nozbe/watermelondb';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {combineLatest, of as of$} from 'rxjs';
|
||||
import {map, switchMap, combineLatestWith} from 'rxjs/operators';
|
||||
import {of as of$, Observable} from 'rxjs';
|
||||
import {switchMap, combineLatestWith, distinctUntilChanged} from 'rxjs/operators';
|
||||
|
||||
import {General, Preferences} from '@constants';
|
||||
import {Preferences} from '@constants';
|
||||
import {DMS_CATEGORY} from '@constants/categories';
|
||||
import {getSidebarPreferenceAsBool} from '@helpers/api/preference';
|
||||
import {observeChannelsByCategoryChannelSortOrder, observeChannelsByLastPostAtInCategory} from '@queries/servers/categories';
|
||||
import {observeArchivedDirectChannels, observeNotifyPropsByChannels, queryChannelsByNames, queryEmptyDirectAndGroupChannels} from '@queries/servers/channel';
|
||||
import {observeArchivedDirectChannels, observeNotifyPropsByChannels} from '@queries/servers/channel';
|
||||
import {queryPreferencesByCategoryAndName, querySidebarPreferences} from '@queries/servers/preference';
|
||||
import {observeCurrentChannelId, observeCurrentUserId, observeLastUnreadChannelId} from '@queries/servers/system';
|
||||
import {getDirectChannelName} from '@utils/channel';
|
||||
import {ChannelWithMyChannel, filterArchivedChannels, filterAutoclosedDMs, filterManuallyClosedDms, getUnreadIds, sortChannels} from '@utils/categories';
|
||||
|
||||
import CategoryBody from './category_body';
|
||||
|
||||
@@ -24,10 +22,6 @@ import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
||||
import type PreferenceModel from '@typings/database/models/servers/preference';
|
||||
|
||||
type ChannelData = Pick<ChannelModel, 'id' | 'displayName'> & {
|
||||
isMuted: boolean;
|
||||
};
|
||||
|
||||
type EnhanceProps = {
|
||||
category: CategoryModel;
|
||||
locale: string;
|
||||
@@ -35,87 +29,45 @@ type EnhanceProps = {
|
||||
isTablet: boolean;
|
||||
} & WithDatabaseArgs
|
||||
|
||||
const sortAlpha = (locale: string, a: ChannelData, b: ChannelData) => {
|
||||
if (a.isMuted && !b.isMuted) {
|
||||
return 1;
|
||||
} else if (!a.isMuted && b.isMuted) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return a.displayName.localeCompare(b.displayName, locale, {numeric: true});
|
||||
};
|
||||
|
||||
const filterArchived = (channels: Array<ChannelModel | null>, currentChannelId: string) => {
|
||||
return channels.filter((c): c is ChannelModel => c != null && ((c.deleteAt > 0 && c.id === currentChannelId) || !c.deleteAt));
|
||||
};
|
||||
|
||||
const buildAlphaData = (channels: ChannelModel[], notifyProps: Record<string, Partial<ChannelNotifyProps>>, locale: string) => {
|
||||
const chanelsById = channels.reduce((result: Record<string, ChannelModel>, c) => {
|
||||
result[c.id] = c;
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
const combined = channels.map((c) => {
|
||||
const s = notifyProps[c.id];
|
||||
return {
|
||||
id: c.id,
|
||||
displayName: c.displayName,
|
||||
isMuted: s?.mark_unread === General.MENTION,
|
||||
};
|
||||
});
|
||||
|
||||
combined.sort(sortAlpha.bind(null, locale));
|
||||
return of$(combined.map((cdata) => chanelsById[cdata.id]));
|
||||
};
|
||||
|
||||
const observeSortedChannels = (database: Database, category: CategoryModel, excludeIds: string[], locale: string) => {
|
||||
switch (category.sorting) {
|
||||
case 'alpha': {
|
||||
const channels = category.channels.extend(Q.where('id', Q.notIn(excludeIds))).observeWithColumns(['display_name']);
|
||||
const notifyProps = channels.pipe(switchMap((cs) => observeNotifyPropsByChannels(database, cs)));
|
||||
return combineLatest([channels, notifyProps]).pipe(
|
||||
switchMap(([cs, np]) => buildAlphaData(cs, np, locale)),
|
||||
);
|
||||
}
|
||||
case 'manual': {
|
||||
return observeChannelsByCategoryChannelSortOrder(database, category, excludeIds);
|
||||
}
|
||||
default:
|
||||
return observeChannelsByLastPostAtInCategory(database, category, excludeIds);
|
||||
}
|
||||
};
|
||||
|
||||
const mapPrefName = (prefs: PreferenceModel[]) => of$(prefs.map((p) => p.name));
|
||||
|
||||
const mapChannelIds = (channels: ChannelModel[] | MyChannelModel[]) => of$(channels.map((c) => c.id));
|
||||
|
||||
const withUserId = withObservables([], ({database}: WithDatabaseArgs) => ({currentUserId: observeCurrentUserId(database)}));
|
||||
|
||||
const enhance = withObservables(['category', 'isTablet', 'locale'], ({category, locale, isTablet, database, currentUserId}: EnhanceProps) => {
|
||||
const dmMap = (p: PreferenceModel) => getDirectChannelName(p.name, currentUserId);
|
||||
const observeCategoryChannels = (category: CategoryModel, myChannels: Observable<MyChannelModel[]>) => {
|
||||
const channels = category.channels.observeWithColumns(['create_at', 'display_name']);
|
||||
const manualSort = category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']);
|
||||
return myChannels.pipe(
|
||||
combineLatestWith(channels, manualSort),
|
||||
switchMap(([my, cs, sorted]) => {
|
||||
const channelMap = new Map<string, ChannelModel>(cs.map((c) => [c.id, c]));
|
||||
const categoryChannelMap = new Map<string, number>(sorted.map((s) => [s.channelId, s.sortOrder]));
|
||||
return of$(my.reduce<ChannelWithMyChannel[]>((result, myChannel) => {
|
||||
const channel = channelMap.get(myChannel.id);
|
||||
if (channel) {
|
||||
const channelWithMyChannel: ChannelWithMyChannel = {
|
||||
channel,
|
||||
myChannel,
|
||||
sortOrder: categoryChannelMap.get(myChannel.id) || 0,
|
||||
};
|
||||
result.push(channelWithMyChannel);
|
||||
}
|
||||
|
||||
const currentChannelId = observeCurrentChannelId(database);
|
||||
return result;
|
||||
}, []));
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const hiddenDmIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, undefined, 'false').
|
||||
observeWithColumns(['value']).pipe(
|
||||
switchMap((prefs: PreferenceModel[]) => {
|
||||
const names = prefs.map(dmMap);
|
||||
const channels = queryChannelsByNames(database, names).observe();
|
||||
const enhanced = withObservables([], ({category, currentUserId, database, isTablet, locale}: EnhanceProps) => {
|
||||
const categoryMyChannels = category.myChannels.observeWithColumns(['last_post_at', 'is_unread']);
|
||||
const channelsWithMyChannel = observeCategoryChannels(category, categoryMyChannels);
|
||||
const currentChannelId = isTablet ? observeCurrentChannelId(database) : of$('');
|
||||
const lastUnreadId = isTablet ? observeLastUnreadChannelId(database) : of$(undefined);
|
||||
|
||||
return channels.pipe(
|
||||
switchMap(mapChannelIds),
|
||||
);
|
||||
}),
|
||||
const unreadsOnTop = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS).
|
||||
observeWithColumns(['value']).
|
||||
pipe(
|
||||
switchMap((prefs: PreferenceModel[]) => of$(getSidebarPreferenceAsBool(prefs, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))),
|
||||
);
|
||||
|
||||
const emptyDmIds = queryEmptyDirectAndGroupChannels(database).observeWithColumns(['last_post_at']).pipe(
|
||||
switchMap(mapChannelIds),
|
||||
);
|
||||
|
||||
const archivedDmIds = observeArchivedDirectChannels(database, currentUserId).pipe(
|
||||
switchMap(mapChannelIds),
|
||||
);
|
||||
|
||||
let limit = of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT);
|
||||
if (category.type === DMS_CATEGORY) {
|
||||
limit = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_LIMIT_DMS).
|
||||
@@ -126,54 +78,61 @@ const enhance = withObservables(['category', 'isTablet', 'locale'], ({category,
|
||||
);
|
||||
}
|
||||
|
||||
const unreadsOnTop = querySidebarPreferences(database, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS).
|
||||
observeWithColumns(['value']).
|
||||
pipe(
|
||||
switchMap((prefs: PreferenceModel[]) => of$(getSidebarPreferenceAsBool(prefs, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS))),
|
||||
);
|
||||
|
||||
const lastUnreadId = isTablet ? observeLastUnreadChannelId(database) : of$(undefined);
|
||||
|
||||
const hiddenChannelIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, undefined, 'false').
|
||||
observeWithColumns(['value']).pipe(
|
||||
switchMap(mapPrefName),
|
||||
combineLatestWith(hiddenDmIds, emptyDmIds, archivedDmIds, lastUnreadId),
|
||||
switchMap(([hIds, hDmIds, eDmIds, aDmIds, excludeId]) => {
|
||||
const hidden = new Set(hIds.concat(hDmIds, eDmIds, aDmIds));
|
||||
if (excludeId) {
|
||||
hidden.delete(excludeId);
|
||||
}
|
||||
return of$(hidden);
|
||||
}),
|
||||
);
|
||||
const sortedChannels = hiddenChannelIds.pipe(
|
||||
switchMap((excludeIds) => observeSortedChannels(database, category, Array.from(excludeIds), locale)),
|
||||
combineLatestWith(currentChannelId),
|
||||
map(([channels, ccId]) => filterArchived(channels, ccId)),
|
||||
const notifyPropsPerChannel = categoryMyChannels.pipe(
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
switchMap((mc) => observeNotifyPropsByChannels(database, mc)),
|
||||
);
|
||||
|
||||
const unreadChannels = category.myChannels.observeWithColumns(['mentions_count', 'is_unread']);
|
||||
const notifyProps = unreadChannels.pipe(switchMap((myChannels) => observeNotifyPropsByChannels(database, myChannels)));
|
||||
const unreadIds = unreadChannels.pipe(
|
||||
combineLatestWith(notifyProps, lastUnreadId),
|
||||
map(([my, settings, lastUnread]) => {
|
||||
return my.reduce<Set<string>>((set, m) => {
|
||||
const isMuted = settings[m.id]?.mark_unread === 'mention';
|
||||
if ((isMuted && m.mentionsCount) || (!isMuted && m.isUnread) || m.id === lastUnread) {
|
||||
set.add(m.id);
|
||||
}
|
||||
return set;
|
||||
}, new Set());
|
||||
const hiddenDmPrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, undefined, 'false').
|
||||
observeWithColumns(['value']);
|
||||
const hiddenGmPrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, undefined, 'false').
|
||||
observeWithColumns(['value']);
|
||||
const manuallyClosedPrefs = hiddenDmPrefs.pipe(
|
||||
combineLatestWith(hiddenGmPrefs),
|
||||
switchMap(([dms, gms]) => of$(dms.concat(gms))),
|
||||
);
|
||||
|
||||
const approxViewTimePrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.CHANNEL_APPROXIMATE_VIEW_TIME, undefined).
|
||||
observeWithColumns(['value']);
|
||||
const openTimePrefs = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.CHANNEL_OPEN_TIME, undefined).
|
||||
observeWithColumns(['value']);
|
||||
const autoclosePrefs = approxViewTimePrefs.pipe(
|
||||
combineLatestWith(openTimePrefs),
|
||||
switchMap(([viewTimes, openTimes]) => of$(viewTimes.concat(openTimes))),
|
||||
);
|
||||
|
||||
const categorySorting = category.observe().pipe(
|
||||
switchMap((c) => of$(c.sorting)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const deactivated = (category.type === DMS_CATEGORY) ? observeArchivedDirectChannels(database, currentUserId) : of$(undefined);
|
||||
const sortedChannels = channelsWithMyChannel.pipe(
|
||||
combineLatestWith(categorySorting, currentChannelId, lastUnreadId, notifyPropsPerChannel, manuallyClosedPrefs, autoclosePrefs, deactivated, limit),
|
||||
switchMap(([cwms, sorting, channelId, unreadId, notifyProps, manuallyClosedDms, autoclose, deactivatedDMS, maxDms]) => {
|
||||
let channelsW = cwms;
|
||||
|
||||
channelsW = filterArchivedChannels(channelsW, channelId);
|
||||
channelsW = filterManuallyClosedDms(channelsW, notifyProps, manuallyClosedDms, currentUserId, unreadId);
|
||||
channelsW = filterAutoclosedDMs(category.type, maxDms, channelId, channelsW, autoclose, notifyProps, deactivatedDMS, unreadId);
|
||||
|
||||
return of$(sortChannels(sorting, channelsW, notifyProps, locale));
|
||||
}),
|
||||
);
|
||||
|
||||
const unreadIds = channelsWithMyChannel.pipe(
|
||||
combineLatestWith(notifyPropsPerChannel, lastUnreadId),
|
||||
switchMap(([cwms, notifyProps, unreadId]) => {
|
||||
return of$(getUnreadIds(cwms, notifyProps, unreadId));
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
limit,
|
||||
sortedChannels,
|
||||
unreadsOnTop,
|
||||
unreadIds,
|
||||
category,
|
||||
sortedChannels,
|
||||
unreadIds,
|
||||
unreadsOnTop,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(withUserId(enhance(CategoryBody)));
|
||||
export default withDatabase(withUserId(enhanced(CategoryBody)));
|
||||
|
||||
@@ -54,7 +54,7 @@ const enhanced = withObservables(['currentTeamId', 'isTablet', 'onlyUnreads'], (
|
||||
const channels = myUnreadChannels.pipe(switchMap((myChannels) => observeChannelsByLastPostAt(database, myChannels)));
|
||||
const channelsMap = channels.pipe(switchMap((cs) => of$(makeChannelsMap(cs))));
|
||||
|
||||
return queryMyChannelUnreads(database, currentTeamId).observeWithColumns(['last_post_at', 'is_unread']).pipe(
|
||||
return myUnreadChannels.pipe(
|
||||
combineLatestWith(channelsMap, notifyProps),
|
||||
map(filterAndSortMyChannels),
|
||||
combineLatestWith(lastUnread),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {combineLatest, of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {Permissions} from '@constants';
|
||||
import {observePermissionForTeam} from '@queries/servers/role';
|
||||
@@ -25,6 +25,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
|
||||
const canJoinChannels = combineLatest([currentUser, team]).pipe(
|
||||
switchMap(([u, t]) => observePermissionForTeam(database, t, u, Permissions.JOIN_PUBLIC_CHANNELS, true)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const canCreatePublicChannels = combineLatest([currentUser, team]).pipe(
|
||||
@@ -37,6 +38,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
|
||||
const canCreateChannels = combineLatest([canCreatePublicChannels, canCreatePrivateChannels]).pipe(
|
||||
switchMap(([open, priv]) => of$(open || priv)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const canAddUserToTeam = combineLatest([currentUser, team]).pipe(
|
||||
@@ -48,9 +50,11 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
canJoinChannels,
|
||||
canInvitePeople: combineLatest([enableOpenServer, canAddUserToTeam]).pipe(
|
||||
switchMap(([openServer, addUser]) => of$(openServer && addUser)),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
displayName: team.pipe(
|
||||
switchMap((t) => of$(t?.displayName)),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
pushProxyStatus: observePushVerificationStatus(database),
|
||||
};
|
||||
|
||||
@@ -33,8 +33,8 @@ describe('components/categories_list', () => {
|
||||
it('should render', () => {
|
||||
const wrapper = renderWithEverything(
|
||||
<CategoriesList
|
||||
teamsCount={1}
|
||||
channelsCount={1}
|
||||
moreThanOneTeam={false}
|
||||
hasChannels={true}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
@@ -46,8 +46,8 @@ describe('components/categories_list', () => {
|
||||
const wrapper = renderWithEverything(
|
||||
<CategoriesList
|
||||
isCRTEnabled={true}
|
||||
teamsCount={1}
|
||||
channelsCount={1}
|
||||
moreThanOneTeam={false}
|
||||
hasChannels={true}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
@@ -67,8 +67,8 @@ describe('components/categories_list', () => {
|
||||
jest.useFakeTimers();
|
||||
const wrapper = renderWithEverything(
|
||||
<CategoriesList
|
||||
teamsCount={0}
|
||||
channelsCount={1}
|
||||
moreThanOneTeam={false}
|
||||
hasChannels={true}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
@@ -89,8 +89,8 @@ describe('components/categories_list', () => {
|
||||
jest.useFakeTimers();
|
||||
const wrapper = renderWithEverything(
|
||||
<CategoriesList
|
||||
teamsCount={1}
|
||||
channelsCount={0}
|
||||
moreThanOneTeam={true}
|
||||
hasChannels={false}
|
||||
/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
@@ -27,28 +27,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
}));
|
||||
|
||||
type ChannelListProps = {
|
||||
channelsCount: number;
|
||||
hasChannels: boolean;
|
||||
iconPad?: boolean;
|
||||
isCRTEnabled?: boolean;
|
||||
teamsCount: number;
|
||||
moreThanOneTeam: boolean;
|
||||
};
|
||||
|
||||
const getTabletWidth = (teamsCount: number) => {
|
||||
return TABLET_SIDEBAR_WIDTH - (teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0);
|
||||
const getTabletWidth = (moreThanOneTeam: boolean) => {
|
||||
return TABLET_SIDEBAR_WIDTH - (moreThanOneTeam ? TEAM_SIDEBAR_WIDTH : 0);
|
||||
};
|
||||
|
||||
const CategoriesList = ({channelsCount, iconPad, isCRTEnabled, teamsCount}: ChannelListProps) => {
|
||||
const CategoriesList = ({hasChannels, iconPad, isCRTEnabled, moreThanOneTeam}: ChannelListProps) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const {width} = useWindowDimensions();
|
||||
const isTablet = useIsTablet();
|
||||
const tabletWidth = useSharedValue(isTablet ? getTabletWidth(teamsCount) : 0);
|
||||
const tabletWidth = useSharedValue(isTablet ? getTabletWidth(moreThanOneTeam) : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTablet) {
|
||||
tabletWidth.value = getTabletWidth(teamsCount);
|
||||
tabletWidth.value = getTabletWidth(moreThanOneTeam);
|
||||
}
|
||||
}, [isTablet && teamsCount]);
|
||||
}, [isTablet && moreThanOneTeam]);
|
||||
|
||||
const tabletStyle = useAnimatedStyle(() => {
|
||||
if (!isTablet) {
|
||||
@@ -61,7 +61,7 @@ const CategoriesList = ({channelsCount, iconPad, isCRTEnabled, teamsCount}: Chan
|
||||
}, [isTablet, width]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (channelsCount < 1) {
|
||||
if (!hasChannels) {
|
||||
return (<LoadChannelsError/>);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,10 @@ import Servers from './servers';
|
||||
import type {LaunchType} from '@typings/launch';
|
||||
|
||||
type ChannelProps = {
|
||||
channelsCount: number;
|
||||
hasChannels: boolean;
|
||||
isCRTEnabled: boolean;
|
||||
teamsCount: number;
|
||||
time?: number;
|
||||
hasTeams: boolean;
|
||||
hasMoreThanOneTeam: boolean;
|
||||
isLicensed: boolean;
|
||||
showToS: boolean;
|
||||
launchType: LaunchType;
|
||||
@@ -127,10 +127,10 @@ const ChannelListScreen = (props: ChannelProps) => {
|
||||
}, [theme, insets.top]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.teamsCount) {
|
||||
if (!props.hasTeams) {
|
||||
resetToTeams();
|
||||
}
|
||||
}, [Boolean(props.teamsCount)]);
|
||||
}, [Boolean(props.hasTeams)]);
|
||||
|
||||
useEffect(() => {
|
||||
const back = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
|
||||
@@ -177,13 +177,13 @@ const ChannelListScreen = (props: ChannelProps) => {
|
||||
>
|
||||
<TeamSidebar
|
||||
iconPad={canAddOtherServers}
|
||||
teamsCount={props.teamsCount}
|
||||
hasMoreThanOneTeam={props.hasMoreThanOneTeam}
|
||||
/>
|
||||
<CategoriesList
|
||||
iconPad={canAddOtherServers && props.teamsCount <= 1}
|
||||
iconPad={canAddOtherServers && !props.hasMoreThanOneTeam}
|
||||
isCRTEnabled={props.isCRTEnabled}
|
||||
teamsCount={props.teamsCount}
|
||||
channelsCount={props.channelsCount}
|
||||
moreThanOneTeam={props.hasMoreThanOneTeam}
|
||||
hasChannels={props.hasChannels}
|
||||
/>
|
||||
{isTablet &&
|
||||
<AdditionalTabletView/>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {queryAllMyChannelsForTeam} from '@queries/servers/channel';
|
||||
import {observeCurrentTeamId, observeLicense} from '@queries/servers/system';
|
||||
@@ -21,11 +21,22 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
switchMap((lcs) => (lcs ? of$(lcs.IsLicensed === 'true') : of$(false))),
|
||||
);
|
||||
|
||||
const teamsCount = queryMyTeams(database).observeCount(false);
|
||||
|
||||
return {
|
||||
isCRTEnabled: observeIsCRTEnabled(database),
|
||||
teamsCount: queryMyTeams(database).observeCount(false),
|
||||
channelsCount: observeCurrentTeamId(database).pipe(
|
||||
hasTeams: teamsCount.pipe(
|
||||
switchMap((v) => of$(v > 0)),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
hasMoreThanOneTeam: teamsCount.pipe(
|
||||
switchMap((v) => of$(v > 1)),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
hasChannels: observeCurrentTeamId(database).pipe(
|
||||
switchMap((id) => (id ? queryAllMyChannelsForTeam(database, id).observeCount(false) : of$(0))),
|
||||
switchMap((v) => of$(v > 0)),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
isLicensed,
|
||||
showToS: observeShowToS(database),
|
||||
|
||||
@@ -9,7 +9,6 @@ import Swipeable from 'react-native-gesture-handler/Swipeable';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
|
||||
import {storeMultiServerTutorial} from '@actions/app/global';
|
||||
import {appEntry} from '@actions/remote/entry';
|
||||
import {doPing} from '@actions/remote/general';
|
||||
import {logout} from '@actions/remote/session';
|
||||
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||
@@ -24,6 +23,7 @@ import {useTheme} from '@context/theme';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {subscribeServerUnreadAndMentions, UnreadObserverArgs} from '@database/subscription/unreads';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import WebsocketManager from '@managers/websocket_manager';
|
||||
import {getServerByIdentifier} from '@queries/app/servers';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import {canReceiveNotifications} from '@utils/push_proxy';
|
||||
@@ -296,7 +296,7 @@ const ServerItem = ({
|
||||
await dismissBottomSheet();
|
||||
Navigation.updateProps(Screens.HOME, {extra: undefined});
|
||||
DatabaseManager.setActiveServerDatabase(server.url);
|
||||
await appEntry(server.url, Date.now());
|
||||
WebsocketManager.initializeClient(server.url);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ enableFreeze(true);
|
||||
|
||||
type HomeProps = LaunchProps & {
|
||||
componentId: string;
|
||||
time?: number;
|
||||
};
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
@@ -46,7 +45,7 @@ export default function HomeScreen(props: HomeProps) {
|
||||
const intl = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
const listener = DeviceEventEmitter.addListener(Events.NOTIFICATION_ERROR, (value: 'Team' | 'Channel') => {
|
||||
const listener = DeviceEventEmitter.addListener(Events.NOTIFICATION_ERROR, (value: 'Team' | 'Channel' | 'Post' | 'Connection') => {
|
||||
notificationError(intl, value);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {useIsFocused, useNavigation} from '@react-navigation/native';
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {FlatList, LayoutChangeEvent, Platform, StyleSheet, ViewStyle} from 'react-native';
|
||||
import HWKeyboardEvent from 'react-native-hw-keyboard-event';
|
||||
import Animated, {useAnimatedStyle, useDerivedValue, withTiming} from 'react-native-reanimated';
|
||||
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
@@ -16,12 +17,14 @@ import FreezeScreen from '@components/freeze_screen';
|
||||
import Loading from '@components/loading';
|
||||
import NavigationHeader from '@components/navigation_header';
|
||||
import RoundedHeaderContext from '@components/rounded_header_context';
|
||||
import {Screens} from '@constants';
|
||||
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useKeyboardHeight} from '@hooks/device';
|
||||
import useDidUpdate from '@hooks/did_update';
|
||||
import {useCollapsibleHeader} from '@hooks/header';
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
import {FileFilter, FileFilters, filterFileExtensions} from '@utils/file';
|
||||
import {TabTypes, TabType} from '@utils/search';
|
||||
|
||||
@@ -317,6 +320,19 @@ const SearchScreen = ({teamId, teams}: Props) => {
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = HWKeyboardEvent.onHWKeyPressed((keyEvent: {pressedKey: string}) => {
|
||||
const topScreen = NavigationStore.getVisibleScreen();
|
||||
if (topScreen === Screens.HOME && isFocused && keyEvent.pressedKey === 'enter') {
|
||||
searchRef.current?.blur();
|
||||
onSubmit();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
listener.remove();
|
||||
};
|
||||
}, [onSubmit]);
|
||||
|
||||
return (
|
||||
<FreezeScreen freeze={!isFocused}>
|
||||
<NavigationHeader
|
||||
|
||||
@@ -79,6 +79,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
|
||||
case Screens.CHANNEL:
|
||||
screen = withServerDatabase(require('@screens/channel').default);
|
||||
break;
|
||||
case Screens.CHANNEL_NOTIFICATION_PREFERENCES:
|
||||
screen = withServerDatabase(require('@screens/channel_notification_preferences').default);
|
||||
break;
|
||||
case Screens.CHANNEL_INFO:
|
||||
screen = withServerDatabase(require('@screens/channel_info').default);
|
||||
break;
|
||||
|
||||
@@ -8,14 +8,14 @@ import {Keyboard, TextInput, TouchableOpacity, View} from 'react-native';
|
||||
import Button from 'react-native-button';
|
||||
|
||||
import {login} from '@actions/remote/session';
|
||||
import CompassIcon from '@app/components/compass_icon';
|
||||
import ClientError from '@client/rest/error';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FloatingTextInput from '@components/floating_text_input_label';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import Loading from '@components/loading';
|
||||
import {FORGOT_PASSWORD, MFA} from '@constants/screens';
|
||||
import {t} from '@i18n';
|
||||
import {goToScreen, loginAnimationOptions, resetToHome, resetToTeams} from '@screens/navigation';
|
||||
import {goToScreen, loginAnimationOptions, resetToHome} from '@screens/navigation';
|
||||
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
|
||||
import {isServerError} from '@utils/errors';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
@@ -99,17 +99,13 @@ const LoginForm = ({config, extra, serverDisplayName, launchError, launchType, l
|
||||
const signIn = async () => {
|
||||
const result: LoginActionResponse = await login(serverUrl!, {serverDisplayName, loginId: loginId.toLowerCase(), password, config, license});
|
||||
if (checkLoginResponse(result)) {
|
||||
if (!result.hasTeams && !result.error) {
|
||||
resetToTeams();
|
||||
return;
|
||||
}
|
||||
goToHome(result.time || 0, result.error as never);
|
||||
goToHome(result.error as never);
|
||||
}
|
||||
};
|
||||
|
||||
const goToHome = (time: number, loginError?: never) => {
|
||||
const goToHome = (loginError?: never) => {
|
||||
const hasError = launchError || Boolean(loginError);
|
||||
resetToHome({extra, launchError: hasError, launchType, serverUrl, time});
|
||||
resetToHome({extra, launchError: hasError, launchType, serverUrl});
|
||||
};
|
||||
|
||||
const checkLoginResponse = (data: LoginActionResponse) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import {t} from '@i18n';
|
||||
import Background from '@screens/background';
|
||||
import {popTopScreen, resetToTeams} from '@screens/navigation';
|
||||
import {popTopScreen} from '@screens/navigation';
|
||||
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
@@ -32,7 +32,7 @@ import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
type MFAProps = {
|
||||
componentId: AvailableScreens;
|
||||
config: Partial<ClientConfig>;
|
||||
goToHome: (time: number, error?: never) => void;
|
||||
goToHome: (error?: never) => void;
|
||||
license: Partial<ClientLicense>;
|
||||
loginId: string;
|
||||
password: string;
|
||||
@@ -156,11 +156,7 @@ const MFA = ({componentId, config, goToHome, license, loginId, password, serverD
|
||||
setError(result.error.message);
|
||||
return;
|
||||
}
|
||||
if (!result.hasTeams && !result.error) {
|
||||
resetToTeams();
|
||||
return;
|
||||
}
|
||||
goToHome(result.time || 0, result.error as never);
|
||||
goToHome(result.error as never);
|
||||
}), [token]);
|
||||
|
||||
const transform = useAnimatedStyle(() => {
|
||||
|
||||
@@ -31,12 +31,13 @@ import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type ReactionModel from '@typings/database/models/servers/reaction';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
type EnhancedProps = WithDatabaseArgs & {
|
||||
combinedPost?: Post | PostModel;
|
||||
post: PostModel;
|
||||
showAddReaction: boolean;
|
||||
location: string;
|
||||
sourceScreen: AvailableScreens;
|
||||
serverUrl: string;
|
||||
}
|
||||
|
||||
@@ -75,7 +76,7 @@ const withPost = withObservables([], ({post, database}: {post: Post | PostModel}
|
||||
};
|
||||
});
|
||||
|
||||
const enhanced = withObservables([], ({combinedPost, post, showAddReaction, location, database, serverUrl}: EnhancedProps) => {
|
||||
const enhanced = withObservables([], ({combinedPost, post, showAddReaction, sourceScreen, database, serverUrl}: EnhancedProps) => {
|
||||
const channel = observeChannel(database, post.channelId);
|
||||
const channelIsArchived = channel.pipe(switchMap((ch: ChannelModel) => of$(ch.deleteAt !== 0)));
|
||||
const currentUser = observeCurrentUser(database);
|
||||
@@ -112,7 +113,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, loca
|
||||
);
|
||||
|
||||
const canReply = combineLatest([channelIsArchived, channelIsReadOnly, canPostPermission]).pipe(switchMap(([isArchived, isReadOnly, canPost]) => {
|
||||
return of$(!isArchived && !isReadOnly && location !== Screens.THREAD && !isSystemMessage(post) && canPost);
|
||||
return of$(!isArchived && !isReadOnly && sourceScreen !== Screens.THREAD && !isSystemMessage(post) && canPost);
|
||||
}));
|
||||
|
||||
const canPin = combineLatest([channelIsArchived, channelIsReadOnly]).pipe(switchMap(([isArchived, isReadOnly]) => {
|
||||
|
||||
@@ -9,13 +9,13 @@ import DeviceInfo from 'react-native-device-info';
|
||||
import Config from '@assets/config.json';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import SettingContainer from '@components/settings/container';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import AboutLinks from '@constants/about_links';
|
||||
import {useTheme} from '@context/theme';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import {t} from '@i18n';
|
||||
import {popTopScreen} from '@screens/navigation';
|
||||
import SettingContainer from '@screens/settings/setting_container';
|
||||
import SettingSeparator from '@screens/settings/settings_separator';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
@@ -5,18 +5,17 @@ import React, {useEffect, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert, TouchableOpacity} from 'react-native';
|
||||
|
||||
import SettingContainer from '@components/settings/container';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import {popTopScreen} from '@screens/navigation';
|
||||
import SettingSeparator from '@screens/settings/settings_separator';
|
||||
import {deleteFileCache, getAllFilesInCachesDirectory, getFormattedFileSize} from '@utils/file';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import SettingContainer from '../setting_container';
|
||||
import SettingOption from '../setting_option';
|
||||
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
import type {ReadDirItem} from 'react-native-fs';
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import React, {useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import SettingContainer from '@components/settings/container';
|
||||
import SettingItem from '@components/settings/item';
|
||||
import {Screens} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
@@ -13,9 +15,6 @@ import {gotoSettingsScreen} from '@screens/settings/config';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {getUserTimezoneProps} from '@utils/user';
|
||||
|
||||
import SettingContainer from '../setting_container';
|
||||
import SettingItem from '../setting_item';
|
||||
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
|
||||
@@ -5,17 +5,16 @@ import React, {useCallback, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {savePreference} from '@actions/remote/preference';
|
||||
import SettingBlock from '@components/settings/block';
|
||||
import SettingContainer from '@components/settings/container';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {Preferences} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import useBackNavigation from '@hooks/navigate_back';
|
||||
import {popTopScreen} from '@screens/navigation';
|
||||
|
||||
import SettingBlock from '../setting_block';
|
||||
import SettingContainer from '../setting_container';
|
||||
import SettingOption from '../setting_option';
|
||||
import SettingSeparator from '../settings_separator';
|
||||
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
const CLOCK_TYPE = {
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {savePreference} from '@actions/remote/preference';
|
||||
import {handleCRTToggled, savePreference} from '@actions/remote/preference';
|
||||
import SettingBlock from '@components/settings/block';
|
||||
import SettingContainer from '@components/settings/container';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {Preferences} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import useBackNavigation from '@hooks/navigate_back';
|
||||
import {t} from '@i18n';
|
||||
import {popTopScreen} from '@screens/navigation';
|
||||
|
||||
import SettingBlock from '../setting_block';
|
||||
import SettingContainer from '../setting_container';
|
||||
import SettingOption from '../setting_option';
|
||||
import SettingSeparator from '../settings_separator';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
@@ -37,7 +37,8 @@ const DisplayCRT = ({componentId, currentUserId, isCRTEnabled}: Props) => {
|
||||
|
||||
const close = () => popTopScreen(componentId);
|
||||
|
||||
const saveCRTPreference = useCallback(() => {
|
||||
const saveCRTPreference = useCallback(async () => {
|
||||
close();
|
||||
if (isCRTEnabled !== isEnabled) {
|
||||
const crtPreference: PreferenceType = {
|
||||
category: Preferences.CATEGORIES.DISPLAY_SETTINGS,
|
||||
@@ -45,9 +46,13 @@ const DisplayCRT = ({componentId, currentUserId, isCRTEnabled}: Props) => {
|
||||
user_id: currentUserId,
|
||||
value: isEnabled ? Preferences.COLLAPSED_REPLY_THREADS_ON : Preferences.COLLAPSED_REPLY_THREADS_OFF,
|
||||
};
|
||||
savePreference(serverUrl, [crtPreference]);
|
||||
|
||||
EphemeralStore.setEnablingCRT(true);
|
||||
const {error} = await savePreference(serverUrl, [crtPreference]);
|
||||
if (!error) {
|
||||
handleCRTToggled(serverUrl);
|
||||
}
|
||||
}
|
||||
close();
|
||||
}, [isEnabled, isCRTEnabled, serverUrl]);
|
||||
|
||||
useBackNavigation(saveCRTPreference);
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {useTheme} from '@context/theme';
|
||||
import SettingSeparator from '@screens/settings/settings_separator';
|
||||
|
||||
import SettingOption from '../setting_option';
|
||||
|
||||
const radioItemProps = {checkedBody: true};
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
|
||||
import {savePreference} from '@actions/remote/preference';
|
||||
import SettingContainer from '@components/settings/container';
|
||||
import {Preferences} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import {popTopScreen} from '@screens/navigation';
|
||||
|
||||
import SettingContainer from '../setting_container';
|
||||
|
||||
import CustomTheme from './custom_theme';
|
||||
import {ThemeTiles} from './theme_tiles';
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import React, {useCallback, useMemo, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {updateMe} from '@actions/remote/user';
|
||||
import SettingContainer from '@components/settings/container';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
@@ -14,10 +17,6 @@ import {preventDoubleTap} from '@utils/tap';
|
||||
import {getDeviceTimezone} from '@utils/timezone';
|
||||
import {getTimezoneRegion, getUserTimezoneProps} from '@utils/user';
|
||||
|
||||
import SettingContainer from '../setting_container';
|
||||
import SettingOption from '../setting_option';
|
||||
import SettingSeparator from '../settings_separator';
|
||||
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import React, {useCallback} from 'react';
|
||||
import {TouchableOpacity, View, Text} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {useTheme} from '@context/theme';
|
||||
import SettingSeparator from '@screens/settings/settings_separator';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import {useIntl} from 'react-intl';
|
||||
import {fetchStatusInBatch, updateMe} from '@actions/remote/user';
|
||||
import FloatingTextInput from '@components/floating_text_input_label';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import SettingContainer from '@components/settings/container';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {General} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
@@ -18,10 +21,6 @@ import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme}
|
||||
import {typography} from '@utils/typography';
|
||||
import {getNotificationProps} from '@utils/user';
|
||||
|
||||
import SettingContainer from '../setting_container';
|
||||
import SettingOption from '../setting_option';
|
||||
import SettingSeparator from '../settings_separator';
|
||||
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import {Text} from 'react-native';
|
||||
|
||||
import {savePreference} from '@actions/remote/preference';
|
||||
import {updateMe} from '@actions/remote/user';
|
||||
import SettingBlock from '@components/settings/block';
|
||||
import SettingContainer from '@components/settings/container';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {Preferences} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
@@ -18,11 +22,6 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
import {getEmailInterval, getNotificationProps} from '@utils/user';
|
||||
|
||||
import SettingBlock from '../setting_block';
|
||||
import SettingContainer from '../setting_container';
|
||||
import SettingOption from '../setting_option';
|
||||
import SettingSeparator from '../settings_separator';
|
||||
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import {Text} from 'react-native';
|
||||
|
||||
import {updateMe} from '@actions/remote/user';
|
||||
import FloatingTextInput from '@components/floating_text_input_label';
|
||||
import SettingBlock from '@components/settings/block';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
@@ -18,10 +21,6 @@ import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme}
|
||||
import {typography} from '@utils/typography';
|
||||
import {getNotificationProps} from '@utils/user';
|
||||
|
||||
import SettingBlock from '../setting_block';
|
||||
import SettingOption from '../setting_option';
|
||||
import SettingSeparator from '../settings_separator';
|
||||
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import SettingContainer from '../setting_container';
|
||||
import SettingContainer from '@components/settings/container';
|
||||
|
||||
import MentionSettings from './mention_settings';
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
import React, {Dispatch, SetStateAction} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import SettingBlock from '@components/settings/block';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {t} from '@i18n';
|
||||
|
||||
import SettingBlock from '../setting_block';
|
||||
import SettingOption from '../setting_option';
|
||||
import SettingSeparator from '../settings_separator';
|
||||
|
||||
const replyHeaderText = {
|
||||
id: t('notification_settings.mention.reply'),
|
||||
defaultMessage: 'Send reply notifications for',
|
||||
|
||||
@@ -5,15 +5,14 @@ import React, {useCallback, useMemo, useState} from 'react';
|
||||
import {Platform} from 'react-native';
|
||||
|
||||
import {updateMe} from '@actions/remote/user';
|
||||
import SettingContainer from '@components/settings/container';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import useBackNavigation from '@hooks/navigate_back';
|
||||
import {popTopScreen} from '@screens/navigation';
|
||||
import SettingSeparator from '@screens/settings/settings_separator';
|
||||
import {getNotificationProps} from '@utils/user';
|
||||
|
||||
import SettingContainer from '../setting_container';
|
||||
|
||||
import MobileSendPush from './push_send';
|
||||
import MobilePushStatus from './push_status';
|
||||
import MobilePushThread from './push_thread';
|
||||
|
||||
@@ -5,15 +5,14 @@ import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import SettingBlock from '@components/settings/block';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {t} from '@i18n';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import SettingBlock from '../setting_block';
|
||||
import SettingOption from '../setting_option';
|
||||
import SettingSeparator from '../settings_separator';
|
||||
|
||||
const headerText = {
|
||||
id: t('notification_settings.send_notification.about'),
|
||||
defaultMessage: 'Notify me about...',
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import SettingBlock from '@components/settings/block';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {t} from '@i18n';
|
||||
|
||||
import SettingBlock from '../setting_block';
|
||||
import SettingOption from '../setting_option';
|
||||
import SettingSeparator from '../settings_separator';
|
||||
|
||||
const headerText = {
|
||||
id: t('notification_settings.mobile.trigger_push'),
|
||||
defaultMessage: 'Trigger push notifications when...',
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import SettingBlock from '@components/settings/block';
|
||||
import SettingOption from '@components/settings/option';
|
||||
import SettingSeparator from '@components/settings/separator';
|
||||
import {t} from '@i18n';
|
||||
|
||||
import SettingBlock from '../setting_block';
|
||||
import SettingOption from '../setting_option';
|
||||
import SettingSeparator from '../settings_separator';
|
||||
|
||||
const headerText = {
|
||||
id: t('notification_settings.push_threads.replies'),
|
||||
defaultMessage: 'Thread replies',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user