Merge branch 'gekidou' into MM-35065-add-onboarding-screens

This commit is contained in:
Pablo Velez Vidal 2022-11-15 16:09:19 +01:00
commit 31f0a27a5a
188 changed files with 8453 additions and 12730 deletions

View file

@ -111,7 +111,9 @@ commands:
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}
- run:
name: Getting JavaScript dependencies
command: NODE_ENV=development npm ci --ignore-scripts
command: |
NODE_ENV=development npm ci --ignore-scripts
node node_modules/\@sentry/cli/scripts/install.js
- save_cache:
name: Save npm cache
key: v2-npm-{{ checksum "package.json" }}-{{ arch }}

View file

@ -145,7 +145,7 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 432
versionCode 438
versionName "2.0.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View file

@ -15,7 +15,7 @@ import {
getMyChannel, getChannelById, queryUsersOnChannel, queryUserChannelsByTypes,
} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId} from '@queries/servers/system';
import {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId, getConfig, getLicense} from '@queries/servers/system';
import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, removeChannelFromTeamHistory} from '@queries/servers/team';
import {getCurrentUser, queryUsersById} from '@queries/servers/user';
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
@ -365,9 +365,10 @@ export async function updateChannelsDisplayName(serverUrl: string, channels: Cha
return {};
}
const {config, license} = await getCommonSystemValues(database);
const license = await getLicense(database);
const config = await getConfig(database);
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const displaySettings = getTeammateNameDisplaySetting(preferences, config, license);
const displaySettings = getTeammateNameDisplaySetting(preferences, config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
const models: Model[] = [];
for await (const channel of channels) {
let newDisplayName = '';

View file

@ -6,7 +6,7 @@ import deepEqual from 'deep-equal';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getServerCredentials} from '@init/credentials';
import {getCommonSystemValues} from '@queries/servers/system';
import {getConfig, getLicense} from '@queries/servers/system';
import {logError} from '@utils/log';
export async function storeConfigAndLicense(serverUrl: string, config: ClientConfig, license: ClientLicense) {
@ -14,17 +14,11 @@ export async function storeConfigAndLicense(serverUrl: string, config: ClientCon
// If we have credentials for this server then update the values in the database
const credentials = await getServerCredentials(serverUrl);
if (credentials) {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const current = await getCommonSystemValues(operator.database);
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentLicense = await getLicense(database);
const systems: IdValue[] = [];
if (!deepEqual(config, current.config)) {
systems.push({
id: SYSTEM_IDENTIFIERS.CONFIG,
value: JSON.stringify(config),
});
}
if (!deepEqual(license, current.license)) {
if (!deepEqual(license, currentLicense)) {
systems.push({
id: SYSTEM_IDENTIFIERS.LICENSE,
value: JSON.stringify(license),
@ -34,8 +28,43 @@ export async function storeConfigAndLicense(serverUrl: string, config: ClientCon
if (systems.length) {
await operator.handleSystem({systems, prepareRecordsOnly: false});
}
await storeConfig(serverUrl, config);
}
} catch (error) {
logError('An error occurred while saving config & license', error);
}
}
export async function storeConfig(serverUrl: string, config: ClientConfig | undefined, prepareRecordsOnly = false) {
if (!config) {
return [];
}
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentConfig = await getConfig(database);
const configsToUpdate: IdValue[] = [];
const configsToDelete: IdValue[] = [];
let k: keyof ClientConfig;
for (k in config) {
if (currentConfig?.[k] !== config[k]) {
configsToUpdate.push({
id: k,
value: config[k],
});
}
}
for (k in currentConfig) {
if (config[k] === undefined) {
configsToDelete.push({
id: k,
value: currentConfig[k],
});
}
}
if (configsToDelete.length || configsToUpdate.length) {
return operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly});
}
return [];
}

View file

@ -8,7 +8,7 @@ import DatabaseManager from '@database/manager';
import {getTranslations, t} from '@i18n';
import {getChannelById} from '@queries/servers/channel';
import {getPostById} from '@queries/servers/post';
import {getCommonSystemValues, getCurrentTeamId, getCurrentUserId, prepareCommonSystemValues, PrepareCommonSystemValuesArgs, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {getCurrentTeamId, getCurrentUserId, prepareCommonSystemValues, PrepareCommonSystemValuesArgs, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {addChannelToTeamHistory, addTeamToTeamHistory} from '@queries/servers/team';
import {getIsCRTEnabled, getThreadById, prepareThreadsFromReceivedPosts, queryThreadsInTeam} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
@ -74,12 +74,12 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
throw new Error('Channel not found');
}
const system = await getCommonSystemValues(database);
const currentTeamId = await getCurrentTeamId(database);
const isTabletDevice = await isTablet();
const teamId = channel.teamId || system.currentTeamId;
const teamId = channel.teamId || currentTeamId;
let switchingTeams = false;
if (system.currentTeamId !== teamId) {
if (currentTeamId !== teamId) {
const modelPromises: Array<Promise<Model[]>> = [];
switchingTeams = true;
modelPromises.push(addTeamToTeamHistory(operator, teamId, true));

View file

@ -523,7 +523,7 @@ export async function fetchDirectChannelsInfo(serverUrl: string, directChannels:
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).fetch();
const config = await getConfig(database);
const license = await getLicense(database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences, config, license);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences, config?.LockTeammateNameDisplay, config?.TeammateNameDisplay, license);
const currentUser = await getCurrentUser(database);
const channels = directChannels.map((d) => d.toApi());
return fetchMissingDirectChannelsInfo(serverUrl, channels, currentUser?.locale, teammateDisplayNameSetting, currentUser?.id);
@ -743,8 +743,9 @@ export async function createDirectChannel(serverUrl: string, userId: string, dis
created.display_name = displayName;
} else {
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const system = await getCommonSystemValues(database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, system.license);
const license = await getLicense(database);
const config = await getConfig(database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
const {directChannels, users} = await fetchMissingDirectChannelsInfo(serverUrl, [created], currentUser.locale, teammateDisplayNameSetting, currentUser.id, true);
created.display_name = directChannels?.[0].display_name || created.display_name;
if (users?.length) {
@ -860,19 +861,16 @@ export async function fetchArchivedChannels(serverUrl: string, teamId: string, p
}
export async function createGroupChannel(serverUrl: string, userIds: string[]) {
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};
}
try {
const currentUser = await getCurrentUser(operator.database);
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentUser = await getCurrentUser(database);
if (!currentUser) {
return {error: 'Cannot get the current user'};
}
@ -886,9 +884,10 @@ export async function createGroupChannel(serverUrl: string, userIds: string[]) {
return {data: created};
}
const preferences = await queryPreferencesByCategoryAndName(operator.database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const system = await getCommonSystemValues(operator.database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, system.license);
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const license = await getLicense(database);
const config = await getConfig(database);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
const {directChannels, users} = await fetchMissingDirectChannelsInfo(serverUrl, [created], currentUser.locale, teammateDisplayNameSetting, currentUser.id, true);
const member = {

View file

@ -4,7 +4,7 @@
import {switchToChannelById} from '@actions/remote/channel';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager';
import {prepareCommonSystemValues, getCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, setCurrentTeamAndChannelId, getCurrentChannelId} from '@queries/servers/system';
import {prepareCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, setCurrentTeamAndChannelId, getCurrentChannelId, getConfig, getLicense} from '@queries/servers/system';
import {getCurrentUser} from '@queries/servers/user';
import {deleteV1Data} from '@utils/file';
import {isTablet} from '@utils/helpers';
@ -65,7 +65,8 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false)
logInfo('ENTRY MODELS BATCHING TOOK', `${Date.now() - dt}ms`);
const {id: currentUserId, locale: currentUserLocale} = meData?.user || (await getCurrentUser(database))!;
const {config, license} = await getCommonSystemValues(database);
const config = await getConfig(database);
const license = await getLicense(database);
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchToChannel ? initialChannelId : undefined);
if (!since) {

View file

@ -145,7 +145,7 @@ export const fetchAppEntryData = async (serverUrl: string, sinceArg: number, ini
const confReq = await fetchConfigAndLicense(serverUrl);
const prefData = await fetchMyPreferences(serverUrl, fetchOnly);
const isCRTEnabled = Boolean(prefData.preferences && processIsCRTEnabled(prefData.preferences, confReq.config));
const isCRTEnabled = Boolean(prefData.preferences && processIsCRTEnabled(prefData.preferences, confReq.config?.CollapsedThreads, confReq.config?.FeatureFlagCollapsedThreads));
if (prefData.preferences) {
const crtToggled = await getHasCRTChanged(database, prefData.preferences);
if (crtToggled) {
@ -306,7 +306,7 @@ export async function entryInitialChannelId(database: Database, requestedChannel
export async function restDeferredAppEntryActions(
serverUrl: string, since: number, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined,
config: ClientConfig, license: ClientLicense, teamData: MyTeamsRequest, chData: MyChannelsRequest | 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;
@ -325,7 +325,7 @@ export async function restDeferredAppEntryActions(
fetchTeamsChannelsAndUnreadPosts(serverUrl, since, teamData.teams, teamData.memberships, initialTeamId);
}
if (preferences && processIsCRTEnabled(preferences, config)) {
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads)) {
if (initialTeamId) {
await fetchNewThreads(serverUrl, initialTeamId, false);
}
@ -348,7 +348,7 @@ export async function restDeferredAppEntryActions(
setTimeout(async () => {
if (channelsToFetchProfiles?.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
}
}, FETCH_MISSING_DM_TIMEOUT);
@ -454,7 +454,7 @@ const restSyncAllChannelMembers = async (serverUrl: string) => {
for (const myTeam of myTeams) {
fetchMyChannelsForTeam(serverUrl, myTeam.id, false, 0, false, excludeDirect);
excludeDirect = true;
if (preferences && processIsCRTEnabled(preferences, config)) {
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads)) {
fetchNewThreads(serverUrl, myTeam.id, false);
}
}

View file

@ -52,7 +52,7 @@ export async function deferredAppEntryGraphQLActions(
}
}, FETCH_UNREADS_TIMEOUT);
if (preferences && processIsCRTEnabled(preferences, config)) {
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads)) {
if (initialTeamId) {
await fetchNewThreads(serverUrl, initialTeamId, false);
}
@ -256,7 +256,7 @@ export const entry = async (serverUrl: string, teamId?: string, channelId?: stri
export async function deferredAppEntryActions(
serverUrl: string, since: number, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined,
config: ClientConfig, license: ClientLicense, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined,
config: ClientConfig, license: ClientLicense | undefined, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined,
initialTeamId?: string, initialChannelId?: string) {
let result;
if (config?.FeatureFlagGraphQL === 'true') {

View file

@ -8,7 +8,7 @@ import {getDefaultThemeByAppearance} from '@context/theme';
import DatabaseManager from '@database/manager';
import {getMyChannel} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {getCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {getConfig, getCurrentTeamId, getLicense, getWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
import {getMyTeamById} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
@ -136,7 +136,8 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not
await operator.batchRecords(models);
const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(operator.database))!;
const {config, license} = await getCommonSystemValues(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);

View file

@ -11,7 +11,7 @@ import {fetchMyTeam} from '@actions/remote/team';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import DatabaseManager from '@database/manager';
import {getMyChannel, getChannelById} from '@queries/servers/channel';
import {getCommonSystemValues, getWebSocketLastDisconnected} from '@queries/servers/system';
import {getCurrentTeamId, getWebSocketLastDisconnected} from '@queries/servers/system';
import {getMyTeamById} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import {emitNotificationError} from '@utils/notification';
@ -30,14 +30,14 @@ const fetchNotificationData = async (serverUrl: string, notification: Notificati
}
const {database} = operator;
const system = await getCommonSystemValues(database);
const currentTeamId = await getCurrentTeamId(database);
let teamId = notification.payload?.team_id;
let isDirectChannel = false;
if (!teamId) {
// If the notification payload does not have a teamId we assume is a DM/GM
isDirectChannel = true;
teamId = system.currentTeamId;
teamId = currentTeamId;
}
// To make the switch faster we determine if we already have the team & channel
@ -129,13 +129,13 @@ export const openNotification = async (serverUrl: string, notification: Notifica
const isCRTEnabled = await getIsCRTEnabled(database);
const isThreadNotification = isCRTEnabled && Boolean(rootId);
const system = await getCommonSystemValues(database);
const currentTeamId = await getCurrentTeamId(database);
const currentServerUrl = await DatabaseManager.getActiveServerUrl();
let teamId = notification.payload?.team_id;
if (!teamId) {
// If the notification payload does not have a teamId we assume is a DM/GM
teamId = system.currentTeamId;
teamId = currentTeamId;
}
if (currentServerUrl !== serverUrl) {

View file

@ -54,7 +54,6 @@ export const saveFavoriteChannel = async (serverUrl: string, channelId: string,
}
try {
// Todo: @shaz I think you'll need to add the category handler here so that the channel is added/removed from the favorites category
const userId = await getCurrentUserId(operator.database);
const favPref: PreferenceType = {
category: CATEGORY_FAVORITE_CHANNEL,

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {storeConfig} from '@actions/local/systems';
import {Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreferenceValue, getTeammateNameDisplaySetting} from '@helpers/api/preference';
@ -9,7 +10,7 @@ import NetworkManager from '@managers/network_manager';
import {prepareCategories, prepareCategoryChannels} from '@queries/servers/categories';
import {prepareMyChannelsForTeam} from '@queries/servers/channel';
import {prepareMyPreferences, queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {prepareCommonSystemValues, getCommonSystemValues} from '@queries/servers/system';
import {prepareCommonSystemValues, getConfig, getLicense} from '@queries/servers/system';
import {prepareMyTeams} from '@queries/servers/team';
import {getCurrentUser} from '@queries/servers/user';
import {isDMorGM, selectDefaultChannelForTeam} from '@utils/channel';
@ -101,14 +102,15 @@ export async function retryInitialTeamAndChannel(serverUrl: string) {
const models: Model[] = (await Promise.all([
prepareMyPreferences(operator, prefData.preferences!),
storeConfig(serverUrl, clData.config, true),
...prepareMyTeams(operator, teamData.teams!, teamData.memberships!),
...await prepareMyChannelsForTeam(operator, initialTeam.id, chData!.channels!, chData!.memberships!),
prepareCategories(operator, chData!.categories!),
prepareCategoryChannels(operator, chData!.categories!),
prepareCommonSystemValues(
operator,
{
config: clData.config!,
license: clData.license!,
currentTeamId: initialTeam?.id,
currentChannelId: initialChannel?.id,
@ -121,7 +123,7 @@ export async function retryInitialTeamAndChannel(serverUrl: string) {
const directChannels = chData!.channels!.filter(isDMorGM);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(prefData.preferences || [], clData.config, clData.license);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(prefData.preferences || [], clData.config?.LockTeammateNameDisplay, clData.config?.TeammateNameDisplay, clData.license);
fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), user.locale, teammateDisplayNameSetting, user.id);
}
@ -163,7 +165,8 @@ export async function retryInitialChannel(serverUrl: string, teamId: string) {
user_id: p.userId,
value: p.value,
}));
const {config, license} = await getCommonSystemValues(database);
const license = await getLicense(database);
const config = await getConfig(database);
// fetch channels / channel membership for initial team
const chData = await fetchMyChannelsForTeam(serverUrl, teamId, false, 0, true);
@ -200,7 +203,7 @@ export async function retryInitialChannel(serverUrl: string, teamId: string) {
const directChannels = chData!.channels!.filter(isDMorGM);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config, license);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), user.locale, teammateDisplayNameSetting, user.id);
}

View file

@ -13,7 +13,7 @@ import NetworkManager from '@managers/network_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getDeviceToken} from '@queries/app/global';
import {queryServerName} from '@queries/app/servers';
import {getCurrentUserId, getCommonSystemValues, getExpiredSession} from '@queries/servers/system';
import {getCurrentUserId, getExpiredSession, getConfig, getLicense} from '@queries/servers/system';
import {getCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import {logWarning, logError} from '@utils/log';
@ -35,9 +35,10 @@ export const completeLogin = async (serverUrl: string) => {
}
const {database} = operator;
const {config, license}: { config: Partial<ClientConfig>; license: Partial<ClientLicense> } = await getCommonSystemValues(database);
const license = await getLicense(database);
const config = await getConfig(database);
if (!Object.keys(config)?.length || !Object.keys(license)?.length) {
if (!Object.keys(config)?.length || !license || !Object.keys(license)?.length) {
return null;
}

View file

@ -9,7 +9,7 @@ import PushNotifications from '@init/push_notifications';
import AppsManager from '@managers/apps_manager';
import NetworkManager from '@managers/network_manager';
import {getPostById} from '@queries/servers/post';
import {getCommonSystemValues, getCurrentChannelId, getCurrentTeamId} from '@queries/servers/system';
import {getConfigValue, getCurrentChannelId, getCurrentTeamId} from '@queries/servers/system';
import {getIsCRTEnabled, getNewestThreadInTeam, getThreadById} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
@ -104,9 +104,8 @@ export const fetchThreads = async (
}
try {
const {config} = await getCommonSystemValues(database);
const data = await client.getThreads('me', teamId, before, after, perPage, deleted, unread, since, false, config.Version);
const version = await getConfigValue(database, 'Version');
const data = await client.getThreads('me', teamId, before, after, perPage, deleted, unread, since, false, version);
const {threads} = data;
@ -323,7 +322,7 @@ async function fetchBatchThreads(
return {error: 'currentUser not found'};
}
const {config} = await getCommonSystemValues(operator.database);
const version = await getConfigValue(operator.database, 'Version');
const data: Thread[] = [];
const fetchThreadsFunc = async (opts: FetchThreadsOptions) => {
@ -331,7 +330,7 @@ async function fetchBatchThreads(
const {before, after, perPage = General.CRT_CHUNK_SIZE, deleted, unread, since} = opts;
page += 1;
const {threads} = await client.getThreads(currentUser.id, teamId, before, after, perPage, deleted, unread, since, false, config.Version);
const {threads} = await client.getThreads(currentUser.id, teamId, before, after, perPage, deleted, unread, since, false, version);
if (threads.length) {
// Mark all fetched threads as following
for (const thread of threads) {

View file

@ -2,9 +2,9 @@
// See LICENSE.txt for license information.
import {fetchGroupsForChannel, fetchGroupsForMember, fetchGroupsForTeam} from '@actions/remote/groups';
import {deleteGroupChannelById, deleteGroupMembershipById, deleteGroupTeamById} from '@app/queries/servers/group';
import {generateGroupAssociationId} from '@app/utils/groups';
import DatabaseManager from '@database/manager';
import {deleteGroupChannelById, deleteGroupMembershipById, deleteGroupTeamById} from '@queries/servers/group';
import {generateGroupAssociationId} from '@utils/groups';
import {logError} from '@utils/log';
type WebsocketGroupMessage = WebSocketMessage<{

View file

@ -31,9 +31,9 @@ import AppsManager from '@managers/apps_manager';
import {getActiveServerUrl, queryActiveServer} from '@queries/app/servers';
import {getCurrentChannel} from '@queries/servers/channel';
import {
getCommonSystemValues,
getConfig,
getCurrentUserId,
getLicense,
getWebSocketLastDisconnected,
resetWebSocketLastDisconnected,
setCurrentTeamAndChannelId,
@ -178,13 +178,15 @@ async function doReconnect(serverUrl: string) {
logInfo('WEBSOCKET RECONNECT MODELS BATCHING TOOK', `${Date.now() - dt}ms`);
const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(database))!;
const {config, license} = await getCommonSystemValues(database);
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchedToChannel ? initialChannelId : undefined);
const license = await getLicense(database);
const config = await getConfig(database);
if (isSupportedServerCalls(config?.Version)) {
loadConfigAndCalls(serverUrl, currentUserId);
}
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchedToChannel ? initialChannelId : undefined);
AppsManager.refreshAppBindings(serverUrl);
}

View file

@ -128,10 +128,7 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag
) {
markAsViewed = true;
markAsRead = false;
} else if ((post.channel_id === currentChannelId)) { // TODO: THREADS && !viewingGlobalThreads) {
// Don't mark as read if we're in global threads screen
// the currentChannelId still refers to previously viewed channel
} else if ((post.channel_id === currentChannelId)) {
const isChannelScreenMounted = NavigationStore.getNavigationComponents().includes(Screens.CHANNEL);
const isTabletDevice = await isTablet();

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {updateDmGmDisplayName} from '@actions/local/channel';
import {storeConfig} from '@actions/local/systems';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getConfig, getLicense} from '@queries/servers/system';
@ -35,10 +36,8 @@ export async function handleConfigChangedEvent(serverUrl: string, msg: WebSocket
try {
const config = msg.data.config;
const systems: IdValue[] = [{id: SYSTEM_IDENTIFIERS.CONFIG, value: JSON.stringify(config)}];
const prevConfig = await getConfig(operator.database);
await operator.handleSystem({systems, prepareRecordsOnly: false});
await storeConfig(serverUrl, config);
if (config?.LockTeammateNameDisplay && (prevConfig?.LockTeammateNameDisplay !== config.LockTeammateNameDisplay)) {
updateDmGmDisplayName(serverUrl);
}

View file

@ -12,7 +12,7 @@ import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import WebsocketManager from '@managers/websocket_manager';
import {queryChannelsByTypes, queryUserChannelsByTypes} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {getCommonSystemValues} from '@queries/servers/system';
import {getConfig, getLicense} from '@queries/servers/system';
import {getCurrentUser} from '@queries/servers/user';
import {displayUsername} from '@utils/user';
@ -84,13 +84,14 @@ export async function handleUserTypingEvent(serverUrl: string, msg: WebSocketMes
return;
}
const {config, license} = await getCommonSystemValues(database);
const license = await getLicense(database);
const config = await getConfig(database);
const {users, existingUsers} = await fetchUsersByIds(serverUrl, [msg.data.user_id]);
const user = users?.[0] || existingUsers?.[0];
const namePreference = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(namePreference, config, license);
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(namePreference, config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
const currentUser = await getCurrentUser(database);
const username = displayUsername(user, currentUser?.locale, teammateDisplayNameSetting);
const data = {

View file

@ -6,7 +6,7 @@ import {Platform} from 'react-native';
import {WebsocketEvents} from '@constants';
import DatabaseManager from '@database/manager';
import {getCommonSystemValues} from '@queries/servers/system';
import {getConfig} from '@queries/servers/system';
import {logError, logInfo, logWarning} from '@utils/log';
const MAX_WEBSOCKET_FAILS = 7;
@ -75,8 +75,8 @@ export default class WebSocketClient {
return;
}
const system = await getCommonSystemValues(database);
const connectionUrl = (system.config.WebsocketURL || this.serverUrl) + '/api/v4/websocket';
const config = await getConfig(database);
const connectionUrl = (config.WebsocketURL || this.serverUrl) + '/api/v4/websocket';
if (this.connectingCallback) {
this.connectingCallback();
@ -97,7 +97,7 @@ export default class WebSocketClient {
this.url = connectionUrl;
const reliableWebSockets = system.config.EnableReliableWebSockets === 'true';
const reliableWebSockets = config.EnableReliableWebSockets === 'true';
if (reliableWebSockets) {
// Add connection id, and last_sequence_number to the query param.
// We cannot also send it as part of the auth_challenge, because the session cookie is already sent with the request.

View file

@ -4,9 +4,9 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
import {switchMap} from 'rxjs/operators';
import {observeConfig} from '@queries/servers/system';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeUser} from '@queries/servers/user';
import {getUserCustomStatus, isCustomStatusExpired} from '@utils/user';
@ -21,9 +21,8 @@ type HeaderInputProps = {
const enhanced = withObservables(
['userId'],
({userId, database}: WithDatabaseArgs & HeaderInputProps) => {
const config = observeConfig(database);
const user = observeUser(database, userId);
const isCustomStatusEnabled = config.pipe(map((cfg) => cfg?.EnableCustomUserStatuses === 'true'));
const isCustomStatusEnabled = observeConfigBooleanValue(database, 'EnableCustomUserStatuses');
const customStatus = user.pipe(switchMap((u) => (u?.isBot ? of$(undefined) : of$(getUserCustomStatus(u)))));
const customStatusExpired = user.pipe(switchMap((u) => (u?.isBot ? of$(false) : of$(isCustomStatusExpired(u)))));

View file

@ -25,7 +25,7 @@ type FileProps = {
galleryIdentifier: string;
index: number;
inViewPort: boolean;
isSingleImage: boolean;
isSingleImage?: boolean;
nonVisibleImagesCount: number;
onPress: (index: number) => void;
publicLinkEnabled: boolean;

View file

@ -6,7 +6,7 @@ import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$, from as from$} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
import {observeConfig, observeLicense} from '@queries/servers/system';
import {observeConfigBooleanValue, observeLicense} from '@queries/servers/system';
import {fileExists} from '@utils/file';
import Files from './files';
@ -36,14 +36,8 @@ const filesLocalPathValidation = async (files: FileModel[], authorId: string) =>
};
const enhance = withObservables(['post'], ({database, post}: EnhanceProps) => {
const config = observeConfig(database);
const enableMobileFileDownload = config.pipe(
switchMap((cfg) => of$(cfg?.EnableMobileFileDownload !== 'false')),
);
const publicLinkEnabled = config.pipe(
switchMap((cfg) => of$(cfg?.EnablePublicLink !== 'false')),
);
const enableMobileFileDownload = observeConfigBooleanValue(database, 'EnableMobileFileDownload');
const publicLinkEnabled = observeConfigBooleanValue(database, 'EnablePublicLink');
const complianceDisabled = observeLicense(database).pipe(
switchMap((lcs) => of$(lcs?.IsLicensed === 'false' || lcs?.Compliance === 'false')),

View file

@ -45,6 +45,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
zIndex: 10,
maxWidth: 315,
},
readOnly: {
backgroundColor: changeOpacity(theme.centerChannelBg, 0.16),
},
smallLabel: {
fontSize: 10,
},
@ -191,6 +194,9 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
const combinedTextInputStyle = useMemo(() => {
const res: StyleProp<TextStyle> = [styles.textInput];
if (!editable) {
res.push(styles.readOnly);
}
res.push({
borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH,
height: DEFAULT_INPUT_HEIGHT + ((focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH) * 2),
@ -209,7 +215,7 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
}
return res;
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline]);
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, editable]);
const textAnimatedTextStyle = useAnimatedStyle(() => {
const inputText = placeholder || value;

File diff suppressed because one or more lines are too long

View file

@ -4,19 +4,16 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeConfig} from '@queries/servers/system';
import {observeConfigBooleanValue} from '@queries/servers/system';
import Markdown from './markdown';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const config = observeConfig(database);
const enableLatex = config.pipe(switchMap((c) => of$(c?.EnableLatex === 'true')));
const enableInlineLatex = config.pipe(switchMap((c) => of$(c?.EnableInlineLatex === 'true')));
const enableLatex = observeConfigBooleanValue(database, 'EnableLatex');
const enableInlineLatex = observeConfigBooleanValue(database, 'EnableInlineLatex');
return {
enableLatex,

View file

@ -3,23 +3,16 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeConfig} from '@queries/servers/system';
import {observeConfigValue} from '@queries/servers/system';
import MarkdownLink from './markdown_link';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
const config = observeConfig(database);
const experimentalNormalizeMarkdownLinks = config.pipe(
switchMap((cfg) => of$(cfg?.ExperimentalNormalizeMarkdownLinks)),
);
const siteURL = config.pipe(
switchMap((cfg) => of$(cfg?.SiteURL)),
);
const experimentalNormalizeMarkdownLinks = observeConfigValue(database, 'ExperimentalNormalizeMarkdownLinks');
const siteURL = observeConfigValue(database, 'SiteURL');
return {
experimentalNormalizeMarkdownLinks,

View file

@ -23,11 +23,13 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flexGrow: 1,
paddingHorizontal: 32,
height: '100%',
alignItems: 'center' as const,
justifyContent: 'center' as const,
},
result: {
textAlign: 'center',
color: theme.centerChannelColor,
...typography('Heading', 400, 'SemiBold'),
},

View file

@ -17,8 +17,8 @@ type Props = {
cursorPosition: number;
rootId?: string;
files?: FileInfo[];
maxFileSize: number;
maxFileCount: number;
maxFileSize: number;
canUploadFiles: boolean;
updateCursorPosition: React.Dispatch<React.SetStateAction<number>>;
updatePostInputTop: (top: number) => void;
@ -41,8 +41,8 @@ export default function DraftHandler(props: Props) {
cursorPosition,
rootId = '',
files,
maxFileSize,
maxFileCount,
maxFileSize,
canUploadFiles,
updateCursorPosition,
updatePostInputTop,
@ -106,7 +106,7 @@ export default function DraftHandler(props: Props) {
}
newUploadError(null);
}, [intl, newUploadError, maxFileCount, maxFileSize, serverUrl, files?.length, channelId, rootId]);
}, [intl, newUploadError, maxFileSize, serverUrl, files?.length, channelId, rootId]);
// This effect mainly handles keeping clean the uploadErrorHandlers, and
// reinstantiate them on component mount and file retry.

View file

@ -4,42 +4,23 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {DEFAULT_SERVER_MAX_FILE_SIZE} from '@constants/post_draft';
import {observeConfig, observeLicense} from '@queries/servers/system';
import {isMinimumServerVersion} from '@utils/helpers';
import {observeCanUploadFiles, observeConfigIntValue, observeMaxFileCount} from '@queries/servers/system';
import DraftHandler from './draft_handler';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const config = observeConfig(database);
const license = observeLicense(database);
const canUploadFiles = combineLatest([config, license]).pipe(
switchMap(([c, l]) => of$(
c?.EnableFileAttachments === 'true' ||
(l?.IsLicensed !== 'true' && l?.Compliance !== 'true' && c?.EnableMobileFileUpload === 'true'),
),
),
);
const maxFileSize = config.pipe(
switchMap((cfg) => of$(parseInt(cfg?.MaxFileSize || '0', 10) || DEFAULT_SERVER_MAX_FILE_SIZE)),
);
const maxFileCount = config.pipe(
switchMap((cfg) => of$(isMinimumServerVersion(cfg?.Version || '', 6, 0) ? 10 : 5)),
);
const canUploadFiles = observeCanUploadFiles(database);
const maxFileSize = observeConfigIntValue(database, 'MaxFileSize', DEFAULT_SERVER_MAX_FILE_SIZE);
const maxFileCount = observeMaxFileCount(database);
return {
maxFileSize,
maxFileCount,
canUploadFiles,
maxFileCount,
};
});

View file

@ -8,7 +8,7 @@ import {of as of$} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {observeChannel} from '@queries/servers/channel';
import {observeConfig} from '@queries/servers/system';
import {observeConfigBooleanValue, observeConfigIntValue} from '@queries/servers/system';
import PostInput from './post_input';
@ -20,18 +20,9 @@ type OwnProps = {
}
const enhanced = withObservables(['channelId'], ({database, channelId}: WithDatabaseArgs & OwnProps) => {
const config = observeConfig(database);
const timeBetweenUserTypingUpdatesMilliseconds = config.pipe(
switchMap((cfg) => of$(parseInt(cfg?.TimeBetweenUserTypingUpdatesMilliseconds || '0', 10))),
);
const enableUserTypingMessage = config.pipe(
switchMap((cfg) => of$(cfg?.EnableUserTypingMessages === 'true')),
);
const maxNotificationsPerChannel = config.pipe(
switchMap((cfg) => of$(parseInt(cfg?.MaxNotificationsPerChannel || '0', 10))),
);
const timeBetweenUserTypingUpdatesMilliseconds = observeConfigIntValue(database, 'TimeBetweenUserTypingUpdatesMilliseconds');
const enableUserTypingMessage = observeConfigBooleanValue(database, 'EnableUserTypingMessages');
const maxNotificationsPerChannel = observeConfigIntValue(database, 'MaxNotificationsPerChannel');
const channel = observeChannel(database, channelId);

View file

@ -4,31 +4,16 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {combineLatest, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeConfig, observeLicense} from '@queries/servers/system';
import {isMinimumServerVersion} from '@utils/helpers';
import {observeCanUploadFiles, observeMaxFileCount} from '@queries/servers/system';
import QuickActions from './quick_actions';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const config = observeConfig(database);
const license = observeLicense(database);
const canUploadFiles = combineLatest([config, license]).pipe(
switchMap(([c, l]) => of$(
c?.EnableFileAttachments === 'true' ||
(l?.IsLicensed !== 'true' && l?.Compliance !== 'true' && c?.EnableMobileFileUpload === 'true'),
),
),
);
const maxFileCount = config.pipe(
switchMap((cfg) => of$(isMinimumServerVersion(cfg?.Version || '', 6, 0) ? 10 : 5)),
);
const canUploadFiles = observeCanUploadFiles(database);
const maxFileCount = observeMaxFileCount(database);
return {
canUploadFiles,

View file

@ -11,7 +11,7 @@ import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft';
import {observeChannel, observeCurrentChannel} from '@queries/servers/channel';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {observePermissionForChannel} from '@queries/servers/role';
import {observeConfig, observeCurrentUserId} from '@queries/servers/system';
import {observeConfigBooleanValue, observeConfigIntValue, observeCurrentUserId} from '@queries/servers/system';
import {observeUser} from '@queries/servers/user';
import SendHandler from './send_handler';
@ -42,16 +42,9 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
switchMap((u) => of$(u?.status === General.OUT_OF_OFFICE)),
);
const config = observeConfig(database);
const enableConfirmNotificationsToChannel = config.pipe(
switchMap((cfg) => of$(Boolean(cfg?.EnableConfirmNotificationsToChannel === 'true'))),
);
const isTimezoneEnabled = config.pipe(
switchMap((cfg) => of$(Boolean(cfg?.ExperimentalTimezone === 'true'))),
);
const maxMessageLength = config.pipe(
switchMap((cfg) => of$(parseInt(cfg?.MaxPostSize || '0', 10) || MAX_MESSAGE_LENGTH_FALLBACK)),
);
const enableConfirmNotificationsToChannel = observeConfigBooleanValue(database, 'EnableConfirmNotificationsToChannel');
const isTimezoneEnabled = observeConfigBooleanValue(database, 'ExperimentalTimezone');
const maxMessageLength = observeConfigIntValue(database, 'MaxPostSize', MAX_MESSAGE_LENGTH_FALLBACK);
const useChannelMentions = combineLatest([channel, currentUser]).pipe(
switchMap(([c, u]) => {

View file

@ -2,8 +2,9 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {ActivityIndicator, DeviceEventEmitter, Platform, View, ViewToken} from 'react-native';
import {ActivityIndicator, DeviceEventEmitter, View, ViewToken} from 'react-native';
import Animated, {interpolate, useAnimatedStyle, useSharedValue, withSpring} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {resetMessageCount} from '@actions/local/channel';
import CompassIcon from '@components/compass_icon';
@ -36,7 +37,7 @@ type Props = {
}
const HIDDEN_TOP = -60;
const SHOWN_TOP = Platform.select({ios: 50, default: 5});
const SHOWN_TOP = 5;
const MIN_INPUT = 0;
const MAX_INPUT = 1;
@ -112,6 +113,7 @@ const MoreMessages = ({
}: Props) => {
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
const insets = useSafeAreaInsets();
const pressed = useRef(false);
const resetting = useRef(false);
const initialScroll = useRef(false);
@ -120,9 +122,11 @@ const MoreMessages = ({
const underlayColor = useMemo(() => `hsl(${hexToHue(theme.buttonBg)}, 50%, 38%)`, [theme]);
const top = useSharedValue(0);
const adjustedShownTop = SHOWN_TOP + (currentCallBarVisible ? CURRENT_CALL_BAR_HEIGHT : 0) + (joinCallBannerVisible ? JOIN_CALL_BAR_HEIGHT : 0);
const shownTop = isTablet || (isCRTEnabled && rootId) ? 5 : adjustedShownTop;
const adjustTop = isTablet || (isCRTEnabled && rootId);
const shownTop = adjustTop ? SHOWN_TOP : adjustedShownTop;
const BARS_FACTOR = Math.abs((1) / (HIDDEN_TOP - SHOWN_TOP));
const styles = getStyleSheet(theme);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{
translateY: withSpring(interpolate(
@ -136,13 +140,13 @@ const MoreMessages = ({
[
HIDDEN_TOP,
HIDDEN_TOP,
shownTop,
shownTop,
shownTop + (adjustTop ? 0 : insets.top),
shownTop + (adjustTop ? 0 : insets.top),
],
Animated.Extrapolate.CLAMP,
), {damping: 15}),
}],
}), [isTablet, shownTop]);
}), [shownTop, insets.top, adjustTop]);
// Due to the implementation differences "unreadCount" gets updated for a channel on reset but not for a thread.
// So we maintain a localUnreadCount to hide the indicator when the count is reset.

View file

@ -8,7 +8,7 @@ import {of as of$, combineLatest} from 'rxjs';
import {Preferences} from '@constants';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeConfig} from '@queries/servers/system';
import {observeConfigBooleanValue} from '@queries/servers/system';
import Opengraph from './opengraph';
@ -21,12 +21,12 @@ const enhance = withObservables(
return {showLinkPreviews: of$(false)};
}
const config = observeConfig(database);
const linkPreviewsConfig = observeConfigBooleanValue(database, 'EnableLinkPreviews');
const linkPreviewPreference = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.LINK_PREVIEW_DISPLAY).
observeWithColumns(['value']);
const showLinkPreviews = combineLatest([config, linkPreviewPreference], (cfg, pref) => {
const showLinkPreviews = combineLatest([linkPreviewsConfig, linkPreviewPreference], (cfg, pref) => {
const previewsEnabled = getPreferenceAsBool(pref, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.LINK_PREVIEW_DISPLAY, true);
return of$(previewsEnabled && cfg?.EnableLinkPreviews === 'true');
return of$(previewsEnabled && cfg);
});
return {showLinkPreviews};

View file

@ -157,9 +157,10 @@ const ProgressiveImage = ({
opacity={defaultOpacity}
source={{uri: thumbnailUri}}
style={[
thumbnailUri ? StyleSheet.absoluteFill : {tintColor: theme.centerChannelColor},
thumbnailUri ? StyleSheet.absoluteFill : undefined,
imageStyle,
]}
tintColor={thumbnailUri ? undefined : theme.centerChannelColor}
/>
{image}
</Animated.View>

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, StyleSheet} from 'react-native';
import {ColorValue, StyleProp} from 'react-native';
import FastImage, {ImageStyle, Source} from 'react-native-fast-image';
import Animated, {SharedValue} from 'react-native-reanimated';
@ -14,9 +14,10 @@ type ThumbnailProps = {
opacity?: SharedValue<number>;
source?: Source;
style: StyleProp<ImageStyle>;
tintColor?: ColorValue;
}
const Thumbnail = ({onError, opacity, style, source}: ThumbnailProps) => {
const Thumbnail = ({onError, opacity, style, source, tintColor}: ThumbnailProps) => {
if (source?.uri) {
return (
<AnimatedFastImage
@ -29,8 +30,6 @@ const Thumbnail = ({onError, opacity, style, source}: ThumbnailProps) => {
);
}
const tintColor = StyleSheet.flatten(style).tintColor;
return (
<AnimatedFastImage
resizeMode='contain'

View file

@ -14,7 +14,7 @@ type Props = {
enableEmoji?: boolean;
enableHardBreak?: boolean;
enableSoftBreak?: boolean;
textStyle: StyleProp<TextStyle>;
textStyle?: StyleProp<TextStyle>;
value: string;
};

View file

@ -58,7 +58,7 @@ function RadioSetting({
isSelected={value === entryValue}
text={text}
value={entryValue}
key={value}
key={entryValue}
/>,
);
}

View file

@ -12,7 +12,7 @@ import FormattedTime from '@components/formatted_time';
import {Preferences} from '@constants';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeConfig} from '@queries/servers/system';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeCurrentUser} from '@queries/servers/user';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -80,10 +80,9 @@ const SystemHeader = ({isMilitaryTime, isTimezoneEnabled, createAt, theme, user}
};
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const config = observeConfig(database);
const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time').
observeWithColumns(['value']);
const isTimezoneEnabled = config.pipe(map((cfg) => cfg?.ExperimentalTimezone === 'true'));
const isTimezoneEnabled = observeConfigBooleanValue(database, 'ExperimentalTimezone');
const isMilitaryTime = preferences.pipe(
map((prefs) => getPreferenceAsBool(prefs, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)),
);

View file

@ -5,9 +5,9 @@ import React, {useMemo} from 'react';
import {StyleProp, Text, TextStyle, useWindowDimensions, View, ViewStyle} from 'react-native';
import Animated, {AnimatedStyleProp} from 'react-native-reanimated';
import {useIsTablet} from '@app/hooks/device';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';

View file

@ -3,23 +3,16 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeConfig, observeCurrentUserId} from '@queries/servers/system';
import {observeConfigBooleanValue, observeCurrentUserId} from '@queries/servers/system';
import UserItem from './user_item';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const config = observeConfig(database);
const isCustomStatusEnabled = config.pipe(
switchMap((cfg) => of$(cfg?.EnableCustomUserStatuses === 'true')),
);
const showFullName = config.pipe(
switchMap((cfg) => of$(cfg?.ShowFullName === 'true')),
);
const isCustomStatusEnabled = observeConfigBooleanValue(database, 'EnableCustomUserStatuses');
const showFullName = observeConfigBooleanValue(database, 'ShowFullName');
const currentUserId = observeCurrentUserId(database);
return {
isCustomStatusEnabled,

View file

@ -15,6 +15,7 @@ export const MM_TABLES = {
CHANNEL: 'Channel',
CHANNEL_INFO: 'ChannelInfo',
CHANNEL_MEMBERSHIP: 'ChannelMembership',
CONFIG: 'Config',
CUSTOM_EMOJI: 'CustomEmoji',
DRAFT: 'Draft',
FILE: 'File',

View file

@ -31,5 +31,4 @@ export default keyMirror({
SEND_TO_POST_DRAFT: null,
CRT_TOGGLED: null,
JOIN_CALL_BAR_VISIBLE: null,
CURRENT_CALL_BAR_VISIBLE: null,
});

View file

@ -23,6 +23,7 @@ export const SEARCH_INPUT_MARGIN = 5;
export const JOIN_CALL_BAR_HEIGHT = 38;
export const CURRENT_CALL_BAR_HEIGHT = 74;
export const CALL_ERROR_BAR_HEIGHT = 62;
export const QUICK_OPTIONS_HEIGHT = 270;

View file

@ -11,7 +11,7 @@ import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database';
import AppDatabaseMigrations from '@database/migration/app';
import ServerDatabaseMigrations from '@database/migration/server';
import {InfoModel, GlobalModel, ServersModel} from '@database/models/app';
import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel,
import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel,
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
@ -47,7 +47,7 @@ class DatabaseManager {
constructor() {
this.appModels = [InfoModel, GlobalModel, ServersModel];
this.serverModels = [
CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel,
CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel,
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,

View file

@ -8,7 +8,7 @@ import {DeviceEventEmitter, Platform} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import FileSystem from 'react-native-fs';
import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database';
import {DatabaseType, MIGRATION_EVENTS, MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import AppDatabaseMigrations from '@database/migration/app';
import ServerDatabaseMigrations from '@database/migration/server';
import {InfoModel, GlobalModel, ServersModel} from '@database/models/app';
@ -16,13 +16,14 @@ import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, Cha
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, UserModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, UserModel, ConfigModel,
} from '@database/models/server';
import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
import {schema as appSchema} from '@database/schema/app';
import {serverSchema} from '@database/schema/server';
import {queryActiveServer, queryServer, queryServerByIdentifier} from '@queries/app/servers';
import {querySystemValue} from '@queries/servers/system';
import {deleteLegacyFileCache} from '@utils/file';
import {emptyFunction} from '@utils/general';
import {logDebug, logError} from '@utils/log';
@ -45,7 +46,7 @@ class DatabaseManager {
constructor() {
this.appModels = [InfoModel, GlobalModel, ServersModel];
this.serverModels = [
CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel,
CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel,
GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel,
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
@ -109,7 +110,7 @@ class DatabaseManager {
return this.appDatabase;
} catch (e) {
// TODO : report to sentry? Show something on the UI ?
logError('Unable to create the App Database!!', e);
}
return undefined;
@ -163,8 +164,6 @@ class DatabaseManager {
return serverDatabase;
} catch (e) {
// TODO : report to sentry? Show something on the UI ?
logError('Error initializing database', e);
}
}
@ -178,13 +177,38 @@ class DatabaseManager {
* @returns {Promise<void>}
*/
private initServerDatabase = async (serverUrl: string): Promise<void> => {
await this.createServerDatabase({
const serverDatabase = await this.createServerDatabase({
config: {
dbName: serverUrl,
dbType: DatabaseType.SERVER,
serverUrl,
},
});
// Migration for config
if (serverDatabase) {
const {database, operator} = serverDatabase;
const oldConfigList = await querySystemValue(database, SYSTEM_IDENTIFIERS.CONFIG).fetch();
if (oldConfigList.length) {
const oldConfigModel = oldConfigList[0];
const oldConfig = oldConfigModel.value as ClientConfig;
const configs = [];
let k: keyof ClientConfig;
for (k in oldConfig) {
// Check to silence eslint (guard-for-in)
if (Object.prototype.hasOwnProperty.call(oldConfig, k)) {
configs.push({
id: k,
value: oldConfig[k],
});
}
}
const models = await operator.handleConfigs({configs, configsToDelete: [], prepareRecordsOnly: true});
operator.batchRecords([...models, oldConfigModel.prepareDestroyPermanently()]);
}
}
};
/**
@ -223,7 +247,7 @@ class DatabaseManager {
}
}
} catch (e) {
// TODO : report to sentry? Show something on the UI ?
logError('Error adding server to App database', e);
}
};

View file

@ -4,17 +4,36 @@
// NOTE : To implement migration, please follow this document
// https://nozbe.github.io/WatermelonDB/Advanced/Migrations.html
import {schemaMigrations, addColumns} from '@nozbe/watermelondb/Schema/migrations';
import {schemaMigrations, addColumns, createTable} from '@nozbe/watermelondb/Schema/migrations';
import {MM_TABLES} from '@constants/database';
import {tableSchemaSpec as configSpec} from '@database/schema/server/table_schemas/config';
const {SERVER: {
GROUP,
MY_CHANNEL,
TEAM,
THREAD,
}} = MM_TABLES;
export default schemaMigrations({migrations: [
{
toVersion: 5,
steps: [
createTable(configSpec),
],
},
{
toVersion: 4,
steps: [
addColumns({
table: TEAM,
columns: [
{name: 'invite_id', type: 'string'},
],
}),
],
},
{
toVersion: 3,
steps: [

View file

@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Model} from '@nozbe/watermelondb';
import {field} from '@nozbe/watermelondb/decorators';
import {MM_TABLES} from '@constants/database';
import type ConfigModelInterface from '@typings/database/models/servers/config';
const {CONFIG} = MM_TABLES.SERVER;
/**
* The Config model is another set of key-value pair combination but this one
* will hold the server configuration.
*/
export default class ConfigModel extends Model implements ConfigModelInterface {
/** table (name) : Config */
static table = CONFIG;
/** value : The value for that config/information and whose key will be the id column */
@field('value') value!: string;
}

View file

@ -6,6 +6,7 @@ export {default as CategoryChannelModel} from './category_channel';
export {default as ChannelInfoModel} from './channel_info';
export {default as ChannelMembershipModel} from './channel_membership';
export {default as ChannelModel} from './channel';
export {default as ConfigModel} from './config';
export {default as CustomEmojiModel} from './custom_emoji';
export {default as DraftModel} from './draft';
export {default as FileModel} from './file';

View file

@ -87,6 +87,9 @@ export default class TeamModel extends Model implements TeamModelInterface {
/** allowed_domains : List of domains that can join this team */
@field('allowed_domains') allowedDomains!: string;
/** invite_id : The token id to use in invites to the team */
@field('invite_id') inviteId!: string;
/** categories : All the categories associated with this team */
@children(CATEGORY) categories!: Query<CategoryModel>;

View file

@ -3,6 +3,7 @@
import DatabaseManager from '@database/manager';
import {
transformConfigRecord,
transformCustomEmojiRecord,
transformRoleRecord,
transformSystemRecord,
@ -95,6 +96,30 @@ describe('*** DataOperator: Base Handlers tests ***', () => {
});
});
it('=> HandleConfig: should write to the CONFIG table', async () => {
expect.assertions(1);
const spyOnHandleRecords = jest.spyOn(operator, 'handleRecords');
const configs = [{id: 'config-1', value: 'config-1'}];
const configsToDelete = [{id: 'toDelete', value: 'toDelete'}];
await operator.handleConfigs({
configs,
configsToDelete,
prepareRecordsOnly: false,
});
expect(spyOnHandleRecords).toHaveBeenCalledWith({
fieldName: 'id',
transformer: transformConfigRecord,
createOrUpdateRawValues: configs,
tableName: 'Config',
prepareRecordsOnly: false,
deleteRawValues: configsToDelete,
});
});
it('=> No table name: should not call execute if tableName is invalid', async () => {
expect.assertions(3);

View file

@ -4,6 +4,7 @@
import {MM_TABLES} from '@constants/database';
import BaseDataOperator from '@database/operator/base_data_operator';
import {
transformConfigRecord,
transformCustomEmojiRecord,
transformRoleRecord,
transformSystemRecord,
@ -12,12 +13,12 @@ import {getUniqueRawsBy} from '@database/operator/utils/general';
import {logWarning} from '@utils/log';
import type {Model} from '@nozbe/watermelondb';
import type {HandleCustomEmojiArgs, HandleRoleArgs, HandleSystemArgs, OperationArgs} from '@typings/database/database';
import type {HandleConfigArgs, HandleCustomEmojiArgs, HandleRoleArgs, HandleSystemArgs, OperationArgs} from '@typings/database/database';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type RoleModel from '@typings/database/models/servers/role';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {CUSTOM_EMOJI, ROLE, SYSTEM}} = MM_TABLES;
const {SERVER: {CONFIG, CUSTOM_EMOJI, ROLE, SYSTEM}} = MM_TABLES;
export default class ServerDataOperatorBase extends BaseDataOperator {
handleRole = async ({roles, prepareRecordsOnly = true}: HandleRoleArgs) => {
@ -71,6 +72,24 @@ export default class ServerDataOperatorBase extends BaseDataOperator {
}) as Promise<SystemModel[]>;
};
handleConfigs = async ({configs, configsToDelete, prepareRecordsOnly = true}: HandleConfigArgs) => {
if (!configs?.length && !configsToDelete?.length) {
logWarning(
'An empty or undefined "configs" and "configsToDelete" arrays has been passed to the handleConfigs',
);
return [];
}
return this.handleRecords({
fieldName: 'id',
transformer: transformConfigRecord,
prepareRecordsOnly,
createOrUpdateRawValues: getUniqueRawsBy({raws: configs, key: 'id'}),
tableName: CONFIG,
deleteRawValues: configsToDelete,
});
};
/**
* execute: Handles the Create/Update operations on an table.
* @param {OperationArgs} execute

View file

@ -5,6 +5,7 @@ import {MM_TABLES, OperationType} from '@constants/database';
import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index';
import type {TransformerArgs} from '@typings/database/database';
import type ConfigModel from '@typings/database/models/servers/config';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type RoleModel from '@typings/database/models/servers/role';
import type SystemModel from '@typings/database/models/servers/system';
@ -13,6 +14,7 @@ const {
CUSTOM_EMOJI,
ROLE,
SYSTEM,
CONFIG,
} = MM_TABLES.SERVER;
/**
@ -94,3 +96,28 @@ export const transformSystemRecord = ({action, database, value}: TransformerArgs
fieldsMapper,
}) as Promise<SystemModel>;
};
/**
* transformConfigRecord: Prepares a record of the SERVER database 'Config' table for update or create actions.
* @param {TransformerArgs} operator
* @param {Database} operator.database
* @param {RecordPair} operator.value
* @returns {Promise<ConfigModel>}
*/
export const transformConfigRecord = ({action, database, value}: TransformerArgs): Promise<ConfigModel> => {
const raw = value.raw as IdValue;
// If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database
const fieldsMapper = (config: ConfigModel) => {
config._raw.id = raw?.id;
config.value = raw?.value as string;
};
return prepareBaseRecord({
action,
database,
tableName: CONFIG,
value,
fieldsMapper,
}) as Promise<ConfigModel>;
};

View file

@ -72,6 +72,7 @@ export const transformTeamRecord = ({action, database, value}: TransformerArgs):
team.allowedDomains = raw.allowed_domains;
team.isGroupConstrained = Boolean(raw.group_constrained);
team.lastTeamIconUpdatedAt = raw.last_team_icon_update;
team.inviteId = raw.invite_id;
};
return prepareBaseRecord({

View file

@ -9,6 +9,7 @@ import {
ChannelInfoSchema,
ChannelMembershipSchema,
ChannelSchema,
ConfigSchema,
CustomEmojiSchema,
DraftSchema,
FileSchema,
@ -37,13 +38,14 @@ import {
} from './table_schemas';
export const serverSchema: AppSchema = appSchema({
version: 3,
version: 5,
tables: [
CategorySchema,
CategoryChannelSchema,
ChannelInfoSchema,
ChannelMembershipSchema,
ChannelSchema,
ConfigSchema,
CustomEmojiSchema,
DraftSchema,
FileSchema,

View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {tableSchema} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type {TableSchemaSpec} from '@nozbe/watermelondb/Schema';
const {CONFIG} = MM_TABLES.SERVER;
export const tableSchemaSpec: TableSchemaSpec = {
name: CONFIG,
columns: [
{name: 'value', type: 'string'},
],
};
export default tableSchema(tableSchemaSpec);

View file

@ -31,3 +31,4 @@ export {default as ThreadSchema} from './thread';
export {default as ThreadParticipantSchema} from './thread_participant';
export {default as ThreadInTeamSchema} from './thread_in_team';
export {default as UserSchema} from './user';
export {default as ConfigSchema} from './config';

View file

@ -19,5 +19,6 @@ export default tableSchema({
{name: 'name', type: 'string'},
{name: 'type', type: 'string'},
{name: 'update_at', type: 'number'},
{name: 'invite_id', type: 'string'},
],
});

View file

@ -13,6 +13,7 @@ const {
CHANNEL,
CHANNEL_INFO,
CHANNEL_MEMBERSHIP,
CONFIG,
CUSTOM_EMOJI,
DRAFT,
FILE,
@ -43,7 +44,8 @@ const {
describe('*** Test schema for SERVER database ***', () => {
it('=> The SERVER SCHEMA should strictly match', () => {
expect(serverSchema).toEqual({
version: 3,
version: 5,
unsafeSql: undefined,
tables: {
[CATEGORY]: {
name: CATEGORY,
@ -145,6 +147,16 @@ describe('*** Test schema for SERVER database ***', () => {
{name: 'scheme_admin', type: 'boolean'},
],
},
[CONFIG]: {
name: CONFIG,
unsafeSql: undefined,
columns: {
value: {name: 'value', type: 'string'},
},
columnArray: [
{name: 'value', type: 'string'},
],
},
[CUSTOM_EMOJI]: {
name: CUSTOM_EMOJI,
unsafeSql: undefined,
@ -463,6 +475,7 @@ describe('*** Test schema for SERVER database ***', () => {
name: {name: 'name', type: 'string'},
type: {name: 'type', type: 'string'},
update_at: {name: 'update_at', type: 'number'},
invite_id: {name: 'invite_id', type: 'string'},
},
columnArray: [
{name: 'allowed_domains', type: 'string'},
@ -474,6 +487,7 @@ describe('*** Test schema for SERVER database ***', () => {
{name: 'name', type: 'string'},
{name: 'type', type: 'string'},
{name: 'update_at', type: 'number'},
{name: 'invite_id', type: 'string'},
],
},
[TEAM_CHANNEL_HISTORY]: {

View file

@ -29,13 +29,13 @@ export function getPreferenceAsInt(preferences: PreferenceType[] | PreferenceMod
return defaultValue;
}
export function getTeammateNameDisplaySetting(preferences: PreferenceType[] | PreferenceModel[], config?: ClientConfig, license?: ClientLicense) {
const useAdminTeammateNameDisplaySetting = license?.LockTeammateNameDisplay === 'true' && config?.LockTeammateNameDisplay === 'true';
export function getTeammateNameDisplaySetting(preferences: PreferenceType[] | PreferenceModel[], lockTeammateNameDisplay?: string, teammateNameDisplay?: string, license?: ClientLicense) {
const useAdminTeammateNameDisplaySetting = license?.LockTeammateNameDisplay === 'true' && lockTeammateNameDisplay === 'true';
const preference = getPreferenceValue(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT, '') as string;
if (preference && !useAdminTeammateNameDisplaySetting) {
return preference;
} else if (config?.TeammateNameDisplay) {
return config.TeammateNameDisplay;
} else if (teammateNameDisplay) {
return teammateNameDisplay;
}
return General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME;

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import Emm from '@mattermost/react-native-emm';
import deepEqual from 'deep-equal';
import JailMonkey from 'jail-monkey';
import {Alert, AlertButton, AppState, AppStateStatus, Platform} from 'react-native';
@ -18,9 +19,15 @@ class ManagedApp {
previousAppState?: AppStateStatus;
processConfigTimeout?: NodeJS.Timeout;
vendor = 'Mattermost';
cacheConfig?: ManagedConfig = undefined;
constructor() {
Emm.addListener(this.processConfig);
Emm.addListener((cfg: ManagedConfig) => {
if (!deepEqual(cfg, this.cacheConfig)) {
this.processConfig(cfg);
this.cacheConfig = cfg;
}
});
this.setIOSAppGroupIdentifier();
@ -28,7 +35,8 @@ class ManagedApp {
}
init() {
this.processConfig(Emm.getManagedConfig<ManagedConfig>());
this.cacheConfig = Emm.getManagedConfig<ManagedConfig>();
this.processConfig(this.cacheConfig);
}
setIOSAppGroupIdentifier = () => {
@ -72,9 +80,9 @@ class ManagedApp {
return;
}
const inAppPinCode = config!.inAppPinCode === 'true';
if (inAppPinCode) {
this.handleDeviceAuthentication();
this.inAppPinCode = config!.inAppPinCode === 'true';
if (this.inAppPinCode && !this.performingAuthentication) {
await this.handleDeviceAuthentication();
}
};
@ -111,11 +119,14 @@ class ManagedApp {
if (authExpired) {
try {
await Emm.authenticate({
const auth = await Emm.authenticate({
reason: translations[t('mobile.managed.secured_by')].replace('{vendor}', this.vendor),
fallback: true,
supressEnterPassword: true,
});
if (!auth) {
throw new Error('Authorization cancelled');
}
} catch (err) {
Emm.exitApp();
return;
@ -133,7 +144,7 @@ class ManagedApp {
const isActive = appState === 'active';
const isBackground = appState === 'background';
if (isActive && this.previousAppState === 'background') {
if (isActive && this.previousAppState === 'background' && !this.performingAuthentication) {
if (this.enabled && this.inAppPinCode) {
const authExpired = this.backgroundSince > 0 && (Date.now() - this.backgroundSince) >= PROMPT_IN_APP_PIN_CODE_AFTER;
await this.handleDeviceAuthentication(authExpired);

View file

@ -96,6 +96,7 @@ class NetworkManager {
private buildConfig = async () => {
const userAgent = UserAgent.getUserAgent();
const managedConfig = ManagedApp.enabled ? Emm.getManagedConfig<ManagedConfig>() : undefined;
const headers: Record<string, string> = {
...this.DEFAULT_CONFIG.headers,
[ClientConstants.HEADER_USER_AGENT]: userAgent,
@ -103,20 +104,15 @@ class NetworkManager {
const config = {
...this.DEFAULT_CONFIG,
sessionConfiguration: {
...this.DEFAULT_CONFIG.sessionConfiguration,
timeoutIntervalForRequest: managedConfig?.timeout ? parseInt(managedConfig.timeout, 10) : this.DEFAULT_CONFIG.sessionConfiguration.timeoutIntervalForRequest,
timeoutIntervalForResource: managedConfig?.timeoutVPN ? parseInt(managedConfig.timeoutVPN, 10) : this.DEFAULT_CONFIG.sessionConfiguration.timeoutIntervalForResource,
waitsForConnectivity: managedConfig?.useVPN === 'true',
},
headers,
};
if (ManagedApp.enabled) {
const managedConfig = Emm.getManagedConfig<ManagedConfig>();
if (managedConfig?.useVPN === 'true') {
config.sessionConfiguration.waitsForConnectivity = true;
}
if (managedConfig?.timeoutVPN) {
config.sessionConfiguration.timeoutIntervalForResource = parseInt(managedConfig.timeoutVPN, 10);
}
}
return config;
};

View file

@ -6,10 +6,9 @@ import {AppState, AppStateStatus, DeviceEventEmitter, Platform} from 'react-nati
import FastImage from 'react-native-fast-image';
import {cancelSessionNotification, logout, scheduleSessionNotification} from '@actions/remote/session';
import {resetMomentLocale} from '@app/i18n';
import {deleteFileCache, deleteFileCacheByDir} from '@app/utils/file';
import {Events, Launch} from '@constants';
import DatabaseManager from '@database/manager';
import {resetMomentLocale} from '@i18n';
import {getAllServerCredentials, removeServerCredentials} from '@init/credentials';
import {relaunchApp} from '@init/launch';
import PushNotifications from '@init/push_notifications';
@ -20,6 +19,7 @@ import {queryServerName} from '@queries/app/servers';
import {getCurrentUser} from '@queries/servers/user';
import {getThemeFromState, resetToOnboarding} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {deleteFileCache, deleteFileCacheByDir} from '@utils/file';
import {addNewServer} from '@utils/server';
import type {LaunchType} from '@typings/launch';

View file

@ -11,6 +11,8 @@ import {getConnectionForTesting} from '@calls/actions/calls';
import * as Permissions from '@calls/actions/permissions';
import * as State from '@calls/state';
import {
myselfLeftCall,
newCurrentCall,
setCallsConfig,
setCallsState,
setChannelsWithCalls,
@ -139,7 +141,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id');
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true);
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
userJoinedCall('server1', 'channel-id', 'myUserId');
});
@ -163,7 +168,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id');
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true);
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
userJoinedCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');
@ -174,6 +182,9 @@ describe('Actions.Calls', () => {
await act(async () => {
CallsActions.leaveCall();
// because disconnect is mocked
myselfLeftCall();
});
expect(disconnectMock).toBeCalled();
@ -191,7 +202,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id');
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true);
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
userJoinedCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');
@ -218,7 +232,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id');
response = await CallsActions.joinCall('server1', 'channel-id', 'mysUserId', true);
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
userJoinedCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');

View file

@ -8,7 +8,6 @@ import {fetchUsersByIds} from '@actions/remote/user';
import {
getCallsConfig,
getCallsState,
myselfLeftCall,
setCalls,
setChannelEnabled,
setConfig,
@ -16,6 +15,8 @@ import {
setScreenShareURL,
setSpeakerPhone,
setCallForChannel,
newCurrentCall,
myselfLeftCall,
} from '@calls/state';
import {General, Preferences} from '@constants';
import Calls from '@constants/calls';
@ -24,7 +25,7 @@ import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import NetworkManager from '@managers/network_manager';
import {getChannelById} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {getCommonSystemValues} from '@queries/servers/system';
import {getConfig, getLicense} from '@queries/servers/system';
import {getCurrentUser, getUserById} from '@queries/servers/user';
import {displayUsername, getUserIdFromChannelName, isSystemAdmin} from '@utils/user';
@ -218,7 +219,7 @@ export const enableChannelCalls = async (serverUrl: string, channelId: string, e
return {};
};
export const joinCall = async (serverUrl: string, channelId: string): Promise<{ error?: string | Error; data?: string }> => {
export const joinCall = async (serverUrl: string, channelId: string, userId: string, hasMicPermission: boolean): Promise<{ error?: string | Error; data?: string }> => {
// Edge case: calls was disabled when app loaded, and then enabled, but app hasn't
// reconnected its websocket since then (i.e., hasn't called batchLoadCalls yet)
const {data: enabled} = await checkIsCallsPluginEnabled(serverUrl);
@ -231,9 +232,12 @@ export const joinCall = async (serverUrl: string, channelId: string): Promise<{
connection = null;
}
setSpeakerphoneOn(false);
newCurrentCall(serverUrl, channelId, userId);
try {
connection = await newConnection(serverUrl, channelId, () => null, setScreenShareURL);
connection = await newConnection(serverUrl, channelId, () => {
myselfLeftCall();
}, setScreenShareURL, hasMicPermission);
} catch (error: unknown) {
await forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error: error as Error};
@ -255,7 +259,6 @@ export const leaveCall = () => {
connection = null;
}
setSpeakerphoneOn(false);
myselfLeftCall();
};
export const muteMyself = () => {
@ -270,6 +273,12 @@ export const unmuteMyself = () => {
}
};
export const initializeVoiceTrack = () => {
if (connection) {
connection.initializeVoiceTrack();
}
};
export const raiseHand = () => {
if (connection) {
connection.raiseHand();
@ -337,9 +346,10 @@ export const getEndCallMessage = async (serverUrl: string, channelId: string, cu
if (channel.type === General.DM_CHANNEL) {
const otherID = getUserIdFromChannelName(currentUserId, channel.name);
const otherUser = await getUserById(database, otherID);
const {config, license} = await getCommonSystemValues(database);
const license = await getLicense(database);
const config = await getConfig(database);
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const displaySetting = getTeammateNameDisplaySetting(preferences, config, license);
const displaySetting = getTeammateNameDisplaySetting(preferences, config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
msg = intl.formatMessage({
id: 'mobile.calls_end_msg_dm',
defaultMessage: 'Are you sure you want to end the call with {displayName}?',

View file

@ -1,28 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Alert, Platform} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import {Platform} from 'react-native';
import Permissions from 'react-native-permissions';
import type {IntlShape} from 'react-intl';
const getMicrophonePermissionDeniedMessage = (intl: IntlShape) => {
const {formatMessage} = intl;
const applicationName = DeviceInfo.getApplicationName();
return {
title: formatMessage({
id: 'mobile.microphone_permission_denied_title',
defaultMessage: '{applicationName} would like to access your microphone',
}, {applicationName}),
text: formatMessage({
id: 'mobile.microphone_permission_denied_description',
defaultMessage: 'To participate in this call, open Settings to grant Mattermost access to your microphone.',
}),
};
};
export const hasMicrophonePermission = async (intl: IntlShape) => {
export const hasMicrophonePermission = async () => {
const targetSource = Platform.select({
ios: Permissions.PERMISSIONS.IOS.MICROPHONE,
default: Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO,
@ -36,32 +18,8 @@ export const hasMicrophonePermission = async (intl: IntlShape) => {
return permissionRequest === Permissions.RESULTS.GRANTED;
}
case Permissions.RESULTS.BLOCKED: {
const grantOption = {
text: intl.formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
}),
onPress: () => Permissions.openSettings(),
};
const {title, text} = getMicrophonePermissionDeniedMessage(intl);
Alert.alert(
title,
text,
[
grantOption,
{
text: intl.formatMessage({
id: 'mobile.permission_denied_dismiss',
defaultMessage: 'Don\'t Allow',
}),
},
],
);
case Permissions.RESULTS.BLOCKED:
return false;
}
}
return true;

View file

@ -4,7 +4,11 @@
import {Alert} from 'react-native';
import {hasMicrophonePermission, joinCall, unmuteMyself} from '@calls/actions';
import {setMicPermissionsGranted} from '@calls/state';
import {errorAlert} from '@calls/utils';
import DatabaseManager from '@database/manager';
import {getCurrentUser} from '@queries/servers/user';
import {logError} from '@utils/log';
import type {IntlShape} from 'react-intl';
@ -89,16 +93,24 @@ export const leaveAndJoinWithAlert = (
const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolean, intl: IntlShape) => {
const {formatMessage} = intl;
const hasPermission = await hasMicrophonePermission(intl);
if (!hasPermission) {
errorAlert(formatMessage({
id: 'mobile.calls_error_permissions',
defaultMessage: 'No permissions to microphone, unable to start call',
}), intl);
let user;
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
user = await getCurrentUser(database);
if (!user) {
// This shouldn't happen, so don't bother localizing and displaying an alert.
return;
}
} catch (error) {
logError('failed to getServerDatabaseAndOperator in doJoinCall', error);
return;
}
const res = await joinCall(serverUrl, channelId);
const hasPermission = await hasMicrophonePermission();
setMicPermissionsGranted(hasPermission);
const res = await joinCall(serverUrl, channelId, user.id, hasPermission);
if (res.error) {
const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'See server logs'});
errorAlert(res.error?.toString() || seeLogs, intl);

View file

@ -34,13 +34,12 @@ const enhanced = withObservables([], ({serverUrl, channelId, database}: EnhanceP
switchMap((calls) => of$(Boolean(calls[channelId]))),
distinctUntilChanged(),
);
const currentCall = observeCurrentCall();
const ccDatabase = currentCall.pipe(
const ccDatabase = observeCurrentCall().pipe(
switchMap((call) => of$(call?.serverUrl || '')),
distinctUntilChanged(),
switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)),
);
const ccChannelId = currentCall.pipe(
const ccChannelId = observeCurrentCall().pipe(
switchMap((call) => of$(call?.channelId)),
distinctUntilChanged(),
);
@ -58,7 +57,6 @@ const enhanced = withObservables([], ({serverUrl, channelId, database}: EnhanceP
isACallInCurrentChannel,
confirmToJoin,
alreadyInCall,
currentCall,
currentCallChannelName,
limitRestrictedInfo: observeIsCallLimitRestricted(serverUrl, channelId),
};

View file

@ -1,16 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, TouchableOpacity, Pressable, Platform, DeviceEventEmitter} from 'react-native';
import {View, Text, TouchableOpacity, Pressable, Platform} from 'react-native';
import {Options} from 'react-native-navigation';
import {muteMyself, unmuteMyself} from '@calls/actions';
import CallAvatar from '@calls/components/call_avatar';
import {CurrentCall, VoiceEventData} from '@calls/types/calls';
import PermissionErrorBar from '@calls/components/permission_error_bar';
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
import {usePermissionsChecker} from '@calls/hooks';
import {CurrentCall} from '@calls/types/calls';
import CompassIcon from '@components/compass_icon';
import {Events, Screens, WebsocketEvents} from '@constants';
import {Screens} from '@constants';
import {CURRENT_CALL_BAR_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {dismissAllModalsAndPopToScreen} from '@screens/navigation';
@ -24,6 +27,7 @@ type Props = {
currentCall: CurrentCall | null;
userModelsDict: Dictionary<UserModel>;
teammateNameDisplay: string;
micPermissionsGranted: boolean;
threadScreen?: boolean;
}
@ -57,18 +61,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
color: theme.sidebarText,
opacity: 0.64,
},
micIcon: {
color: theme.sidebarText,
micIconContainer: {
width: 42,
height: 42,
textAlign: 'center',
textAlignVertical: 'center',
justifyContent: 'center',
backgroundColor: '#3DB887',
alignItems: 'center',
backgroundColor: theme.onlineIndicator,
borderRadius: 4,
margin: 4,
padding: 9,
overflow: 'hidden',
},
micIcon: {
color: theme.sidebarText,
},
muted: {
backgroundColor: 'transparent',
@ -86,49 +90,13 @@ const CurrentCallBar = ({
currentCall,
userModelsDict,
teammateNameDisplay,
micPermissionsGranted,
threadScreen,
}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const {formatMessage} = useIntl();
const [speaker, setSpeaker] = useState<string | null>(null);
const [talkingMessage, setTalkingMessage] = useState('');
const isCurrentCall = Boolean(currentCall);
const handleVoiceOn = (data: VoiceEventData) => {
if (data.channelId === currentCall?.channelId) {
setSpeaker(data.userId);
}
};
const handleVoiceOff = (data: VoiceEventData) => {
if (data.channelId === currentCall?.channelId && ((speaker === data.userId) || !speaker)) {
setSpeaker(null);
}
};
useEffect(() => {
const onVoiceOn = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_ON, handleVoiceOn);
const onVoiceOff = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_OFF, handleVoiceOff);
DeviceEventEmitter.emit(Events.CURRENT_CALL_BAR_VISIBLE, isCurrentCall);
return () => {
DeviceEventEmitter.emit(Events.CURRENT_CALL_BAR_VISIBLE, Boolean(false));
onVoiceOn.remove();
onVoiceOff.remove();
};
}, [isCurrentCall]);
useEffect(() => {
if (speaker) {
setTalkingMessage(formatMessage({
id: 'mobile.calls_name_is_talking',
defaultMessage: '{name} is talking',
}, {name: displayUsername(userModelsDict[speaker], teammateNameDisplay)}));
} else {
setTalkingMessage(formatMessage({
id: 'mobile.calls_noone_talking',
defaultMessage: 'No one is talking',
}));
}
}, [speaker, setTalkingMessage]);
usePermissionsChecker(micPermissionsGranted);
const goToCallScreen = useCallback(async () => {
const options: Options = {
@ -150,6 +118,21 @@ const CurrentCallBar = ({
const myParticipant = currentCall?.participants[currentCall.myUserId];
// Since we can only see one user talking, it doesn't really matter who we show here (e.g., we can't
// tell who is speaking louder).
const talkingUsers = Object.keys(currentCall?.voiceOn || {});
const speaker = talkingUsers.length > 0 ? talkingUsers[0] : '';
let talkingMessage = formatMessage({
id: 'mobile.calls_noone_talking',
defaultMessage: 'No one is talking',
});
if (speaker) {
talkingMessage = formatMessage({
id: 'mobile.calls_name_is_talking',
defaultMessage: '{name} is talking',
}, {name: displayUsername(userModelsDict[speaker], teammateNameDisplay)});
}
const muteUnmute = () => {
if (myParticipant?.muted) {
unmuteMyself();
@ -158,42 +141,48 @@ const CurrentCallBar = ({
}
};
const style = getStyleSheet(theme);
const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed;
return (
<View style={style.wrapper}>
<View style={style.container}>
<CallAvatar
userModel={userModelsDict[speaker || '']}
volume={speaker ? 0.5 : 0}
serverUrl={currentCall?.serverUrl || ''}
/>
<View style={style.userInfo}>
<Text style={style.speakingUser}>{talkingMessage}</Text>
<Text style={style.currentChannel}>{`~${displayName}`}</Text>
<>
<View style={style.wrapper}>
<View style={style.container}>
<CallAvatar
userModel={userModelsDict[speaker || '']}
volume={speaker ? 0.5 : 0}
serverUrl={currentCall?.serverUrl || ''}
/>
<View style={style.userInfo}>
<Text style={style.speakingUser}>{talkingMessage}</Text>
<Text style={style.currentChannel}>{`~${displayName}`}</Text>
</View>
<Pressable
onPressIn={goToCallScreen}
style={style.pressable}
>
<CompassIcon
name='arrow-expand'
size={24}
style={style.expandIcon}
/>
</Pressable>
<TouchableOpacity
onPress={muteUnmute}
style={[style.pressable, style.micIconContainer, myParticipant?.muted && style.muted]}
disabled={!micPermissionsGranted}
>
<UnavailableIconWrapper
name={myParticipant?.muted ? 'microphone-off' : 'microphone'}
size={24}
unavailable={!micPermissionsGranted}
style={[style.micIcon]}
/>
</TouchableOpacity>
</View>
<Pressable
onPressIn={goToCallScreen}
style={style.pressable}
>
<CompassIcon
name='arrow-expand'
size={24}
style={style.expandIcon}
/>
</Pressable>
<TouchableOpacity
onPress={muteUnmute}
style={style.pressable}
>
<CompassIcon
name={myParticipant?.muted ? 'microphone-off' : 'microphone'}
size={24}
style={[style.micIcon, myParticipant?.muted ? style.muted : undefined]}
/>
</TouchableOpacity>
</View>
</View>
{micPermissionsError && <PermissionErrorBar/>}
</>
);
};
export default CurrentCallBar;

View file

@ -5,7 +5,7 @@ import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {observeCurrentCall} from '@calls/state';
import {observeCurrentCall, observeGlobalCallsState} from '@calls/state';
import {idsAreEqual} from '@calls/utils';
import DatabaseManager from '@database/manager';
import {observeChannel} from '@queries/servers/channel';
@ -45,12 +45,17 @@ const enhanced = withObservables([], () => {
const teammateNameDisplay = database.pipe(
switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
);
const micPermissionsGranted = observeGlobalCallsState().pipe(
switchMap((gs) => of$(gs.micPermissionsGranted)),
distinctUntilChanged(),
);
return {
displayName,
currentCall,
userModelsDict,
teammateNameDisplay,
micPermissionsGranted,
};
});

View file

@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Pressable, View} from 'react-native';
import Permissions from 'react-native-permissions';
import {setMicPermissionsErrorDismissed} from '@calls/state';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {CALL_ERROR_BAR_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => (
{
pressable: {
zIndex: 10,
},
errorWrapper: {
padding: 10,
paddingTop: 0,
},
errorBar: {
flexDirection: 'row',
backgroundColor: theme.dndIndicator,
minHeight: CALL_ERROR_BAR_HEIGHT,
width: '100%',
borderRadius: 5,
padding: 10,
alignItems: 'center',
},
errorText: {
flex: 1,
...typography('Body', 100, 'SemiBold'),
color: theme.buttonColor,
},
errorIconContainer: {
width: 42,
height: 42,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 4,
margin: 0,
padding: 9,
},
pressedErrorIconContainer: {
backgroundColor: theme.buttonColor,
},
errorIcon: {
color: theme.buttonColor,
fontSize: 18,
},
pressedErrorIcon: {
color: theme.dndIndicator,
},
paddingRight: {
paddingRight: 9,
},
}
));
const PermissionErrorBar = () => {
const theme = useTheme();
const style = getStyleSheet(theme);
return (
<View style={style.errorWrapper}>
<Pressable
onPress={Permissions.openSettings}
style={style.errorBar}
>
<CompassIcon
name='microphone-off'
style={[style.errorIcon, style.paddingRight]}
/>
<FormattedText
id={'mobile.calls_mic_error'}
defaultMessage={'To participate, open Settings to grant Mattermost access to your microphone.'}
style={style.errorText}
/>
<Pressable
onPress={setMicPermissionsErrorDismissed}
hitSlop={5}
style={({pressed}) => [
style.pressable,
style.errorIconContainer,
pressed && style.pressedErrorIconContainer,
]}
>
{({pressed}) => (
<CompassIcon
name='close'
style={[style.errorIcon, pressed && style.pressedErrorIcon]}
/>
)}
</Pressable>
</Pressable>
</View>
);
};
export default PermissionErrorBar;

View file

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, TextStyle, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
name: string;
size: number;
style: StyleProp<TextStyle>;
unavailable: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
position: 'relative',
},
unavailable: {
color: changeOpacity(theme.sidebarText, 0.32),
},
errorContainer: {
position: 'absolute',
right: 0,
backgroundColor: '#3F4350',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 0.5,
borderColor: '#3F4350',
},
errorIcon: {
color: theme.dndIndicator,
},
};
});
const UnavailableIconWrapper = ({name, size, style: providedStyle, unavailable}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const errorIconSize = size / 2;
return (
<View style={style.container}>
<CompassIcon
name={name}
size={size}
style={[providedStyle, unavailable && style.unavailable]}
/>
{unavailable &&
<View
style={[style.errorContainer, {borderRadius: errorIconSize / 2}]}
>
<CompassIcon
name={'close-circle'}
size={errorIconSize}
style={style.errorIcon}
/>
</View>
}
</View>
);
};
export default UnavailableIconWrapper;

View file

@ -25,7 +25,13 @@ import type {CallsConnection} from '@calls/types/calls';
const peerConnectTimeout = 5000;
export async function newConnection(serverUrl: string, channelID: string, closeCb: () => void, setScreenShareURL: (url: string) => void) {
export async function newConnection(
serverUrl: string,
channelID: string,
closeCb: () => void,
setScreenShareURL: (url: string) => void,
hasMicPermission: boolean,
) {
let peer: Peer | null = null;
let stream: MediaStream;
let voiceTrackAdded = false;
@ -34,21 +40,26 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
let onCallEnd: EmitterSubscription | null = null;
const streams: MediaStream[] = [];
try {
stream = await mediaDevices.getUserMedia({
video: false,
audio: true,
}) as MediaStream;
voiceTrack = stream.getAudioTracks()[0];
voiceTrack.enabled = false;
streams.push(stream);
} catch (err) {
logError('Unable to get media device:', err);
}
const initializeVoiceTrack = async () => {
if (voiceTrack) {
return;
}
try {
stream = await mediaDevices.getUserMedia({
video: false,
audio: true,
}) as MediaStream;
voiceTrack = stream.getAudioTracks()[0];
voiceTrack.enabled = false;
streams.push(stream);
} catch (err) {
logError('Unable to get media device:', err);
}
};
// getClient can throw an error, which will be handled by the caller.
const client = NetworkManager.getClient(serverUrl);
const credentials = await getServerCredentials(serverUrl);
const ws = new WebSocketClient(serverUrl, client.getWebSocketUrl(), credentials?.token);
@ -56,6 +67,10 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
// Throws an error, to be caught by caller.
await ws.initialize();
if (hasMicPermission) {
initializeVoiceTrack();
}
const disconnect = () => {
if (isClosed) {
return;
@ -182,6 +197,7 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
InCallManager.start({media: 'audio'});
InCallManager.stopProximitySensor();
peer = new Peer(null, iceConfigs);
peer.on('signal', (data: any) => {
if (data.type === 'offer' || data.type === 'answer') {
@ -265,6 +281,7 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
waitForPeerConnection,
raiseHand,
unraiseHand,
initializeVoiceTrack,
};
return connection;

View file

@ -7,8 +7,8 @@ import {encode} from '@msgpack/msgpack/dist';
import Calls from '@constants/calls';
import DatabaseManager from '@database/manager';
import {getCommonSystemValues} from '@queries/servers/system';
import {logError} from '@utils/log';
import {getConfigValue} from '@queries/servers/system';
import {logError, logDebug} from '@utils/log';
const wsMinReconnectRetryTimeMs = 1000; // 1 second
const wsReconnectionTimeout = 30000; // 30 seconds
@ -42,8 +42,8 @@ export class WebSocketClient extends EventEmitter {
return;
}
const system = await getCommonSystemValues(database);
const connectionUrl = (system.config.WebsocketURL || this.serverUrl) + this.wsPath;
const websocketURL = await getConfigValue(database, 'WebsocketURL');
const connectionUrl = (websocketURL || this.serverUrl) + this.wsPath;
this.ws = new WebSocket(`${connectionUrl}?connection_id=${this.connID}&sequence_number=${this.serverSeqNo}`, [], {headers: {authorization: `Bearer ${this.authToken}`}});
@ -88,9 +88,11 @@ export class WebSocketClient extends EventEmitter {
if (msg.event === 'hello') {
if (msg.data.connection_id !== this.connID) {
logDebug('calls: ws new conn id from server');
this.connID = msg.data.connection_id;
this.serverSeqNo = 0;
if (this.originalConnID === '') {
logDebug('calls: ws setting original conn id');
this.originalConnID = this.connID;
}
}
@ -99,6 +101,7 @@ export class WebSocketClient extends EventEmitter {
}
return;
} else if (!this.connID) {
logDebug('calls: ws message received while waiting for hello');
return;
}

View file

@ -12,6 +12,7 @@ import {
setChannelEnabled,
setRaisedHand,
setUserMuted,
setUserVoiceOn,
userJoinedCall,
userLeftCall,
} from '@calls/state';
@ -38,17 +39,11 @@ export const handleCallUserUnmuted = (serverUrl: string, msg: WebSocketMessage)
};
export const handleCallUserVoiceOn = (msg: WebSocketMessage) => {
DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_ON, {
channelId: msg.broadcast.channel_id,
userId: msg.data.userID,
});
setUserVoiceOn(msg.broadcast.channel_id, msg.data.userID, true);
};
export const handleCallUserVoiceOff = (msg: WebSocketMessage) => {
DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_OFF, {
channelId: msg.broadcast.channel_id,
userId: msg.data.userID,
});
setUserVoiceOn(msg.broadcast.channel_id, msg.data.userID, false);
};
export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage) => {

View file

@ -3,14 +3,18 @@
// Check if calls is enabled. If it is, then run fn; if it isn't, show an alert and set
// msgPostfix to ' (Not Available)'.
import {useCallback, useState} from 'react';
import {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert} from 'react-native';
import {Alert, Platform} from 'react-native';
import Permissions from 'react-native-permissions';
import {initializeVoiceTrack} from '@calls/actions/calls';
import {setMicPermissionsGranted} from '@calls/state';
import {errorAlert} from '@calls/utils';
import {Client} from '@client/rest';
import ClientError from '@client/rest/error';
import {useServerUrl} from '@context/server';
import {useAppState} from '@hooks/device';
import NetworkManager from '@managers/network_manager';
export const useTryCallsFunction = (fn: () => void) => {
@ -71,3 +75,27 @@ export const useTryCallsFunction = (fn: () => void) => {
return [tryFn, msgPostfix] as [() => Promise<void>, string];
};
const micPermission = Platform.select({
ios: Permissions.PERMISSIONS.IOS.MICROPHONE,
default: Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO,
});
export const usePermissionsChecker = (micPermissionsGranted: boolean) => {
const appState = useAppState();
useEffect(() => {
const asyncFn = async () => {
if (appState === 'active') {
const hasPermission = (await Permissions.check(micPermission)) === Permissions.RESULTS.GRANTED;
if (hasPermission) {
initializeVoiceTrack();
setMicPermissionsGranted(hasPermission);
}
}
};
if (!micPermissionsGranted) {
asyncFn();
}
}, [appState]);
};

View file

@ -28,9 +28,12 @@ import {
} from '@calls/actions';
import CallAvatar from '@calls/components/call_avatar';
import CallDuration from '@calls/components/call_duration';
import PermissionErrorBar from '@calls/components/permission_error_bar';
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
import {usePermissionsChecker} from '@calls/hooks';
import RaisedHandIcon from '@calls/icons/raised_hand_icon';
import UnraisedHandIcon from '@calls/icons/unraised_hand_icon';
import {CallParticipant, CurrentCall, VoiceEventData} from '@calls/types/calls';
import {CallParticipant, CurrentCall} from '@calls/types/calls';
import {sortParticipants} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
@ -48,13 +51,14 @@ import {
import NavigationStore from '@store/navigation_store';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {mergeNavigationOptions} from '@utils/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {displayUsername} from '@utils/user';
export type Props = {
componentId: string;
currentCall: CurrentCall | null;
participantsDict: Dictionary<CallParticipant>;
micPermissionsGranted: boolean;
teammateNameDisplay: string;
fromThreadScreen?: boolean;
}
@ -252,20 +256,31 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
color: 'white',
margin: 3,
},
unavailableText: {
color: changeOpacity(theme.sidebarText, 0.32),
},
}));
const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDisplay, fromThreadScreen}: Props) => {
const CallScreen = ({
componentId,
currentCall,
participantsDict,
micPermissionsGranted,
teammateNameDisplay,
fromThreadScreen,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const insets = useSafeAreaInsets();
const {width, height} = useWindowDimensions();
usePermissionsChecker(micPermissionsGranted);
const [showControlsInLandscape, setShowControlsInLandscape] = useState(false);
const [speakers, setSpeakers] = useState<Dictionary<boolean>>({});
const style = getStyleSheet(theme);
const isLandscape = width > height;
const showControls = !isLandscape || showControlsInLandscape;
const myParticipant = currentCall?.participants[currentCall.myUserId];
const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed;
const chatThreadTitle = intl.formatMessage({id: 'mobile.calls_chat_thread', defaultMessage: 'Chat thread'});
useEffect(() => {
@ -279,30 +294,6 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
});
}, []);
useEffect(() => {
const handleVoiceOn = (data: VoiceEventData) => {
if (data.channelId === currentCall?.channelId) {
setSpeakers((prev) => ({...prev, [data.userId]: true}));
}
};
const handleVoiceOff = (data: VoiceEventData) => {
if (data.channelId === currentCall?.channelId && speakers.hasOwnProperty(data.userId)) {
setSpeakers((prev) => {
const next = {...prev};
delete next[data.userId];
return next;
});
}
};
const onVoiceOn = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_ON, handleVoiceOn);
const onVoiceOff = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_OFF, handleVoiceOff);
return () => {
onVoiceOn.remove();
onVoiceOff.remove();
};
}, [speakers, currentCall?.channelId]);
const leaveCallHandler = useCallback(() => {
popTopScreen();
leaveCall();
@ -325,6 +316,10 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
}
}, [myParticipant?.raisedHand]);
const toggleSpeakerPhone = useCallback(() => {
setSpeakerphoneOn(!currentCall?.speakerphoneOn);
}, [currentCall?.speakerphoneOn]);
const toggleControlsInLandscape = useCallback(() => {
setShowControlsInLandscape(!showControlsInLandscape);
}, [showControlsInLandscape]);
@ -424,8 +419,8 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
usersList = (
<ScrollView
alwaysBounceVertical={false}
horizontal={currentCall?.screenOn !== ''}
contentContainerStyle={[isLandscape && currentCall?.screenOn && style.usersScrollLandscapeScreenOn]}
horizontal={currentCall.screenOn !== ''}
contentContainerStyle={[isLandscape && currentCall.screenOn && style.usersScrollLandscapeScreenOn]}
>
<Pressable
testID='users-list'
@ -435,12 +430,12 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
{participants.map((user) => {
return (
<View
style={[style.user, currentCall?.screenOn && style.userScreenOn]}
style={[style.user, currentCall.screenOn && style.userScreenOn]}
key={user.id}
>
<CallAvatar
userModel={user.userModel}
volume={speakers[user.id] ? 1 : 0}
volume={currentCall.voiceOn[user.id] ? 1 : 0}
muted={user.muted}
sharingScreen={user.id === currentCall.screenOn}
raisedHand={Boolean(user.raisedHand)}
@ -484,7 +479,7 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
<FormattedText
id={'mobile.calls_unmute'}
defaultMessage={'Unmute'}
style={style.buttonText}
style={[style.buttonText, !micPermissionsGranted && style.unavailableText]}
/>);
return (
@ -508,6 +503,7 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
</View>
{usersList}
{screenShareView}
{micPermissionsError && <PermissionErrorBar/>}
<View
style={[style.buttons, isLandscape && style.buttonsLandscape, !showControls && style.buttonsLandscapeNoControls]}
>
@ -516,10 +512,12 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
testID='mute-unmute'
style={[style.mute, myParticipant.muted && style.muteMuted]}
onPress={muteUnmuteHandler}
disabled={!micPermissionsGranted}
>
<CompassIcon
<UnavailableIconWrapper
name={myParticipant.muted ? 'microphone-off' : 'microphone'}
size={24}
unavailable={!micPermissionsGranted}
style={style.muteIcon}
/>
{myParticipant.muted ? UnmuteText : MuteText}
@ -544,12 +542,12 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
<Pressable
testID={'toggle-speakerphone'}
style={style.button}
onPress={() => setSpeakerphoneOn(!currentCall?.speakerphoneOn)}
onPress={toggleSpeakerPhone}
>
<CompassIcon
name={'volume-high'}
size={24}
style={[style.buttonIcon, style.speakerphoneIcon, currentCall?.speakerphoneOn && style.speakerphoneIconOn]}
style={[style.buttonIcon, style.speakerphoneIcon, currentCall.speakerphoneOn && style.speakerphoneIconOn]}
/>
<FormattedText
id={'mobile.calls_speaker'}

View file

@ -6,7 +6,7 @@ import {combineLatest, of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import CallScreen from '@calls/screens/call_screen/call_screen';
import {observeCurrentCall} from '@calls/state';
import {observeCurrentCall, observeGlobalCallsState} from '@calls/state';
import {CallParticipant} from '@calls/types/calls';
import DatabaseManager from '@database/manager';
import {observeTeammateNameDisplay, queryUsersById} from '@queries/servers/user';
@ -34,6 +34,10 @@ const enhanced = withObservables([], () => {
}, {} as Dictionary<CallParticipant>))),
)),
);
const micPermissionsGranted = observeGlobalCallsState().pipe(
switchMap((gs) => of$(gs.micPermissionsGranted)),
distinctUntilChanged(),
);
const teammateNameDisplay = database.pipe(
switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
distinctUntilChanged(),
@ -42,6 +46,7 @@ const enhanced = withObservables([], () => {
return {
currentCall,
participantsDict,
micPermissionsGranted,
teammateNameDisplay,
};
});

View file

@ -6,13 +6,17 @@ import assert from 'assert';
import {act, renderHook} from '@testing-library/react-hooks';
import {
newCurrentCall,
setCallsState,
setChannelsWithCalls,
setCurrentCall,
setMicPermissionsErrorDismissed,
setMicPermissionsGranted,
useCallsConfig,
useCallsState,
useChannelsWithCalls,
useCurrentCall,
useGlobalCallsState,
} from '@calls/state';
import {
setCalls,
@ -30,12 +34,22 @@ import {
setSpeakerPhone,
setConfig,
setPluginEnabled,
setUserVoiceOn,
} from '@calls/state/actions';
import {License} from '@constants';
import {CallsState, CurrentCall, DefaultCallsConfig, DefaultCallsState} from '../types/calls';
import {
Call,
CallsState,
CurrentCall,
DefaultCallsConfig,
DefaultCallsState,
DefaultCurrentCall,
DefaultGlobalCallsState,
GlobalCallsState,
} from '../types/calls';
const call1 = {
const call1: Call = {
participants: {
'user-1': {id: 'user-1', muted: false, raisedHand: 0},
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
@ -46,7 +60,7 @@ const call1 = {
threadId: 'thread-1',
ownerId: 'user-1',
};
const call2 = {
const call2: Call = {
participants: {
'user-3': {id: 'user-3', muted: false, raisedHand: 0},
'user-4': {id: 'user-4', muted: true, raisedHand: 0},
@ -57,7 +71,7 @@ const call2 = {
threadId: 'thread-2',
ownerId: 'user-3',
};
const call3 = {
const call3: Call = {
participants: {
'user-5': {id: 'user-5', muted: false, raisedHand: 0},
'user-6': {id: 'user-6', muted: true, raisedHand: 0},
@ -107,39 +121,67 @@ describe('useCallsState', () => {
const initialChannelsWithCallsState = {
'channel-1': true,
};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
};
const testNewCall1 = {
...call1,
participants: {
'user-1': {id: 'user-1', muted: false, raisedHand: 0},
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
'user-3': {id: 'user-3', muted: false, raisedHand: 123},
},
};
const test = {
calls: {'channel-1': call2, 'channel-2': call3},
calls: {'channel-1': testNewCall1, 'channel-2': call2, 'channel-3': call3},
enabled: {'channel-2': true},
};
const expectedCallsState = {
...initialCallsState,
serverUrl: 'server1',
myUserId: 'myId',
calls: {'channel-1': call2, 'channel-2': call3},
calls: {'channel-1': testNewCall1, 'channel-2': call2, 'channel-3': call3},
enabled: {'channel-2': true},
};
const expectedChannelsWithCallsState = {
...initialChannelsWithCallsState,
'channel-2': true,
'channel-3': true,
};
const expectedCurrentCallState = {
...initialCurrentCallState,
...testNewCall1,
};
// setup
const {result} = renderHook(() => {
return [useCallsState('server1'), useCallsState('server1'), useChannelsWithCalls('server1')];
return [
useCallsState('server1'),
useCallsState('server1'),
useChannelsWithCalls('server1'),
useCurrentCall(),
];
});
act(() => {
setCallsState('server1', initialCallsState);
setChannelsWithCalls('server1', initialChannelsWithCallsState);
setCurrentCall(initialCurrentCallState);
});
assert.deepEqual(result.current[0], initialCallsState);
assert.deepEqual(result.current[1], initialCallsState);
assert.deepEqual(result.current[2], initialChannelsWithCallsState);
assert.deepEqual(result.current[3], initialCurrentCallState);
// test
act(() => setCalls('server1', 'myId', test.calls, test.enabled));
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], expectedCallsState);
assert.deepEqual(result.current[2], expectedChannelsWithCallsState);
assert.deepEqual(result.current[3], expectedCurrentCallState);
});
it('joinedCall', () => {
@ -150,13 +192,14 @@ describe('useCallsState', () => {
const initialChannelsWithCallsState = {
'channel-1': true,
};
const initialCurrentCallState = {
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
connected: true,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
} as CurrentCall;
};
const expectedCallsState = {
'channel-1': {
participants: {
@ -209,13 +252,13 @@ describe('useCallsState', () => {
const initialChannelsWithCallsState = {
'channel-1': true,
};
const initialCurrentCallState = {
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
connected: true,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
} as CurrentCall;
};
const expectedCallsState = {
'channel-1': {
participants: {
@ -311,13 +354,13 @@ describe('useCallsState', () => {
calls: {'channel-1': call1, 'channel-2': call2},
};
const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
const initialCurrentCallState = {
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
connected: true,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
} as CurrentCall;
};
// setup
const {result} = renderHook(() => {
@ -358,13 +401,12 @@ describe('useCallsState', () => {
calls: {'channel-1': call1, 'channel-2': call2},
};
const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
const initialCurrentCallState = {
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
} as CurrentCall;
};
// setup
const {result} = renderHook(() => {
@ -416,13 +458,13 @@ describe('useCallsState', () => {
ownerId: 'user-1',
},
};
const initialCurrentCallState = {
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
connected: true,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
} as CurrentCall;
};
const expectedCurrentCallState = {
...initialCurrentCallState,
...expectedCalls['channel-1'],
@ -469,17 +511,18 @@ describe('useCallsState', () => {
};
const expectedCallsState = {
...initialCallsState,
calls: {...initialCallsState.calls,
calls: {
...initialCallsState.calls,
'channel-1': newCall1,
},
};
const expectedCurrentCallState = {
const expectedCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
connected: true,
serverUrl: 'server1',
myUserId: 'myUserId',
screenShareURL: '',
speakerphoneOn: false,
...newCall1,
} as CurrentCall;
};
// setup
const {result} = renderHook(() => {
@ -490,7 +533,10 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], null);
// test
act(() => userJoinedCall('server1', 'channel-1', 'myUserId'));
act(() => {
newCurrentCall('server1', 'channel-1', 'myUserId');
userJoinedCall('server1', 'channel-1', 'myUserId');
});
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], expectedCurrentCallState);
@ -560,6 +606,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], null);
// test joining a call and setting url:
act(() => newCurrentCall('server1', 'channel-1', 'myUserId'));
act(() => userJoinedCall('server1', 'channel-1', 'myUserId'));
assert.deepEqual((result.current[1] as CurrentCall | null)?.screenShareURL, '');
act(() => setScreenShareURL('testUrl'));
@ -589,7 +636,8 @@ describe('useCallsState', () => {
};
const expectedCallsState = {
...initialCallsState,
calls: {...initialCallsState.calls,
calls: {
...initialCallsState.calls,
'channel-1': newCall1,
},
};
@ -603,6 +651,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], null);
// test
act(() => newCurrentCall('server1', 'channel-1', 'myUserId'));
act(() => userJoinedCall('server1', 'channel-1', 'myUserId'));
assert.deepEqual((result.current[1] as CurrentCall | null)?.speakerphoneOn, false);
act(() => setSpeakerPhone(true));
@ -618,6 +667,123 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], null);
});
it('MicPermissions', () => {
const initialGlobalState = DefaultGlobalCallsState;
const initialCallsState: CallsState = {
...DefaultCallsState,
myUserId: 'myUserId',
calls: {'channel-1': call1, 'channel-2': call2},
};
const newCall1: Call = {
...call1,
participants: {
...call1.participants,
myUserId: {id: 'myUserId', muted: true, raisedHand: 0},
},
};
const expectedCallsState: CallsState = {
...initialCallsState,
calls: {
...initialCallsState.calls,
'channel-1': newCall1,
},
};
const expectedCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
connected: true,
...newCall1,
};
const secondExpectedCurrentCallState: CurrentCall = {
...expectedCurrentCallState,
micPermissionsErrorDismissed: true,
};
const expectedGlobalState: GlobalCallsState = {
micPermissionsGranted: true,
};
// setup
const {result} = renderHook(() => {
return [useCallsState('server1'), useCurrentCall(), useGlobalCallsState()];
});
act(() => setCallsState('server1', initialCallsState));
assert.deepEqual(result.current[0], initialCallsState);
assert.deepEqual(result.current[1], null);
assert.deepEqual(result.current[2], initialGlobalState);
// join call
act(() => {
setMicPermissionsGranted(false);
newCurrentCall('server1', 'channel-1', 'myUserId');
userJoinedCall('server1', 'channel-1', 'myUserId');
});
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], expectedCurrentCallState);
assert.deepEqual(result.current[2], initialGlobalState);
// dismiss mic error
act(() => setMicPermissionsErrorDismissed());
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], secondExpectedCurrentCallState);
assert.deepEqual(result.current[2], initialGlobalState);
// grant permissions
act(() => setMicPermissionsGranted(true));
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], secondExpectedCurrentCallState);
assert.deepEqual(result.current[2], expectedGlobalState);
act(() => {
myselfLeftCall();
userLeftCall('server1', 'channel-1', 'myUserId');
});
assert.deepEqual(result.current[0], initialCallsState);
assert.deepEqual(result.current[1], null);
});
it('voiceOn and Off', () => {
const initialCallsState = {
...DefaultCallsState,
serverUrl: 'server1',
myUserId: 'myUserId',
calls: {'channel-1': call1, 'channel-2': call2},
};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
};
// setup
const {result} = renderHook(() => {
return [useCallsState('server1'), useCurrentCall()];
});
act(() => {
setCallsState('server1', initialCallsState);
setCurrentCall(initialCurrentCallState);
});
assert.deepEqual(result.current[0], initialCallsState);
assert.deepEqual(result.current[1], initialCurrentCallState);
// test
act(() => setUserVoiceOn('channel-1', 'user-1', true));
assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-1': true}});
assert.deepEqual(result.current[0], initialCallsState);
act(() => setUserVoiceOn('channel-1', 'user-2', true));
assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-1': true, 'user-2': true}});
assert.deepEqual(result.current[0], initialCallsState);
act(() => setUserVoiceOn('channel-1', 'user-1', false));
assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-2': true}});
assert.deepEqual(result.current[0], initialCallsState);
// test that voice state is cleared on reconnect
act(() => setCalls('server1', 'myUserId', initialCallsState.calls, {}));
assert.deepEqual(result.current[1], initialCurrentCallState);
assert.deepEqual(result.current[0], initialCallsState);
});
it('config', () => {
const newConfig = {
ICEServers: [],

View file

@ -6,12 +6,14 @@ import {
getCallsState,
getChannelsWithCalls,
getCurrentCall,
getGlobalCallsState,
setCallsConfig,
setCallsState,
setChannelsWithCalls,
setCurrentCall,
setGlobalCallsState,
} from '@calls/state';
import {Call, CallsConfig, ChannelsWithCalls} from '@calls/types/calls';
import {Call, CallsConfig, ChannelsWithCalls, DefaultCall, DefaultCurrentCall} from '@calls/types/calls';
export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary<Call>, enabled: Dictionary<boolean>) => {
const channelsWithCalls = Object.keys(calls).reduce(
@ -22,6 +24,21 @@ export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary<
setChannelsWithCalls(serverUrl, channelsWithCalls);
setCallsState(serverUrl, {serverUrl, myUserId, calls, enabled});
// Does the current call need to be updated?
const currentCall = getCurrentCall();
if (!currentCall || !calls[currentCall.channelId]) {
return;
}
// Edge case: if the app went into the background and lost the main ws connection, we don't know who is currently
// talking. Instead of guessing, erase voiceOn state (same state as when joining an ongoing call).
const nextCall = {
...currentCall,
...calls[currentCall.channelId],
voiceOn: {},
};
setCurrentCall(nextCall);
};
export const setCallForChannel = (serverUrl: string, channelId: string, enabled: boolean, call?: Call) => {
@ -80,23 +97,21 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str
// Did the user join the current call? If so, update that too.
const currentCall = getCurrentCall();
if (currentCall && currentCall.channelId === channelId) {
const voiceOn = {...currentCall.voiceOn};
delete voiceOn[userId];
const nextCurrentCall = {
...currentCall,
participants: {...currentCall.participants, [userId]: nextCall.participants[userId]},
voiceOn,
};
setCurrentCall(nextCurrentCall);
}
// Was it me that joined the call?
if (callsState.myUserId === userId) {
setCurrentCall({
...nextCall,
participants: {...nextCall.participants},
serverUrl,
myUserId: userId,
screenShareURL: '',
speakerphoneOn: false,
});
// If this is the currentUser, that means we've connected to the call we created.
if (userId === nextCurrentCall.myUserId) {
nextCurrentCall.connected = true;
}
setCurrentCall(nextCurrentCall);
}
};
@ -137,14 +152,35 @@ export const userLeftCall = (serverUrl: string, channelId: string, userId: strin
return;
}
// Clear them from the voice list
const voiceOn = {...currentCall.voiceOn};
delete voiceOn[userId];
const nextCurrentCall = {
...currentCall,
participants: {...currentCall.participants},
voiceOn,
};
delete nextCurrentCall.participants[userId];
setCurrentCall(nextCurrentCall);
};
export const newCurrentCall = (serverUrl: string, channelId: string, myUserId: string) => {
let existingCall: Call = DefaultCall;
const callsState = getCallsState(serverUrl);
if (callsState.calls[channelId]) {
existingCall = callsState.calls[channelId];
}
setCurrentCall({
...DefaultCurrentCall,
...existingCall,
serverUrl,
channelId,
myUserId,
});
};
export const myselfLeftCall = () => {
setCurrentCall(null);
};
@ -157,6 +193,21 @@ export const callStarted = (serverUrl: string, call: Call) => {
const nextChannelsWithCalls = {...getChannelsWithCalls(serverUrl), [call.channelId]: true};
setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
// If we started a call, we will get a callStarted event with the 'official' data from the server.
// Save that in our currentCall.
const currentCall = getCurrentCall();
if (!currentCall || currentCall.channelId !== call.channelId) {
return;
}
const nextCurrentCall = {
...currentCall,
startTime: call.startTime,
threadId: call.threadId,
ownerId: call.ownerId,
};
setCurrentCall(nextCurrentCall);
};
export const callEnded = (serverUrl: string, channelId: string) => {
@ -170,10 +221,7 @@ export const callEnded = (serverUrl: string, channelId: string) => {
delete nextChannelsWithCalls[channelId];
setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
const currentCall = getCurrentCall();
if (currentCall?.channelId === channelId) {
setCurrentCall(null);
}
// currentCall is set to null by the disconnect.
};
export const setUserMuted = (serverUrl: string, channelId: string, userId: string, muted: boolean) => {
@ -208,6 +256,26 @@ export const setUserMuted = (serverUrl: string, channelId: string, userId: strin
setCurrentCall(nextCurrentCall);
};
export const setUserVoiceOn = (channelId: string, userId: string, voiceOn: boolean) => {
const currentCall = getCurrentCall();
if (!currentCall || currentCall.channelId !== channelId) {
return;
}
const nextVoiceOn = {...currentCall.voiceOn};
if (voiceOn) {
nextVoiceOn[userId] = true;
} else {
delete nextVoiceOn[userId];
}
const nextCurrentCall = {
...currentCall,
voiceOn: nextVoiceOn,
};
setCurrentCall(nextCurrentCall);
};
export const setRaisedHand = (serverUrl: string, channelId: string, userId: string, timestamp: number) => {
const callsState = getCallsState(serverUrl);
if (!callsState.calls[channelId] || !callsState.calls[channelId].participants[userId]) {
@ -318,3 +386,26 @@ export const setPluginEnabled = (serverUrl: string, pluginEnabled: boolean) => {
const callsConfig = getCallsConfig(serverUrl);
setCallsConfig(serverUrl, {...callsConfig, pluginEnabled});
};
export const setMicPermissionsGranted = (granted: boolean) => {
const globalState = getGlobalCallsState();
const nextGlobalState = {
...globalState,
micPermissionsGranted: granted,
};
setGlobalCallsState(nextGlobalState);
};
export const setMicPermissionsErrorDismissed = () => {
const currentCall = getCurrentCall();
if (!currentCall) {
return;
}
const nextCurrentCall = {
...currentCall,
micPermissionsErrorDismissed: true,
};
setCurrentCall(nextCurrentCall);
};

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {BehaviorSubject} from 'rxjs';
import {DefaultGlobalCallsState, GlobalCallsState} from '@calls/types/calls';
const globalStateSubject = new BehaviorSubject(DefaultGlobalCallsState);
export const getGlobalCallsState = () => {
return globalStateSubject.value;
};
export const setGlobalCallsState = (globalState: GlobalCallsState) => {
globalStateSubject.next(globalState);
};
export const observeGlobalCallsState = () => {
return globalStateSubject.asObservable();
};
export const useGlobalCallsState = () => {
const [state, setState] = useState<GlobalCallsState>(DefaultGlobalCallsState);
useEffect(() => {
const subscription = globalStateSubject.subscribe((globalState) => {
setState(globalState);
});
return () => {
subscription?.unsubscribe();
};
}, []);
return state;
};

View file

@ -6,3 +6,4 @@ export * from './calls_state';
export * from './calls_config';
export * from './current_call';
export * from './channels_with_calls';
export * from './global_calls_state';

View file

@ -4,6 +4,14 @@
import type UserModel from '@typings/database/models/servers/user';
import type {ConfigurationParamWithUrls, ConfigurationParamWithUrl} from 'react-native-webrtc';
export type GlobalCallsState = {
micPermissionsGranted: boolean;
}
export const DefaultGlobalCallsState: GlobalCallsState = {
micPermissionsGranted: false,
};
export type CallsState = {
serverUrl: string;
myUserId: string;
@ -11,12 +19,12 @@ export type CallsState = {
enabled: Dictionary<boolean>;
}
export const DefaultCallsState = {
export const DefaultCallsState: CallsState = {
serverUrl: '',
myUserId: '',
calls: {} as Dictionary<Call>,
enabled: {} as Dictionary<boolean>,
} as CallsState;
};
export type Call = {
participants: Dictionary<CallParticipant>;
@ -27,26 +35,41 @@ export type Call = {
ownerId: string;
}
export const DefaultCall = {
export const DefaultCall: Call = {
participants: {} as Dictionary<CallParticipant>,
channelId: '',
startTime: 0,
screenOn: '',
threadId: '',
ownerId: '',
};
export type CurrentCall = {
export type CurrentCall = Call & {
connected: boolean;
serverUrl: string;
myUserId: string;
participants: Dictionary<CallParticipant>;
channelId: string;
startTime: number;
screenOn: string;
threadId: string;
screenShareURL: string;
speakerphoneOn: boolean;
voiceOn: Dictionary<boolean>;
micPermissionsErrorDismissed: boolean;
}
export const DefaultCurrentCall: CurrentCall = {
connected: false,
serverUrl: '',
myUserId: '',
participants: {},
channelId: '',
startTime: 0,
screenOn: '',
threadId: '',
ownerId: '',
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
micPermissionsErrorDismissed: false,
};
export type CallParticipant = {
id: string;
muted: boolean;
@ -89,6 +112,7 @@ export type CallsConnection = {
waitForPeerConnection: () => Promise<void>;
raiseHand: () => void;
unraiseHand: () => void;
initializeVoiceTrack: () => void;
}
export type ServerCallsConfig = {
@ -106,7 +130,7 @@ export type CallsConfig = ServerCallsConfig & {
last_retrieved_at: number;
}
export const DefaultCallsConfig = {
export const DefaultCallsConfig: CallsConfig = {
pluginEnabled: false,
ICEServers: [], // deprecated
ICEServersConfigs: [],
@ -116,7 +140,7 @@ export const DefaultCallsConfig = {
last_retrieved_at: 0,
sku_short_name: '',
MaxCallParticipants: 0,
} as CallsConfig;
};
export type ICEServersConfigs = Array<ConfigurationParamWithUrls | ConfigurationParamWithUrl>;

View file

@ -2,18 +2,19 @@
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import {of as of$, Observable} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {of as of$, Observable, combineLatest} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {Config, Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {PUSH_PROXY_STATUS_UNKNOWN} from '@constants/push_proxy';
import {isMinimumServerVersion} from '@utils/helpers';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type ConfigModel from '@typings/database/models/servers/config';
import type SystemModel from '@typings/database/models/servers/system';
export type PrepareCommonSystemValuesArgs = {
config?: ClientConfig;
lastUnreadChannelId?: string;
currentChannelId?: string;
currentTeamId?: string;
@ -22,7 +23,7 @@ export type PrepareCommonSystemValuesArgs = {
teamHistory?: string;
}
const {SERVER: {SYSTEM}} = MM_TABLES;
const {SERVER: {SYSTEM, CONFIG}} = MM_TABLES;
export const getCurrentChannelId = async (serverDatabase: Database): Promise<string> => {
try {
@ -94,7 +95,6 @@ export const observePushVerificationStatus = (database: Database): Observable<st
export const getCommonSystemValues = async (serverDatabase: Database) => {
const systemRecords = (await serverDatabase.collections.get<SystemModel>(SYSTEM).query().fetch());
let config: ClientConfig = {} as ClientConfig;
let license: ClientLicense = {} as ClientLicense;
let currentChannelId = '';
let currentTeamId = '';
@ -102,9 +102,6 @@ export const getCommonSystemValues = async (serverDatabase: Database) => {
let lastUnreadChannelId = '';
systemRecords.forEach((systemRecord) => {
switch (systemRecord.id) {
case SYSTEM_IDENTIFIERS.CONFIG:
config = systemRecord.value;
break;
case SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID:
currentChannelId = systemRecord.value;
break;
@ -128,51 +125,75 @@ export const getCommonSystemValues = async (serverDatabase: Database) => {
currentTeamId,
currentUserId,
lastUnreadChannelId,
config,
license,
};
};
export const getConfig = async (serverDatabase: Database) => {
try {
const config = await serverDatabase.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.CONFIG);
return (config?.value || {}) as ClientConfig;
} catch {
return undefined;
}
const fromModelToClientConfig = (list: ConfigModel[]) => {
const config: {[key: string]: any} = {};
list.forEach((v) => {
config[v.id] = v.value;
});
return config as ClientConfig;
};
export const getConfig = async (database: Database) => {
const configList = await database.get<ConfigModel>(CONFIG).query().fetch();
return fromModelToClientConfig(configList);
};
export const queryConfigValue = (database: Database, key: keyof ClientConfig) => {
return database.get<ConfigModel>(CONFIG).query(Q.where('id', Q.eq(key)));
};
export const getConfigValue = async (database: Database, key: keyof ClientConfig) => {
const list = await queryConfigValue(database, key).fetch();
return list.length ? list[0].value : undefined;
};
export const observeConfig = (database: Database): Observable<ClientConfig | undefined> => {
return querySystemValue(database, SYSTEM_IDENTIFIERS.CONFIG).observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$({value: undefined}))),
switchMap((model) => of$(model.value)),
return database.get<ConfigModel>(CONFIG).query().observeWithColumns(['value']).pipe(
switchMap((result) => of$(fromModelToClientConfig(result))),
);
};
export const observeConfigValue = (database: Database, key: keyof ClientConfig) => {
return observeConfig(database).pipe(
switchMap((cfg) => of$(cfg?.[key])),
return queryConfigValue(database, key).observeWithColumns(['value']).pipe(
switchMap((result) => of$(result.length ? result[0].value : undefined)),
);
};
export const observeMaxFileCount = (database: Database) => {
return observeConfigValue(database, 'Version').pipe(
switchMap((v) => of$(isMinimumServerVersion(v || '', 6, 0) ? 10 : 5)),
);
};
export const observeIsCustomStatusExpirySupported = (database: Database) => {
return observeConfigValue(database, 'Version').pipe(
switchMap((v) => of$(isMinimumServerVersion(v || '', 5, 37))),
);
};
export const observeConfigBooleanValue = (database: Database, key: keyof ClientConfig) => {
return observeConfig(database).pipe(
switchMap((cfg) => of$(cfg?.[key] === 'true')),
return observeConfigValue(database, key).pipe(
switchMap((v) => of$(v === 'true')),
distinctUntilChanged(),
);
};
export const observeConfigIntValue = (database: Database, key: keyof ClientConfig, defaultValue = 0) => {
return observeConfig(database).pipe(
switchMap((cfg) => of$((parseInt(cfg?.[key] || '0', 10) || defaultValue))),
return observeConfigValue(database, key).pipe(
switchMap((v) => of$((parseInt(v || '0', 10) || defaultValue))),
);
};
export const observeIsPostPriorityEnabled = (database: Database) => {
const config = observeConfig(database);
return config.pipe(
switchMap(
(cfg) => of$(cfg?.FeatureFlagPostPriority === Config.TRUE && cfg?.PostPriority === Config.TRUE),
),
const featureFlag = observeConfigValue(database, 'FeatureFlagPostPriority');
const cfg = observeConfigValue(database, 'PostPriority');
return combineLatest([featureFlag, cfg]).pipe(
switchMap(([ff, c]) => of$(ff === Config.TRUE && c === Config.TRUE)),
distinctUntilChanged(),
);
};
@ -297,14 +318,8 @@ export const patchTeamHistory = (operator: ServerDataOperator, value: string[],
export async function prepareCommonSystemValues(
operator: ServerDataOperator, values: PrepareCommonSystemValuesArgs): Promise<SystemModel[]> {
try {
const {config, lastUnreadChannelId, currentChannelId, currentTeamId, currentUserId, license} = values;
const {lastUnreadChannelId, currentChannelId, currentTeamId, currentUserId, license} = values;
const systems: IdValue[] = [];
if (config !== undefined) {
systems.push({
id: SYSTEM_IDENTIFIERS.CONFIG,
value: JSON.stringify(config),
});
}
if (license !== undefined) {
systems.push({
@ -431,3 +446,17 @@ export const getExpiredSession = async (database: Database) => {
return undefined;
}
};
export const observeCanUploadFiles = (database: Database) => {
const enableFileAttachments = observeConfigBooleanValue(database, 'EnableFileAttachments');
const enableMobileFileUpload = observeConfigBooleanValue(database, 'EnableMobileFileUpload');
const license = observeLicense(database);
return combineLatest([enableFileAttachments, enableMobileFileUpload, license]).pipe(
switchMap(([efa, emfu, l]) => of$(
efa ||
(l?.IsLicensed !== 'true' && l?.Compliance !== 'true' && emfu),
),
),
);
};

View file

@ -10,7 +10,7 @@ import {MM_TABLES} from '@constants/database';
import {processIsCRTEnabled} from '@utils/thread';
import {queryPreferencesByCategoryAndName} from './preference';
import {getConfig, observeConfig} from './system';
import {getConfig, observeConfigValue} from './system';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type Model from '@nozbe/watermelondb/Model';
@ -22,7 +22,7 @@ const {SERVER: {CHANNEL, POST, THREAD, THREADS_IN_TEAM, THREAD_PARTICIPANT, USER
export const getIsCRTEnabled = async (database: Database): Promise<boolean> => {
const config = await getConfig(database);
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).fetch();
return processIsCRTEnabled(preferences, config);
return processIsCRTEnabled(preferences, config?.CollapsedThreads, config?.FeatureFlagCollapsedThreads);
};
export const getThreadById = async (database: Database, threadId: string) => {
@ -35,11 +35,12 @@ export const getThreadById = async (database: Database, threadId: string) => {
};
export const observeIsCRTEnabled = (database: Database) => {
const config = observeConfig(database);
const cfgValue = observeConfigValue(database, 'CollapsedThreads');
const featureFlag = observeConfigValue(database, 'FeatureFlagCollapsedThreads');
const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).observeWithColumns(['value']);
return combineLatest([config, preferences]).pipe(
return combineLatest([cfgValue, featureFlag, preferences]).pipe(
map(
([cfg, prefs]) => processIsCRTEnabled(prefs, cfg),
([cfgV, ff, prefs]) => processIsCRTEnabled(prefs, cfgV, ff),
),
distinctUntilChanged(),
);

View file

@ -12,7 +12,7 @@ import {observeMyChannel} from '@queries/servers/channel';
import {isChannelAdmin} from '@utils/user';
import {queryPreferencesByCategoryAndName} from './preference';
import {observeConfig, observeCurrentUserId, observeLicense, getCurrentUserId, getConfig, getLicense} from './system';
import {observeCurrentUserId, observeLicense, getCurrentUserId, getConfig, getLicense, observeConfigValue} from './system';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership';
@ -67,13 +67,14 @@ export async function prepareUsers(operator: ServerDataOperator, users: UserProf
}
export const observeTeammateNameDisplay = (database: Database) => {
const config = observeConfig(database);
const lockTeammateNameDisplay = observeConfigValue(database, 'LockTeammateNameDisplay');
const teammateNameDisplay = observeConfigValue(database, 'TeammateNameDisplay');
const license = observeLicense(database);
const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).
observeWithColumns(['value']);
return combineLatest([config, license, preferences]).pipe(
return combineLatest([lockTeammateNameDisplay, teammateNameDisplay, license, preferences]).pipe(
switchMap(
([cfg, lcs, prefs]) => of$(getTeammateNameDisplaySetting(prefs, cfg, lcs)),
([ltnd, tnd, lcs, prefs]) => of$(getTeammateNameDisplaySetting(prefs, ltnd, tnd, lcs)),
),
);
};
@ -82,7 +83,7 @@ export async function getTeammateNameDisplay(database: Database) {
const config = await getConfig(database);
const license = await getLicense(database);
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).fetch();
return getTeammateNameDisplaySetting(preferences, config, license);
return getTeammateNameDisplaySetting(preferences, config?.LockTeammateNameDisplay, config?.TeammateNameDisplay, license);
}
export const queryUsersLike = (database: Database, likeUsername: string) => {

View file

@ -9,7 +9,7 @@ import {switchMap} from 'rxjs/operators';
import {Permissions} from '@constants';
import {queryAllMyChannel} from '@queries/servers/channel';
import {queryRolesByNames} from '@queries/servers/role';
import {observeConfig, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeUser} from '@queries/servers/user';
import {hasPermission} from '@utils/role';
@ -18,15 +18,8 @@ import SearchHandler from './search_handler';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const config = observeConfig(database);
const sharedChannelsEnabled = config.pipe(
switchMap((v) => of$(v?.ExperimentalSharedChannels === 'true')),
);
const canShowArchivedChannels = config.pipe(
switchMap((v) => of$(v?.ExperimentalViewArchivedChannels === 'true')),
);
const sharedChannelsEnabled = observeConfigBooleanValue(database, 'ExperimentalSharedChannels');
const canShowArchivedChannels = observeConfigBooleanValue(database, 'ExperimentalViewArchivedChannels');
const currentTeamId = observeCurrentTeamId(database);
const currentUserId = observeCurrentUserId(database);

File diff suppressed because one or more lines are too long

View file

@ -234,8 +234,6 @@ const PublicChannelIllustration = ({theme}: Props) => (
id='image0_516_33521'
width={72}
height={77}
// @ts-expect-error string source
xlinkHref='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEgAAABNCAYAAAAFICL0AAALM0lEQVR4XtWce68URRDFq8Uo8YHGBz5QfBCNGvnH7/8tNBolIgqIGlExgmgwbX6Vqk51T/fM7GXv3tmbkMvd2Z2drqk6darq9CTZw0/O+bWU0s+jU+WcnxSRJ1NKD+e+Luf8gojcTyk92sNl7eUU6XHOYgt6lFK6z3lyzk+nlP5Zc06MtiVDjK55ZwNhlJTSvd4Jewby13LOV1JK3/K3iPwnIpznrhl27px4H4bXm3Don1UGshB5eS6M/MI93DxcRKT6XM6Zv9Uwx/CzykALuFGFlYdOzvn8HOaY0YsXbdVYlYEiLsRQ4q6LCMD5sMWYnPOzLG4UAhyPx6IHRSM2r2/Gy1oDkUVY7L3m4s+LyDkR+ceBdRQqOec3U0o/+nE3UAy9EYYZHoE5XMMmMlnaZzaxsDnX8bJuxuImmGdilLw2Ax4yHDHQ6tRsd5hF8RnNZLZIPA+OgyHuNqH6oYgAyhjhDwPtH8Nnsx3js+q5IkJYbgLIuyBtnlC5uV043lHSbXyfg66I3CM8QmiBUQD2ZME97+2RypFnHsKTRgbiLnZd3gAb4+Epb5p3PGd/l8wF5ojIX24cS/vqYYESXE0pfW7eNAT73s05hHEUC5e+qJOFHLApL647sLrXAOR2ToyHt7lHOVFUUmjZb+JZc0R06VpP4/jEQHbhmq2M9ZJyFTPMGFdE5FcR+UBErtnL3H0w6XX7+xZs2c6BoTj2nB27a6/P1m+nsdiTnLNN8+odobYakr1ACMtC3bgi8o6IfG+GAMDxKgVee8+LKaXbay649eA1n9nne9aEmKdiLRlaYG0JnoUVBWzFY4JByWaXPTzNKzfrTYsGsgUUKtAwbBbbLRcMlPFAbYMYuHuRO1vXLbU9cs7vpJS+36enjM41ymIV1W+MUoVhPLFX7AGv8JQflrzE0vjFiHVzi98nuV0ycotBXiYsFZq0LKjPfrEvALB/E5GnROQBeGPpv9RoXroEnIrtjjNtacwZaW2IVQZzjzJ+MiwRQlhNyo/gZXNlyJmXH70sNrmoQAi10QUB9Lqpx1tyzs+IyL+Wxd4TkT8tg03AeGRkM275nqVQOK3jk3YHXxQq9pjCy53OObPom+GiNFxyzhdE5HnYM6GWUroTa72Yspf62Ke14F3POwLpqofTnjTgiVKAYNBJVmsbZ4P6azP9n3atIwNRR+FJnqLLwq3GguPwmrNiiCF1Fz+8TmOtrfY5DtOGhWvB2+FKszdm17u/j/f3QszD5ZOU0pdtZR85SiB/4FIpRGPLosUoMzDGuwTbNua+ahKyjwXveg76QaTk0inshBPe5PMs7rwbkFSP51CwkvKp0uk6au0WSxF73SlB4Tv23ZvsA7kdhh1F85yqJDDCF8Ea43qLlAr9sogQlrzmBe+kJ23eNhk0HpIArvUk7Sh65R24yYcppW9swXgGlTi/MQjegpE4rov3xr29D3zykuKi4VQVQls0xLDUmAuxECYsFMNci0370Ot5iTYsbBp+ZIZNa+ulLRtMQdqb5zFdxwzjk4rgYT4GKnN0C0k8yvvKbXsWLNOsF+9WmLRuZpIRr69ksZmZGFwHD+EHJu2pHZzRcLNM9INnPPOmV6IHdTqT3lmshgDhJmxidh8NNOxDN3e8ynqNYfESJhd4g+JObwDQgn0v/nPOioNrwfS03jdbrFoNxkK1JjIO40TQhQd6p5uSwr0DfkRIcQ6v2LvCh9Na4OOet/KgJf1OhyN5+MFxtGKnJyQiP1l220SYPI6RZsc+zmcCOSycJuANGQ4CWY10LIzck6oaLSg/dGx0VtKWNYbrTTUms/HOpJR6isqdtP7nltP0GiPMvac79pm7oz5qtga+i6H47cy525LNOb+UUqLreFQ/EYOKyitOMoNKY4InUfrSFLGlqW7ADqC/JSJ3zDowc9okBbDtXJe2kLm6PGiQajX1G//xgtR5DyWGZzAvaDXDGR6NZHpHBdyRScNvAGGdQhjIKmOOeqHQj45ED7ClB+SqjVjQfiYiX3tWMxI57B5sLf5WpfmoZh0INaNBkLvQ5/HR80Rec5ZqjV1vwJz8pRoINuEEcSTEVNfsQk37O14DzXvC9OaSYmxpWLjrwvb1/l5HsZLc+oTUWhhFhmcs27VCOkHtzbwG2KZ13JJqNuf8fhxR72vRu5ynZ6Ayw4rzdCsnYMmED+QQEKZ41ao95/xxSumrBr9URxReq8Y+W5O69AzXnayGBXGnMQb8Rb3Hjjn/AdhLurY+EHqgSko3EkAdA8GcqFzDNAKwxSB4yPWQvWi2w6QxHviiAinLehGswacLKaWbjVRv0sHcxeUP/V5P86VmCottBeI6t28v0CagvmhwCGrAZ6NQU2dnI031WWuAdio1Yng17VWAeJXoqcEhuo5Lu3y6xj+0t6zBoGrUbLhTNb+C0XQWFk7alg6TaWmv/x1btVswSHsNi+oOWxQq1pjGXW1WJL5hAgJGqbgzGHNzE9O1NyMyabCD8TD6wWpmZVkInIH38BtQ5rP8roRPYW4/krVEtdpOIva1i9rn+6KBNLxGjNaMxHfTwP/FQJcQ0+Gh66RdX22hQ432s3UaY7dgU/sxTgTSnrZF5F0RudGEjHYI8TYRuRX3WLR1Vi9DubBqqfzYpyec9FzV2CeGS8NdyiTDhU3GovV7g6YaZj2cb7XztZNe9CE/NypWwRnSOoy4wpJ2yBhSOp+hEUYJQtp28abWU1HguWXeM8liAwmd7t4x7Hjb2PSN4D0fWdjBbwB3PAcD8YOB4sR10oIl48W+k4XzJkVUzqRZ8Hdh2BenFzHreO3FeGey+aTjbdU277n9qlvdyzopVm1SoczXcOiqiFClA8hMTVXk6U00GwmhB8JgKooKhu5J9Dj1UPV6SHxZ813uQT1vIHRI/ZXKLIgNXBukbdqcM4b0UTHGLBvs2gtZkg+vufBDvccN1CrrwROOaUgZQeT/EEWqfAglfR6qfcKIpv11ai5XjZmBtO26tJgtgzYCKt2NY4tzWQuhgRFehee0IWEy4CfsQQEuonImDmDz/z8aAcPmt4D3bqRK8Dr8R8sJwxomqEjr7sQnJTSZqmwlCC3a1kBtK3fpeR9n3m5VTmdg3N3bHprwLvsFXHU6aqHE66V2yzm7Mta3UKmgKt6ZY2izxuvFg1wEVTSIo0Z5u42y3aHczPCrxry3XW0cNNsfWsKsQx7vijjNQ8AmKvVroZqnUAWoX4EoppQesP0AAUPjJVqaLD2U5Ch60iHr+BMX8CjCwvWG3hgrbdRQXuAlAPnvsYrvKeh7WmzGOiJye4sPFPA1lp50KC08tbNtAGIIzpDaqa18BzMYAzXQFmxohfDeuA9sUw8JOEloutI+qt0Z7+AZ/NN+chBLuTfxHn44TkGr5UhUznfI4fAZQSe58EN9ZsKkgzhcZbtxrOzVvYdfKEeURMYWaxtSYQg5u/XhUAtf+z1uIO0MmgE+TSl9EVL85Jli7R4wA21mZVFEBcAXpcjaC9ra+5woVsWjiZ7AnzdSSrQ5fO87oeUFaMtvXCukID+3h2xrRpi7Hscgwsnvtg/+ok4ID0OpQbX+N0brYAz9Z+1Vd44dlWiqIoqGI97s0papNb98WgoIe+rvKjJ6ou9eU+yYPKek+ZmHjET5HcqNz8N8HuNBFplYMFIeeojJZLqedQwGKw9Y6nX0mvSNwWjDInXxJ8CQummWud55szsHT3ozqn6QgTHhhGfoPrD4u2HQOz3b8NiK1BhirWTFSw7nQTr8Y0Nuu//LwlO9KBivSwh3fRTYSe/4vj8X52K+m7CahIYHselYJ46ljWG3WsZqu8KajuK+F7XP8/0Pwx3exhMOW2YAAAAASUVORK5CYII='
/>
</Defs>

File diff suppressed because one or more lines are too long

View file

@ -6,11 +6,7 @@ import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {map} from 'rxjs/operators';
import {Preferences} from '@constants';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeConfig, observeLicense} from '@queries/servers/system';
import {observeCurrentUser, observeUser} from '@queries/servers/user';
import {observeCurrentUser, observeTeammateNameDisplay, observeUser} from '@queries/servers/user';
import {displayUsername} from '@utils/user';
import PublicOrPrivateChannel from './public_or_private_channel';
@ -21,16 +17,10 @@ import type ChannelModel from '@typings/database/models/servers/channel';
const enhanced = withObservables([], ({channel, database}: {channel: ChannelModel} & WithDatabaseArgs) => {
let creator;
if (channel.creatorId) {
const config = observeConfig(database);
const license = observeLicense(database);
const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).
observeWithColumns(['value']);
const me = observeCurrentUser(database);
const profile = observeUser(database, channel.creatorId);
const teammateNameDisplay = combineLatest([preferences, config, license]).pipe(
map(([prefs, cfg, lcs]) => getTeammateNameDisplaySetting(prefs, cfg, lcs)),
);
const teammateNameDisplay = observeTeammateNameDisplay(database);
creator = combineLatest([profile, teammateNameDisplay, me]).pipe(
map(([user, displaySetting, currentUser]) => (user ? displayUsername(user, currentUser?.locale, displaySetting, true) : '')),

View file

@ -30,7 +30,7 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
distinctUntilChanged(),
);
const isInACall = currentCall.pipe(
switchMap((call) => of$(Boolean(call))),
switchMap((call) => of$(Boolean(call?.connected))),
distinctUntilChanged(),
);
const isInCurrentChannelCall = combineLatest([channelId, ccChannelId]).pipe(

View file

@ -8,7 +8,7 @@ import {switchMap} from 'rxjs/operators';
import {General} from '@constants';
import {observeProfileLongPresTutorial} from '@queries/app/global';
import {observeConfig, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeConfigValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import CreateDirectMessage from './create_direct_message';
@ -16,8 +16,8 @@ import CreateDirectMessage from './create_direct_message';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const restrictDirectMessage = observeConfig(database).pipe(
switchMap((cfg) => of$(cfg?.RestrictDirectMessage !== General.RESTRICT_DIRECT_MESSAGE_ANY)),
const restrictDirectMessage = observeConfigValue(database, 'RestrictDirectMessage').pipe(
switchMap((v) => of$(v !== General.RESTRICT_DIRECT_MESSAGE_ANY)),
);
return {

View file

@ -0,0 +1,19 @@
// 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 {observeIsCustomStatusExpirySupported} from '@queries/servers/system';
import CustomStatusSuggestion from './custom_status_suggestion';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
isExpirySupported: observeIsCustomStatusExpirySupported(database),
};
});
export default withDatabase(enhanced(CustomStatusSuggestion));

View file

@ -13,7 +13,6 @@ import CustomStatusSuggestion from './custom_status_suggestion';
type Props = {
intl: IntlShape;
isExpirySupported: boolean;
onHandleCustomStatusSuggestionClick: (status: UserCustomStatus) => void;
recentCustomStatuses: UserCustomStatus[];
theme: Theme;
@ -57,7 +56,6 @@ const defaultCustomStatusSuggestions: DefaultUserCustomStatus[] = [
const CustomStatusSuggestions = ({
intl,
isExpirySupported,
onHandleCustomStatusSuggestionClick,
recentCustomStatuses,
theme,
@ -81,7 +79,6 @@ const CustomStatusSuggestions = ({
theme={theme}
separator={index !== arr.length - 1}
duration={status.duration}
isExpirySupported={isExpirySupported}
/>
));

View file

@ -10,7 +10,6 @@ import CustomStatusSuggestion from '@screens/custom_status/components/custom_sta
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
isExpirySupported: boolean;
onHandleClear: (status: UserCustomStatus) => void;
onHandleSuggestionClick: (status: UserCustomStatus) => void;
recentCustomStatuses: UserCustomStatus[];
@ -38,7 +37,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const RecentCustomStatuses = ({isExpirySupported, onHandleClear, onHandleSuggestionClick, recentCustomStatuses, theme}: Props) => {
const RecentCustomStatuses = ({onHandleClear, onHandleSuggestionClick, recentCustomStatuses, theme}: Props) => {
const style = getStyleSheet(theme);
if (recentCustomStatuses.length === 0) {
@ -66,7 +65,6 @@ const RecentCustomStatuses = ({isExpirySupported, onHandleClear, onHandleSuggest
separator={index !== recentCustomStatuses.length - 1}
duration={status.duration}
expires_at={status.expires_at}
isExpirySupported={isExpirySupported}
/>
))}
</View>

View file

@ -9,8 +9,6 @@ import {injectIntl, IntlShape} from 'react-intl';
import {BackHandler, DeviceEventEmitter, Keyboard, KeyboardAvoidingView, Platform, ScrollView, View} from 'react-native';
import {EventSubscription, Navigation, NavigationButtonPressedEvent, NavigationComponent, NavigationComponentProps} from 'react-native-navigation';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {updateLocalCustomStatus} from '@actions/local/user';
import {removeRecentCustomStatus, updateCustomStatus, unsetCustomStatus} from '@actions/remote/user';
@ -20,11 +18,11 @@ import {Events, Screens} from '@constants';
import {CustomStatusDurationEnum, SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status';
import {withServerUrl} from '@context/server';
import {withTheme} from '@context/theme';
import {observeConfig, observeRecentCustomStatus} from '@queries/servers/system';
import {observeIsCustomStatusExpirySupported, observeRecentCustomStatus} from '@queries/servers/system';
import {observeCurrentUser} from '@queries/servers/user';
import {dismissModal, goToScreen, showModal} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {getCurrentMomentForTimezone, getRoundedTime, isCustomStatusExpirySupported} from '@utils/helpers';
import {getCurrentMomentForTimezone, getRoundedTime} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {mergeNavigationOptions} from '@utils/navigation';
import {preventDoubleTap} from '@utils/tap';
@ -376,7 +374,6 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
</View>
{recentCustomStatuses.length > 0 && (
<RecentCustomStatuses
isExpirySupported={customStatusExpirySupported}
onHandleClear={this.handleRecentCustomStatusClear}
onHandleSuggestionClick={this.handleRecentCustomStatusSuggestionClick}
recentCustomStatuses={recentCustomStatuses}
@ -386,7 +383,6 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
}
<CustomStatusSuggestions
intl={intl}
isExpirySupported={customStatusExpirySupported}
onHandleCustomStatusSuggestionClick={this.handleCustomStatusSuggestionClick}
recentCustomStatuses={recentCustomStatuses}
theme={theme}
@ -404,13 +400,10 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
const augmentCSM = injectIntl(withTheme(withServerUrl(CustomStatusModal)));
const enhancedCSM = withObservables([], ({database}: WithDatabaseArgs) => {
const config = observeConfig(database);
return {
currentUser: observeCurrentUser(database),
customStatusExpirySupported: config.pipe(
switchMap((cfg) => of$(isCustomStatusExpirySupported(cfg?.Version || ''))),
),
recentCustomStatuses: observeRecentCustomStatus(database),
customStatusExpirySupported: observeIsCustomStatusExpirySupported(database),
};
});

Some files were not shown because too many files have changed in this diff Show more