Merge branch 'main' into i18n-vi

This commit is contained in:
Mattermost Build 2023-10-05 09:38:58 +02:00 committed by GitHub
commit 3d3934e37b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
286 changed files with 21219 additions and 7876 deletions

View file

@ -61,6 +61,7 @@
"afterColon": true
}}],
"@typescript-eslint/member-delimiter-style": 2,
"@typescript-eslint/no-unsafe-declaration-merging": "off",
"import/order": [
2,
{

View file

@ -7,7 +7,7 @@ runs:
- name: ci/setup-node
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
with:
node-version: "${{ env.NODE_VERSION }}"
node-version-file: ".nvmrc"
cache: "npm"
cache-dependency-path: package-lock.json

View file

@ -1 +1 @@
18.7.0
18.17

1
.npmrc
View file

@ -1 +1,2 @@
save-exact=true
engine-strict=true

2
.nvmrc
View file

@ -1 +1 @@
18.7.0
18.17

View file

@ -4,29 +4,11 @@
"output" : "moderate"
},
"requirements": {
"Node": [
{
"rule": "cli",
"binary": "node",
"semver": ">=16.0.0",
"error": "install node using nvm https://github.com/nvm-sh/nvm#installing-and-updating"
},
{
"rule": "cli",
"binary": "npm",
"semver": ">=7.24.0 <9.0.0",
"error": "install npm 8.5.5 `npm i -g npm@8.5.5"
}
],
"Android": [
{
"rule": "cli",
"binary": "emulator"
},
{
"rule": "cli",
"binary": "android"
},
{
"rule": "env",
"variable": "ANDROID_HOME",
@ -57,7 +39,7 @@
{
"rule": "cli",
"binary": "pod",
"semver": "1.11.3",
"semver": "1.12.1",
"platform": "darwin"
}
],

View file

@ -484,6 +484,48 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @react-native/eslint-config
This product contains '@react-native/eslint-config' by Meta.
ESLint config for React Native
* HOMEPAGE:
* https://github.com/facebook/react-native/tree/HEAD/packages/eslint-config-react-native-community#readme
* LICENSE: MIT
---
## @react-native/metro-config
This product contains '@react-native/metro-config' by Meta.
Config resolver and transformer for Metro.
* HOMEPAGE:
* https://github.com/facebook/react-native#readme
* LICENSE: MIT
---
## @tsconfig/react-native
This product contains '@tsconfig/react-native' by TSC Base.
Hosts TSConfigs for you to extend in your apps, tuned to a particular runtime environment. Owned and improved by the community. Basically Definitely Typed for TSConfigs.
* HOMEPAGE:
* https://github.com/tsconfig/bases#readme
* LICENSE: MIT
---
## @react-navigation/bottom-tabs
@ -554,42 +596,6 @@ Stack navigator component for iOS and Android with animated transitions and gest
---
## @rudderstack/rudder-sdk-react-native
This product contains '@rudderstack/rudder-sdk-react-native' by RudderStack.
Rudder React Native SDK
* HOMEPAGE:
* https://github.com/rudderlabs/rudder-sdk-reactnative#readme
* LICENSE: Apache-2.0
MIT License
Copyright (c) 2021 RudderStack
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @sentry/react-native

View file

@ -1,7 +1,7 @@
# Mattermost Mobile v2
- **Minimum Server versions:** Current ESR version (7.8.0+)
- **Supported iOS versions:** 12.1+
- **Supported iOS versions:** 13.4+
- **Supported Android versions:** 7.0+
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 21 languages. Learn more at [https://mattermost.com](https://mattermost.com).

View file

@ -110,8 +110,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 479
versionName "2.6.0"
versionCode 487
versionName "2.9.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}

View file

@ -52,6 +52,7 @@ public class CustomPushNotificationHelper {
public static final String PUSH_TYPE_MESSAGE = "message";
public static final String PUSH_TYPE_CLEAR = "clear";
public static final String PUSH_TYPE_SESSION = "session";
public static final String CATEGORY_CAN_REPLY = "CAN_REPLY";
private static NotificationChannel mHighImportanceChannel;
private static NotificationChannel mMinImportanceChannel;
@ -132,8 +133,9 @@ public class CustomPushNotificationHelper {
private static void addNotificationReplyAction(Context context, NotificationCompat.Builder notification, Bundle bundle, int notificationId) {
String postId = bundle.getString("post_id");
String serverUrl = bundle.getString("server_url");
boolean canReply = bundle.containsKey("category") && bundle.getString("category").equals(CATEGORY_CAN_REPLY);
if (android.text.TextUtils.isEmpty(postId) || serverUrl == null) {
if (android.text.TextUtils.isEmpty(postId) || serverUrl == null || !canReply) {
return;
}

View file

@ -221,11 +221,6 @@ public class MattermostManagedModule extends ReactContextBaseJavaModule {
}
}
@ReactMethod
public void invalidateKeychainCache(String serverUrl) {
// Not using cache
}
private static class SaveDataTask extends GuardedResultAsyncTask<Object> {
private final WeakReference<Context> weakContext;
private final String fromFile;

View file

@ -16,7 +16,7 @@
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material">
<selector>
<!--
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'

View file

@ -29,7 +29,7 @@ android.useAndroidX=true
android.enableJetifier=true
# Version of flipper SDK to use with React Native
FLIPPER_VERSION=0.177.0
FLIPPER_VERSION=0.182.0
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using

View file

@ -4,6 +4,7 @@
import {Tutorial} from '@constants';
import {GLOBAL_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl} from '@init/credentials';
import {logError} from '@utils/log';
export const storeGlobal = async (id: string, value: unknown, prepareRecordsOnly = false) => {
@ -50,3 +51,29 @@ export const storeLastAskForReview = async (prepareRecordsOnly = false) => {
export const storeFirstLaunch = async (prepareRecordsOnly = false) => {
return storeGlobal(GLOBAL_IDENTIFIERS.FIRST_LAUNCH, Date.now(), prepareRecordsOnly);
};
export const storeLastViewedChannelIdAndServer = async (channelId: string) => {
const currentServerUrl = await getActiveServerUrl();
return storeGlobal(GLOBAL_IDENTIFIERS.LAST_VIEWED_CHANNEL, {
server_url: currentServerUrl,
channel_id: channelId,
}, false);
};
export const storeLastViewedThreadIdAndServer = async (threadId: string) => {
const currentServerUrl = await getActiveServerUrl();
return storeGlobal(GLOBAL_IDENTIFIERS.LAST_VIEWED_THREAD, {
server_url: currentServerUrl,
thread_id: threadId,
}, false);
};
export const removeLastViewedChannelIdAndServer = async () => {
return storeGlobal(GLOBAL_IDENTIFIERS.LAST_VIEWED_CHANNEL, null, false);
};
export const removeLastViewedThreadIdAndServer = async () => {
return storeGlobal(GLOBAL_IDENTIFIERS.LAST_VIEWED_THREAD, null, false);
};

View file

@ -20,11 +20,14 @@ export async function setCurrentUserStatus(serverUrl: string, status: string) {
throw new Error(`No current user for ${serverUrl}`);
}
user.prepareStatus(status);
await operator.batchRecords([user], 'setCurrentUserStatusOffline');
if (user.status !== status) {
user.prepareStatus(status);
await operator.batchRecords([user], 'setCurrentUserStatus');
}
return null;
} catch (error) {
logError('Failed setCurrentUserStatusOffline', error);
logError('Failed setCurrentUserStatus', error);
return {error};
}
}

View file

@ -33,7 +33,7 @@ import {displayGroupMessageName, displayUsername} from '@utils/user';
import {fetchGroupsForChannelIfConstrained} from './groups';
import {fetchPostsForChannel} from './post';
import {setDirectChannelVisible} from './preference';
import {openChannelIfNeeded, savePreference} from './preference';
import {fetchRolesIfNeeded} from './role';
import {forceLogoutIfNecessary} from './session';
import {addCurrentUserToTeam, fetchTeamByName, removeCurrentUserFromTeam} from './team';
@ -764,6 +764,7 @@ export async function createDirectChannel(serverUrl: string, userId: string, dis
const channelName = getDirectChannelName(currentUser.id, userId);
const channel = await getChannelByName(database, '', channelName);
if (channel) {
openChannelIfNeeded(serverUrl, channel.id);
return {data: channel.toApi()};
}
@ -798,6 +799,16 @@ export async function createDirectChannel(serverUrl: string, userId: string, dis
};
const models = [];
const preferences = [
{user_id: currentUser.id, category: Preferences.CATEGORIES.DIRECT_CHANNEL_SHOW, name: userId, value: 'true'},
{user_id: currentUser.id, category: Preferences.CATEGORIES.CHANNEL_OPEN_TIME, name: created.id, value: new Date().getTime().toString()},
];
const preferenceModels = await savePreference(serverUrl, preferences, true);
if (preferenceModels.preferences?.length) {
models.push(...preferenceModels.preferences);
}
const channelPromises = await prepareMyChannelsForTeam(operator, '', [created], [member, {...member, user_id: userId}]);
if (channelPromises.length) {
const channelModels = await Promise.all(channelPromises);
@ -893,14 +904,15 @@ export async function createGroupChannel(serverUrl: string, userIds: string[]) {
// Check the channel previous existency: if the channel already have
// posts is because it existed before.
if (created.total_msg_count > 0) {
openChannelIfNeeded(serverUrl, created.id);
EphemeralStore.creatingChannel = false;
return {data: created};
}
const preferences = await queryDisplayNamePreferences(database, Preferences.NAME_NAME_FORMAT).fetch();
const displayNamePreferences = await queryDisplayNamePreferences(database, 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 teammateDisplayNameSetting = getTeammateNameDisplaySetting(displayNamePreferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
const {directChannels, users} = await fetchMissingDirectChannelsInfo(serverUrl, [created], currentUser.locale, teammateDisplayNameSetting, currentUser.id, true);
const member = {
@ -931,6 +943,15 @@ export async function createGroupChannel(serverUrl: string, userIds: string[]) {
models.push(...categoryModels.models);
}
const preferences = [
{user_id: currentUser.id, category: Preferences.CATEGORIES.GROUP_CHANNEL_SHOW, name: created.id, value: 'true'},
{user_id: currentUser.id, category: Preferences.CATEGORIES.CHANNEL_OPEN_TIME, name: created.id, value: new Date().getTime().toString()},
];
const preferenceModels = await savePreference(serverUrl, preferences, true);
if (preferenceModels.preferences?.length) {
models.push(...preferenceModels.preferences);
}
models.push(...userModels);
operator.batchRecords(models, 'createGroupChannel');
}
@ -1012,7 +1033,7 @@ export async function switchToChannelById(serverUrl: string, channelId: string,
fetchPostsForChannel(serverUrl, channelId);
await switchToChannel(serverUrl, channelId, teamId, skipLastUnread);
setDirectChannelVisible(serverUrl, channelId);
openChannelIfNeeded(serverUrl, channelId);
markChannelAsRead(serverUrl, channelId);
fetchChannelStats(serverUrl, channelId);
fetchGroupsForChannelIfConstrained(serverUrl, channelId);

View file

@ -9,11 +9,9 @@ import {addPostAcknowledgement, removePost, removePostAcknowledgement, storePost
import {addRecentReaction} from '@actions/local/reactions';
import {createThreadFromNewPost} from '@actions/local/thread';
import {ActionType, General, Post, ServerErrors} from '@constants';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {filterPostsInOrderedArray} from '@helpers/api/post';
import {getNeededAtMentionedUsernames} from '@helpers/api/user';
import {extractRecordsForTable} from '@helpers/database';
import NetworkManager from '@managers/network_manager';
import {getMyChannel, prepareMissingChannelsForAllTeams, queryAllMyChannel} from '@queries/servers/channel';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
@ -378,7 +376,10 @@ export async function fetchPosts(serverUrl: string, channelId: string, page = 0,
models.push(...threadModels);
}
}
await operator.batchRecords(models, 'fetchPosts');
if (models.length) {
await operator.batchRecords(models, 'fetchPosts');
}
}
return result;
} catch (error) {
@ -597,7 +598,7 @@ export async function fetchPostThread(serverUrl: string, postId: string, options
await operator.batchRecords(models, 'fetchPostThread');
}
setFetchingThreadState(postId, false);
return {posts: extractRecordsForTable<PostModel>(posts, MM_TABLES.SERVER.POST)};
return {posts: result.posts};
} catch (error) {
logDebug('error on fetchPostThread', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
@ -665,7 +666,7 @@ export async function fetchPostsAround(serverUrl: string, channelId: string, pos
await operator.batchRecords(models, 'fetchPostsAround');
}
return {posts: extractRecordsForTable<PostModel>(posts, MM_TABLES.SERVER.POST)};
return {posts: data.posts};
} catch (error) {
logDebug('error on fetchPostsAround', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);

View file

@ -6,18 +6,22 @@ import {DeviceEventEmitter} from 'react-native';
import {handleReconnect} from '@actions/websocket';
import {Events, General, Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreferenceAsBool} from '@helpers/api/preference';
import NetworkManager from '@managers/network_manager';
import {getChannelById} from '@queries/servers/channel';
import {queryAllUnreadDMsAndGMsIds, getChannelById} from '@queries/servers/channel';
import {truncateCrtRelatedTables} from '@queries/servers/entry';
import {querySavedPostsPreferences} from '@queries/servers/preference';
import {queryPreferencesByCategoryAndName, querySavedPostsPreferences} from '@queries/servers/preference';
import {getCurrentUserId} from '@queries/servers/system';
import EphemeralStore from '@store/ephemeral_store';
import {isDMorGM} from '@utils/channel';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {getUserIdFromChannelName} from '@utils/user';
import {forceLogoutIfNecessary} from './session';
import type ChannelModel from '@typings/database/models/servers/channel';
export type MyPreferencesRequest = {
preferences?: PreferenceType[];
error?: unknown;
@ -79,19 +83,23 @@ export const savePostPreference = async (serverUrl: string, postId: string) => {
}
};
export const savePreference = async (serverUrl: string, preferences: PreferenceType[]) => {
export const savePreference = async (serverUrl: string, preferences: PreferenceType[], prepareRecordsOnly = false) => {
try {
if (!preferences.length) {
return {preferences: []};
}
const client = NetworkManager.getClient(serverUrl);
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const userId = await getCurrentUserId(database);
client.savePreferences(userId, preferences);
await operator.handlePreferences({
const preferenceModels = await operator.handlePreferences({
preferences,
prepareRecordsOnly: false,
prepareRecordsOnly,
});
return {preferences};
return {preferences: preferenceModels};
} catch (error) {
logDebug('error on savePreference', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
@ -128,6 +136,70 @@ export const deleteSavedPost = async (serverUrl: string, postId: string) => {
}
};
export const openChannelIfNeeded = async (serverUrl: string, channelId: string) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const channel = await getChannelById(database, channelId);
if (!channel || !isDMorGM(channel)) {
return {};
}
const res = await openChannels(serverUrl, [channel]);
return res;
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const openAllUnreadChannels = async (serverUrl: string) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const channels = await queryAllUnreadDMsAndGMsIds(database).fetch();
const res = await openChannels(serverUrl, channels);
return res;
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
const openChannels = async (serverUrl: string, channels: ChannelModel[]) => {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const userId = await getCurrentUserId(database);
const {DIRECT_CHANNEL_SHOW, GROUP_CHANNEL_SHOW} = Preferences.CATEGORIES;
const directChannelShowPreferences = await queryPreferencesByCategoryAndName(database, DIRECT_CHANNEL_SHOW).fetch();
const groupChannelShowPreferences = await queryPreferencesByCategoryAndName(database, GROUP_CHANNEL_SHOW).fetch();
const showPreferences = directChannelShowPreferences.concat(groupChannelShowPreferences);
const prefs: PreferenceType[] = [];
for (const channel of channels) {
const category = channel.type === General.DM_CHANNEL ? DIRECT_CHANNEL_SHOW : GROUP_CHANNEL_SHOW;
const name = channel.type === General.DM_CHANNEL ? getUserIdFromChannelName(userId, channel.name) : channel.id;
const visible = getPreferenceAsBool(showPreferences, category, name, false);
if (visible) {
continue;
}
prefs.push(
{
user_id: userId,
category,
name,
value: 'true',
},
{
user_id: userId,
category: Preferences.CATEGORIES.CHANNEL_OPEN_TIME,
name: channel.id,
value: Date.now().toString(),
},
);
}
return savePreference(serverUrl, prefs);
};
export const setDirectChannelVisible = async (serverUrl: string, channelId: string, visible = true) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);

View file

@ -294,14 +294,19 @@ const debouncedFetchUserOrGroupsByMentionNames = debounce(
},
);
const notFoundMentions: {[serverUrl: string]: Set<string>} = {};
const fetchUserOrGroupsByMentionNames = async (serverUrl: string, mentions: string[]) => {
try {
if (!notFoundMentions[serverUrl]) {
notFoundMentions[serverUrl] = new Set();
}
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
// Get any missing users
const usersInDb = await queryUsersByIdsOrUsernames(database, [], mentions).fetch();
const usersMap = new Set(usersInDb.map((u) => u.username));
const usernamesToFetch = mentions.filter((m) => !usersMap.has(m));
const usernamesToFetch = mentions.filter((m) => !usersMap.has(m) && !notFoundMentions[serverUrl].has(m));
let fetchedUsers;
if (usernamesToFetch.length) {
@ -317,7 +322,15 @@ const fetchUserOrGroupsByMentionNames = async (serverUrl: string, mentions: stri
const groupsToFetch = groupsToCheck.filter((g) => !groupsMap.has(g));
if (groupsToFetch.length) {
await fetchGroupsByNames(serverUrl, groupsToFetch, false);
const results = await fetchGroupsByNames(serverUrl, groupsToFetch, false);
if (!('error' in results)) {
const retrievedSet = new Set(results.map((r) => r.name));
for (const g of groupsToFetch) {
if (!retrievedSet.has(g)) {
notFoundMentions[serverUrl].add(g);
}
}
}
}
return {};
} catch (error) {
@ -344,12 +357,19 @@ export async function fetchStatusByIds(serverUrl: string, userIds: string[], fet
return result;
}, {});
const usersToBatch = [];
for (const user of users) {
const status = userStatuses[user.id];
user.prepareStatus(status?.status || General.OFFLINE);
const receivedStatus = userStatuses[user.id];
const statusToSet = receivedStatus?.status || General.OFFLINE;
if (statusToSet !== user.status) {
user.prepareStatus(statusToSet);
usersToBatch.push(user);
}
}
await operator.batchRecords(users, 'fetchStatusByIds');
if (usersToBatch.length) {
await operator.batchRecords(usersToBatch, 'fetchStatusByIds');
}
}
return {statuses};
@ -360,6 +380,48 @@ export async function fetchStatusByIds(serverUrl: string, userIds: string[], fet
}
}
let usersByIdBatch: {
serverUrl: string;
userIds: Set<string>;
timeout?: NodeJS.Timeout;
} | undefined;
const TIME_TO_BATCH = 500;
const processBatch = () => {
if (!usersByIdBatch) {
return;
}
if (usersByIdBatch.timeout) {
clearTimeout(usersByIdBatch.timeout);
}
if (usersByIdBatch.userIds.size) {
fetchUsersByIds(usersByIdBatch.serverUrl, Array.from(usersByIdBatch.userIds));
}
usersByIdBatch = undefined;
};
export const fetchUserByIdBatched = async (serverUrl: string, userId: string) => {
if (serverUrl !== usersByIdBatch?.serverUrl) {
processBatch();
}
if (!usersByIdBatch) {
usersByIdBatch = {
serverUrl,
userIds: new Set(),
};
}
if (usersByIdBatch.timeout) {
clearTimeout(usersByIdBatch.timeout);
}
usersByIdBatch.userIds.add(userId);
usersByIdBatch.timeout = setTimeout(processBatch, TIME_TO_BATCH);
};
export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetchOnly = false) => {
if (!userIds.length) {
return {users: [], existingUsers: []};

View file

@ -18,6 +18,7 @@ import {deleteChannelMembership, getChannelById, prepareMyChannelsForTeam, getCu
import {getConfig, getCurrentChannelId} from '@queries/servers/system';
import {getCurrentUser, getTeammateNameDisplay, getUserById} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import MyChannelModel from '@typings/database/models/servers/my_channel';
import {logDebug} from '@utils/log';
import type {Model} from '@nozbe/watermelondb';
@ -122,6 +123,43 @@ export async function handleChannelViewedEvent(serverUrl: string, msg: any) {
}
}
export async function handleMultipleChannelsViewedEvent(serverUrl: string, msg: any) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const {channel_times: channelTimes} = msg.data;
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
const currentChannelId = await getCurrentChannelId(database);
const promises: Array<ReturnType<typeof markChannelAsViewed>> = [];
for (const id of Object.keys(channelTimes)) {
if (activeServerUrl === serverUrl && (currentChannelId === id || EphemeralStore.isSwitchingToChannel(id))) {
continue;
}
promises.push(markChannelAsViewed(serverUrl, id, false, true));
}
const members = (await Promise.allSettled(promises)).reduce<MyChannelModel[]>((acum, v) => {
if (v.status === 'rejected') {
return acum;
}
const value = v.value;
if (value.member) {
acum.push(value.member);
}
return acum;
}, []);
if (members.length) {
operator.batchRecords(members, 'handleMultipleCahnnelViewedEvent');
}
} catch {
// do nothing
}
}
// This event is triggered by changes in the notify props or in the roles.
export async function handleChannelMemberUpdatedEvent(serverUrl: string, msg: any) {
try {

View file

@ -7,6 +7,7 @@ import {markChannelAsRead} from '@actions/remote/channel';
import {handleEntryAfterLoadNavigation, registerDeviceToken} from '@actions/remote/entry/common';
import {deferredAppEntryActions, entry} from '@actions/remote/entry/gql_common';
import {fetchPostsForChannel, fetchPostThread} from '@actions/remote/post';
import {openAllUnreadChannels} from '@actions/remote/preference';
import {autoUpdateTimezone} from '@actions/remote/user';
import {loadConfigAndCalls} from '@calls/actions/calls';
import {
@ -27,6 +28,7 @@ import {
handleCallUserUnraiseHand,
handleCallUserVoiceOff,
handleCallUserVoiceOn,
handleUserDismissedNotification,
} from '@calls/connection/websocket_event_handlers';
import {isSupportedServerCalls} from '@calls/utils';
import {Screens, WebsocketEvents} from '@constants';
@ -57,6 +59,7 @@ import {handleChannelConvertedEvent, handleChannelCreatedEvent,
handleChannelUnarchiveEvent,
handleChannelUpdatedEvent,
handleChannelViewedEvent,
handleMultipleChannelsViewedEvent,
handleDirectAddedEvent,
handleUserAddedToChannelEvent,
handleUserRemovedFromChannelEvent} from './channel';
@ -152,6 +155,8 @@ async function doReconnect(serverUrl: string) {
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId);
openAllUnreadChannels(serverUrl);
dataRetentionCleanup(serverUrl);
AppsManager.refreshAppBindings(serverUrl);
@ -251,6 +256,10 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
handleChannelViewedEvent(serverUrl, msg);
break;
case WebsocketEvents.MULTIPLE_CHANNELS_VIEWED:
handleMultipleChannelsViewedEvent(serverUrl, msg);
break;
case WebsocketEvents.CHANNEL_MEMBER_UPDATED:
handleChannelMemberUpdatedEvent(serverUrl, msg);
break;
@ -384,6 +393,9 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
case WebsocketEvents.CALLS_HOST_CHANGED:
handleCallHostChanged(serverUrl, msg);
break;
case WebsocketEvents.CALLS_USER_DISMISSED_NOTIFICATION:
handleUserDismissedNotification(serverUrl, msg);
break;
case WebsocketEvents.GROUP_RECEIVED:
handleGroupReceivedEvent(serverUrl, msg);

View file

@ -8,6 +8,7 @@ import {addPostAcknowledgement, markPostAsDeleted, removePostAcknowledgement} fr
import {createThreadFromNewPost, updateThread} from '@actions/local/thread';
import {fetchChannelStats, fetchMyChannel} from '@actions/remote/channel';
import {fetchPostAuthors, fetchPostById} from '@actions/remote/post';
import {openChannelIfNeeded} from '@actions/remote/preference';
import {fetchThread} from '@actions/remote/thread';
import {fetchMissingProfilesByIds} from '@actions/remote/user';
import {ActionType, Events, Screens} from '@constants';
@ -157,6 +158,8 @@ export async function handleNewPostEvent(serverUrl: string, msg: WebSocketMessag
models.push(unreadAt);
}
}
openChannelIfNeeded(serverUrl, post.channel_id);
}
let actionType: string = ActionType.POSTS.RECEIVED_NEW;

View file

@ -14,7 +14,6 @@ type PoliciesResponse<T> = {
}
export interface ClientGeneralMix {
getOpenGraphMetadata: (url: string) => Promise<any>;
ping: (deviceId?: string, timeoutInterval?: number) => Promise<any>;
logClientError: (message: string, level?: string) => Promise<any>;
getClientConfigOld: () => Promise<ClientConfig>;
@ -28,13 +27,6 @@ export interface ClientGeneralMix {
}
const ClientGeneral = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
getOpenGraphMetadata = async (url: string) => {
return this.doFetch(
`${this.urlVersion}/opengraph`,
{method: 'post', body: {url}},
);
};
ping = async (deviceId?: string, timeoutInterval?: number) => {
let url = `${this.urlVersion}/system/ping?time=${Date.now()}`;
if (deviceId) {

View file

@ -136,6 +136,14 @@ export default class WebSocketClient {
// Check again if the client is the same, to avoid race conditions
if (this.conn === client) {
// In case turning on/off Wi-fi on Samsung devices
// the websocket will call onClose then onError then initialize again with readyState CLOSED, we need to open it again
if (this.conn.readyState === WebSocketReadyState.CLOSED) {
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
}
this.conn.open();
}
return;
}
this.conn = client;

View file

@ -42,7 +42,7 @@ const getMatchTermForAtMention = (() => {
return (value: string, isSearch: boolean) => {
if (value !== lastValue || isSearch !== lastIsSearch) {
const regex = isSearch ? AT_MENTION_SEARCH_REGEX : AT_MENTION_REGEX;
let term = value;
let term = value.toLowerCase();
if (term.startsWith('from: @') || term.startsWith('from:@')) {
term = term.replace('@', '');
}

View file

@ -35,7 +35,7 @@ const CopyChannelLinkOption = ({channelName, teamName, showAsLabel, testID}: Pro
<SlideUpPanelItem
onPress={onCopyLink}
text={intl.formatMessage({id: 'channel_info.copy_link', defaultMessage: 'Copy Link'})}
icon='link-variant'
leftIcon='link-variant'
testID={testID}
/>
);

View file

@ -45,7 +45,7 @@ const InfoBox = ({channelId, containerStyle, showAsLabel = false, testID}: Props
if (showAsLabel) {
return (
<SlideUpPanelItem
icon='information-outline'
leftIcon='information-outline'
onPress={onViewInfo}
testID={testID}
text={intl.formatMessage({id: 'channel_header.info', defaultMessage: 'View info'})}

View file

@ -174,7 +174,7 @@ const LeaveChannelLabel = ({canLeave, channelId, displayName, isOptionItem, type
return (
<SlideUpPanelItem
destructive={true}
icon={icon}
leftIcon={icon}
onPress={onLeave}
text={leaveText}
testID={testID}

View file

@ -1,10 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useEffect} from 'react';
import {fetchUserByIdBatched} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import ProfilePicture from '@components/profile_picture';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -12,6 +14,7 @@ import type UserModel from '@typings/database/models/servers/user';
import type {StyleProp, ViewStyle} from 'react-native';
type Props = {
authorId: string;
author?: UserModel;
isOnCenterBg?: boolean;
style: StyleProp<ViewStyle>;
@ -35,10 +38,22 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const DmAvatar = ({author, isOnCenterBg, style, size}: Props) => {
const DmAvatar = ({
authorId,
author,
isOnCenterBg,
style,
size,
}: Props) => {
const theme = useTheme();
const serverUrl = useServerUrl();
const styles = getStyleSheet(theme);
useEffect(() => {
if (authorId && !author) {
fetchUserByIdBatched(serverUrl, authorId);
}
}, []);
if (author?.deleteAt) {
return (
<CompassIcon

View file

@ -28,6 +28,7 @@ const enhance = withObservables(['channelName'], ({channelName, database}: {chan
);
return {
authorId,
author,
};
});

View file

@ -44,7 +44,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.24),
borderRadius: 4,

View file

@ -43,6 +43,9 @@ const styles = StyleSheet.create({
failed: {
opacity: 0.5,
},
marginTop: {
marginTop: 10,
},
});
const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, location, postId, publicLinkEnabled}: FilesProps) => {
@ -86,7 +89,7 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l
}
return (
<View
style={container}
style={[container, styles.marginTop]}
key={file.id}
>
<File

View file

@ -2,10 +2,11 @@
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import {FlatList, type ListRenderItemInfo, type StyleProp, type ViewStyle} from 'react-native';
import {FlatList, type ListRenderItemInfo, type StyleProp, type ViewStyle, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import NoResults from '@components/files_search/no_results';
import FormattedText from '@components/formatted_text';
import NoResultsWithTerm from '@components/no_results_with_term';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
@ -20,6 +21,8 @@ import {
import {openGalleryAtIndex} from '@utils/gallery';
import {TabTypes} from '@utils/search';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {showMobileOptionsBottomSheet} from './file_options/mobile_options';
import Toasts from './file_options/toasts';
@ -28,6 +31,14 @@ import FileResult from './file_result';
import type ChannelModel from '@typings/database/models/servers/channel';
import type {GalleryAction} from '@typings/screens/gallery';
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
resultsNumber: {
...typography('Heading', 300),
padding: 20,
color: theme.centerChannelColor,
},
}));
type Props = {
canDownloadFiles: boolean;
fileChannels: ChannelModel[];
@ -41,6 +52,9 @@ type Props = {
const galleryIdentifier = 'search-files-location';
const separatorStyle = {height: 10};
const Separator = () => <View style={separatorStyle}/>;
const FileResults = ({
canDownloadFiles,
fileChannels,
@ -52,13 +66,14 @@ const FileResults = ({
isFilterEnabled,
}: Props) => {
const theme = useTheme();
const styles = getStyles(theme);
const insets = useSafeAreaInsets();
const isTablet = useIsTablet();
const [action, setAction] = useState<GalleryAction>('none');
const [lastViewedFileInfo, setLastViewedFileInfo] = useState<FileInfo | undefined>(undefined);
const containerStyle = useMemo(() => ([paddingTop, {top: fileInfos.length ? 8 : 0, flexGrow: 1}]), [fileInfos, paddingTop]);
const containerStyle = useMemo(() => ([paddingTop, {flexGrow: 1}]), [paddingTop]);
const numOptions = getNumberFileMenuOptions(canDownloadFiles, publicLinkEnabled);
const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(fileInfos, publicLinkEnabled);
@ -141,6 +156,15 @@ const FileResults = ({
return (
<>
<FlatList
ListHeaderComponent={
<FormattedText
style={styles.resultsNumber}
id='mobile.search.results'
defaultMessage='{count} search {count, plural, one {result} other {results}}'
values={{count: orderedFileInfos.length}}
/>
}
ItemSeparatorComponent={Separator}
ListEmptyComponent={noResults}
contentContainerStyle={containerStyle}
data={orderedFileInfos}

View file

@ -167,7 +167,7 @@ const AtMention = ({
style={style.bottomSheet}
>
<SlideUpPanelItem
icon='content-copy'
leftIcon='content-copy'
onPress={() => {
dismissBottomSheet();
let username = mentionName;
@ -182,7 +182,7 @@ const AtMention = ({
/>
<SlideUpPanelItem
destructive={true}
icon='cancel'
leftIcon='cancel'
onPress={() => {
dismissBottomSheet();
}}

View file

@ -5,7 +5,7 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeConfigBooleanValue, observeConfigIntValue} from '@queries/servers/system';
import Markdown from './markdown';
@ -14,11 +14,13 @@ import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const enableLatex = observeConfigBooleanValue(database, 'EnableLatex');
const enableInlineLatex = observeConfigBooleanValue(database, 'EnableInlineLatex');
const maxNodes = observeConfigIntValue(database, 'MaxMarkdownNodes');
return {
enableLatex,
enableInlineLatex,
maxNodes,
};
});
export default React.memo(withDatabase(enhanced(Markdown)));
export default withDatabase(enhanced(React.memo(Markdown)));

View file

@ -62,6 +62,7 @@ type MarkdownProps = {
layoutHeight?: number;
layoutWidth?: number;
location: string;
maxNodes: number;
mentionKeys?: UserMentionKey[];
minimumHashtagLength?: number;
onPostPress?: (event: GestureResponderEvent) => void;
@ -69,7 +70,7 @@ type MarkdownProps = {
searchPatterns?: SearchPattern[];
textStyles?: MarkdownTextStyles;
theme: Theme;
value?: string | number;
value?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
@ -94,6 +95,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
color: editedColor,
opacity: editedOpacity,
},
maxNodesWarning: {
color: theme.errorTextColor,
},
atMentionOpacity: {
opacity: 1,
},
@ -126,7 +130,7 @@ const Markdown = ({
autolinkedUrlSchemes, baseTextStyle, blockStyles, channelId, channelMentions,
disableAtChannelMentionHighlight, disableAtMentions, disableBlockQuote, disableChannelLink,
disableCodeBlock, disableGallery, disableHashtags, disableHeading, disableTables,
enableInlineLatex, enableLatex,
enableInlineLatex, enableLatex, maxNodes,
imagesMetadata, isEdited, isReplyPost, isSearchResult, layoutHeight, layoutWidth,
location, mentionKeys, minimumHashtagLength = 3, onPostPress, postId, searchPatterns,
textStyles = {}, theme, value = '', baseParagraphStyle,
@ -520,6 +524,19 @@ const Markdown = ({
);
};
const renderMaxNodesWarning = () => {
const styles = [baseTextStyle, style.maxNodesWarning];
return (
<FormattedText
id='markdown.max_nodes.error'
defaultMessage='This message is too long to by shown fully on a mobile device. Please view it on desktop or contact an admin to increase this limit.'
style={styles}
testID='max_nodes_warning'
/>
);
};
const createRenderer = () => {
const renderers: any = {
text: renderText,
@ -534,7 +551,7 @@ const Markdown = ({
channelLink: renderChannelLink,
emoji: renderEmoji,
hashtag: renderHashtag,
latexinline: renderLatexInline,
latexInline: renderLatexInline,
paragraph: renderParagraph,
heading: renderHeading,
@ -560,11 +577,13 @@ const Markdown = ({
checkbox: renderCheckbox,
editedIndicator: renderEditedIndicator,
maxNodesWarning: renderMaxNodesWarning,
};
return new Renderer({
renderers,
renderParagraphsInLists: true,
maxNodes,
getExtraPropsForNode,
allowedTypes: Object.keys(renderers),
});

View file

@ -123,7 +123,7 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc
style={style.bottomSheet}
>
<SlideUpPanelItem
icon='content-copy'
leftIcon='content-copy'
onPress={() => {
dismissBottomSheet();
Clipboard.setString(content);
@ -133,7 +133,7 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc
/>
<SlideUpPanelItem
destructive={true}
icon='cancel'
leftIcon='cancel'
onPress={() => {
dismissBottomSheet();
}}

View file

@ -157,7 +157,7 @@ const MarkdownImage = ({
style={style.bottomSheet}
>
<SlideUpPanelItem
icon='content-copy'
leftIcon='content-copy'
onPress={() => {
dismissBottomSheet();
Clipboard.setString(linkDestination || source);
@ -167,7 +167,7 @@ const MarkdownImage = ({
/>
<SlideUpPanelItem
destructive={true}
icon='cancel'
leftIcon='cancel'
onPress={() => {
dismissBottomSheet();
}}

View file

@ -139,7 +139,7 @@ const LatexCodeBlock = ({content, theme}: Props) => {
style={styles.bottomSheet}
>
<SlideUpPanelItem
icon='content-copy'
leftIcon='content-copy'
onPress={() => {
dismissBottomSheet();
Clipboard.setString(content);
@ -149,7 +149,7 @@ const LatexCodeBlock = ({content, theme}: Props) => {
/>
<SlideUpPanelItem
destructive={true}
icon='cancel'
leftIcon='cancel'
onPress={dismissBottomSheet}
testID='at_mention.bottom_sheet.cancel'
text={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}

View file

@ -116,7 +116,7 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU
style={style.bottomSheet}
>
<SlideUpPanelItem
icon='content-copy'
leftIcon='content-copy'
onPress={() => {
dismissBottomSheet();
Clipboard.setString(href);
@ -126,7 +126,7 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU
/>
<SlideUpPanelItem
destructive={true}
icon='cancel'
leftIcon='cancel'
onPress={() => {
dismissBottomSheet();
}}

View file

@ -45,9 +45,11 @@ const MarkdownTableRow = ({isFirstRow, isLastRow, children}: MarkdownTableRowPro
// Add an extra prop to the last cell so that it knows not to render a right border since the container
// will handle that
const renderChildren = React.Children.toArray(children) as ReactElement[];
renderChildren[renderChildren.length - 1] = React.cloneElement(renderChildren[renderChildren.length - 1], {
isLastCell: true,
});
if (renderChildren.length > 0) {
renderChildren[renderChildren.length - 1] = React.cloneElement(renderChildren[renderChildren.length - 1], {
isLastCell: true,
});
}
return (
<View

View file

@ -2131,10 +2131,6 @@ describe('Components.Markdown.transform', () => {
}, {
type: 'at_mention',
_mentionName: 'user',
children: [{
type: 'text',
literal: '@user',
}],
}],
}],
},
@ -2154,10 +2150,6 @@ describe('Components.Markdown.transform', () => {
children: [{
type: 'at_mention',
_mentionName: 'words',
children: [{
type: 'text',
literal: '@words',
}],
}],
}],
}],
@ -2178,10 +2170,6 @@ describe('Components.Markdown.transform', () => {
children: [{
type: 'at_mention',
_mentionName: 'words',
children: [{
type: 'text',
literal: '@words',
}],
}],
}],
}],
@ -2579,10 +2567,6 @@ describe('Components.Markdown.transform', () => {
children: [{
type: 'at_mention',
_mentionName: 'channel.',
children: [{
type: 'text',
literal: '@channel.',
}],
}],
}],
}],
@ -2601,10 +2585,6 @@ describe('Components.Markdown.transform', () => {
}, {
type: 'at_mention',
_mentionName: 'Gvn.',
children: [{
type: 'text',
literal: '@Gvn.',
}],
}],
}],
},

View file

@ -136,10 +136,14 @@ export default function PostInput({
return {...style.input, maxHeight};
}, [maxHeight, style.input]);
const handleAndroidKeyboard = () => {
const handleAndroidKeyboardHide = () => {
onBlur();
};
const handleAndroidKeyboardShow = () => {
onFocus();
};
const onBlur = useCallback(() => {
updateDraftMessage(serverUrl, channelId, rootId, value);
setIsFocused(false);
@ -252,13 +256,16 @@ export default function PostInput({
}, [serverUrl, channelId, rootId, value]);
useEffect(() => {
let keyboardListener: EmitterSubscription | undefined;
let keyboardHideListener: EmitterSubscription | undefined;
let keyboardShowListener: EmitterSubscription | undefined;
if (Platform.OS === 'android') {
keyboardListener = Keyboard.addListener('keyboardDidHide', handleAndroidKeyboard);
keyboardHideListener = Keyboard.addListener('keyboardDidHide', handleAndroidKeyboardHide);
keyboardShowListener = Keyboard.addListener('keyboardDidShow', handleAndroidKeyboardShow);
}
return (() => {
keyboardListener?.remove();
keyboardShowListener?.remove();
keyboardHideListener?.remove();
});
}, []);

View file

@ -66,13 +66,13 @@ const CameraType = ({onPress}: Props) => {
/>
}
<SlideUpPanelItem
icon='camera-outline'
leftIcon='camera-outline'
onPress={onPhoto}
testID='camera_type.photo'
text={intl.formatMessage({id: 'camera_type.photo.option', defaultMessage: 'Capture Photo'})}
/>
<SlideUpPanelItem
icon='video-outline'
leftIcon='video-outline'
onPress={onVideo}
testID='camera_type.video'
text={intl.formatMessage({id: 'camera_type.video.option', defaultMessage: 'Record Video'})}

View file

@ -1,9 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetFlatList} from '@gorhom/bottom-sheet';
import React, {useCallback, useRef} from 'react';
import {FlatList} from 'react-native-gesture-handler';
import {useIsTablet} from '@hooks/device';
import UserListItem from './user_list_item';
import type UserModel from '@typings/database/models/servers/user';
@ -18,6 +21,7 @@ type Props = {
};
const UsersList = ({channelId, location, users, userAcknowledgements, timezone}: Props) => {
const isTablet = useIsTablet();
const listRef = useRef<FlatList>(null);
const renderItem = useCallback(({item}: ListRenderItemInfo<UserModel>) => (
@ -30,10 +34,20 @@ const UsersList = ({channelId, location, users, userAcknowledgements, timezone}:
/>
), [channelId, location, timezone]);
if (isTablet) {
return (
<FlatList
data={users}
ref={listRef}
renderItem={renderItem}
overScrollMode={'always'}
/>
);
}
return (
<FlatList
<BottomSheetFlatList
data={users}
ref={listRef}
renderItem={renderItem}
overScrollMode={'always'}
/>

View file

@ -44,7 +44,7 @@ const Failed = ({post, theme}: FailedProps) => {
style={styles.bottomSheet}
>
<SlideUpPanelItem
icon='send-outline'
leftIcon='send-outline'
onPress={() => {
dismissBottomSheet();
retryFailedPost(serverUrl, post);
@ -54,7 +54,7 @@ const Failed = ({post, theme}: FailedProps) => {
/>
<SlideUpPanelItem
destructive={true}
icon='close-circle-outline'
leftIcon='close-circle-outline'
onPress={() => {
dismissBottomSheet();
removePost(serverUrl, post);

View file

@ -4,7 +4,7 @@
import {FlatList} from '@stream-io/flat-list-mvcp';
import React, {type ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter, type ListRenderItemInfo, type NativeScrollEvent, type NativeSyntheticEvent, Platform, type StyleProp, StyleSheet, type ViewStyle} from 'react-native';
import Animated from 'react-native-reanimated';
import Animated, {type AnimatedStyle} from 'react-native-reanimated';
import {fetchPosts, fetchPostThread} from '@actions/remote/post';
import CombinedUserActivity from '@components/post_list/combined_user_activity';
@ -27,7 +27,7 @@ import type PostModel from '@typings/database/models/servers/post';
type Props = {
appsEnabled: boolean;
channelId: string;
contentContainerStyle?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<AnimatedStyle<ViewStyle>>;
currentTimezone: string | null;
currentUserId: string;
currentUsername: string;
@ -354,6 +354,8 @@ const PostList = ({
onScroll={onScroll}
onScrollToIndexFailed={onScrollToIndexFailed}
onViewableItemsChanged={onViewableItemsChanged}
// @ts-expect-error old style ref
ref={listRef}
removeClippedSubviews={true}
renderItem={renderItem}

View file

@ -92,6 +92,8 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => {
return (
<AnimatedFastImage
key={pictureUrl}
// @ts-expect-error TS expects old type ref
ref={forwardRef}
style={fIStyle}
source={imgSource}

View file

@ -120,6 +120,8 @@ const ProgressiveImage = ({
if (showHighResImage && imageUri) {
image = (
<AnimatedFastImage
// @ts-expect-error old style ref
ref={forwardRef}
nativeID={`image-${id}`}
resizeMode={resizeMode}
@ -138,6 +140,8 @@ const ProgressiveImage = ({
} else if (imageUri) {
image = (
<AnimatedFastImage
// @ts-expect-error old style ref
ref={forwardRef}
nativeID={`image-${id}`}
resizeMode={resizeMode}

View file

@ -25,6 +25,8 @@ const Thumbnail = ({onError, opacity, style, source, tintColor}: ThumbnailProps)
onError={onError}
resizeMode='cover'
source={source}
// @ts-expect-error style is supported but TS complains
style={style}
testID='progressive_image.miniPreview'
/>

View file

@ -14,10 +14,12 @@ import {isValidUrl} from '@utils/url';
type SlideUpPanelProps = {
destructive?: boolean;
icon?: string | Source;
rightIcon?: boolean;
imageStyles?: StyleProp<ImageStyle>;
iconStyles?: StyleProp<TextStyle>;
leftIcon?: string | Source;
leftImageStyles?: StyleProp<ImageStyle>;
leftIconStyles?: StyleProp<TextStyle>;
rightIcon?: string | Source;
rightImageStyles?: StyleProp<ImageStyle>;
rightIconStyles?: StyleProp<TextStyle>;
onPress: () => void;
textStyles?: TextStyle;
testID?: string;
@ -65,13 +67,55 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const SlideUpPanelItem = ({destructive, icon, imageStyles, onPress, testID, text, textStyles, iconStyles, rightIcon = false}: SlideUpPanelProps) => {
const SlideUpPanelItem = ({
destructive = false,
leftIcon,
leftImageStyles,
leftIconStyles,
rightIcon,
rightImageStyles,
rightIconStyles,
onPress,
testID,
text,
textStyles,
}: SlideUpPanelProps) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const {image: leftImage, iconStyle: leftIconStyle} = useImageAndStyle(leftIcon, leftImageStyles, leftIconStyles, destructive);
const {image: rightImage, iconStyle: rightIconStyle} = useImageAndStyle(rightIcon, rightImageStyles, rightIconStyles, destructive);
const handleOnPress = useCallback(preventDoubleTap(onPress, 500), []);
return (
<TouchableHighlight
onPress={handleOnPress}
style={style.container}
testID={testID}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
>
<View style={style.row}>
{Boolean(leftImage) &&
<View style={leftIconStyle}>{leftImage}</View>
}
<View style={style.textContainer}>
<Text style={[style.text, destructive && style.destructive, textStyles]}>{text}</Text>
</View>
{Boolean(rightImage) &&
<View style={rightIconStyle}>{rightImage}</View>
}
</View>
</TouchableHighlight>
);
};
const useImageAndStyle = (icon: string | Source | undefined, imageStyles: StyleProp<ImageStyle>, iconStyles: StyleProp<TextStyle>, destructive: boolean) => {
const theme = useTheme();
const style = getStyleSheet(theme);
let image;
let iconStyle: StyleProp<ViewStyle> = [style.iconContainer];
let iconStyle: Array<StyleProp<ViewStyle>> = [style.iconContainer];
if (icon) {
if (typeof icon === 'object') {
if (icon.uri && isValidUrl(icon.uri)) {
@ -101,26 +145,7 @@ const SlideUpPanelItem = ({destructive, icon, imageStyles, onPress, testID, text
}
}
return (
<TouchableHighlight
onPress={handleOnPress}
style={style.container}
testID={testID}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
>
<View style={style.row}>
{Boolean(image) && !rightIcon &&
<View style={iconStyle}>{image}</View>
}
<View style={style.textContainer}>
<Text style={[style.text, destructive && style.destructive, textStyles]}>{text}</Text>
</View>
{Boolean(image) && rightIcon &&
<View style={iconStyle}>{image}</View>
}
</View>
</TouchableHighlight>
);
return {image, iconStyle};
};
export default SlideUpPanelItem;

View file

@ -67,12 +67,12 @@ export function createProfilesSections(intl: IntlShape, profiles: UserProfile[],
return [];
}
const sections = new Map<string, UserProfile[]>();
const sections = new Map<string, UserProfileWithChannelAdmin[]>();
if (members?.length) {
// when channel members are provided, build the sections by admins and members
const membersDictionary = new Map<string, ChannelMembership>();
const membersSections = new Map<string, UserProfile[]>();
const membersSections = new Map<string, UserProfileWithChannelAdmin[]>();
const {formatMessage} = intl;
members.forEach((m) => membersDictionary.set(m.user_id, m));
profiles.forEach((p) => {
@ -80,7 +80,7 @@ export function createProfilesSections(intl: IntlShape, profiles: UserProfile[],
if (member) {
const sectionKey = sectionRoleKeyExtractor(member.scheme_admin!).id;
const section = membersSections.get(sectionKey) || [];
section.push(p);
section.push({...p, scheme_admin: member.scheme_admin});
membersSections.set(sectionKey, section);
}
});
@ -111,6 +111,28 @@ export function createProfilesSections(intl: IntlShape, profiles: UserProfile[],
return results;
}
function createProfiles(profiles: UserProfile[], members?: ChannelMembership[]): UserProfileWithChannelAdmin[] {
if (!profiles.length) {
return [];
}
const profileMap = new Map<string, UserProfileWithChannelAdmin>();
profiles.forEach((profile) => {
profileMap.set(profile.id, profile);
});
if (members?.length) {
members.forEach((m) => {
const profileFound = profileMap.get(m.user_id);
if (profileFound) {
profileFound.scheme_admin = m.scheme_admin;
}
});
}
return Array.from(profileMap.values());
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
list: {
@ -195,7 +217,7 @@ export default function UserList({
}
if (term) {
return profiles;
return createProfiles(profiles, channelMembers);
}
return createProfilesSections(intl, profiles, channelMembers);
@ -337,7 +359,7 @@ export default function UserList({
};
if (term) {
return renderFlatList(data as UserProfile[]);
return renderFlatList(data as UserProfileWithChannelAdmin[]);
}
return renderSectionList(data as Array<SectionListData<UserProfile>>);
return renderSectionList(data as Array<SectionListData<UserProfileWithChannelAdmin>>);
}

View file

@ -5,13 +5,13 @@ export const AT_MENTION_REGEX = /\B(@([^@\r\n]*))$/i;
export const AT_MENTION_REGEX_GLOBAL = /\B(@([^@\r\n]*))/gi;
export const AT_MENTION_SEARCH_REGEX = /\bfrom:\s*(\S*)$/i;
export const AT_MENTION_SEARCH_REGEX = /\bfrom:\s*([^\r\n]*)$/i;
export const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i;
export const CHANNEL_MENTION_REGEX_DELAYED = /\B(~([^~\r\n]{2,}))$/i;
export const CHANNEL_MENTION_SEARCH_REGEX = /\b(?:in|channel):\s*(\S*)$/i;
export const CHANNEL_MENTION_SEARCH_REGEX = /\b(?:in|channel):\s*([^\r\n]*)$/i;
export const DATE_MENTION_SEARCH_REGEX = /\b(?:on|before|after):\s*(\S*)$/i;

View file

@ -80,6 +80,8 @@ export const GLOBAL_IDENTIFIERS = {
FIRST_LAUNCH: 'firstLaunch',
LAST_ASK_FOR_REVIEW: 'lastAskForReview',
ONBOARDING: 'onboarding',
LAST_VIEWED_CHANNEL: 'lastViewedChannel',
LAST_VIEWED_THREAD: 'lastViewedThread',
};
export enum OperationType {

View file

@ -7,6 +7,7 @@ import keyMirror from '@utils/key_mirror';
export const CERTIFICATE_ERRORS = keyMirror({
CLIENT_CERTIFICATE_IMPORT_ERROR: null,
CLIENT_CERTIFICATE_MISSING: null,
SERVER_INVALID_CERTIFICATE: null,
});
export const DOWNLOAD_TIMEOUT = toMilliseconds({minutes: 10});

View file

@ -1,10 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const ALL = 'all';
export const DEFAULT = 'default';
export const MENTION = 'mention';
export const NONE = 'none';
export const ALL = 'all' as const;
export const DEFAULT = 'default' as const;
export const MENTION = 'mention' as const;
export const NONE = 'none' as const;
export default {
ALL,

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const CATEGORIES_TO_KEEP: Record<string, string> = {
export const CATEGORIES_TO_KEEP = {
ADVANCED_SETTINGS: 'advanced_settings',
CHANNEL_APPROXIMATE_VIEW_TIME: 'channel_approximate_view_time',
CHANNEL_OPEN_TIME: 'channel_open_time',
@ -14,15 +14,21 @@ export const CATEGORIES_TO_KEEP: Record<string, string> = {
SIDEBAR_SETTINGS: 'sidebar_settings',
TEAMS_ORDER: 'teams_order',
THEME: 'theme',
SYSTEM_NOTICE: 'system_notice',
};
const CATEGORIES: Record<string, string> = {
const CATEGORIES = {
...CATEGORIES_TO_KEEP,
FAVORITE_CHANNEL: 'favorite_channel',
};
const NOTICES = {
GM_AS_DM: 'GMasDM',
};
const Preferences = {
CATEGORIES,
NOTICES,
COLLAPSED_REPLY_THREADS: 'collapsed_reply_threads',
COLLAPSED_REPLY_THREADS_OFF: 'off',
COLLAPSED_REPLY_THREADS_ON: 'on',

View file

@ -8,6 +8,7 @@ export const SNACK_BAR_TYPE = keyMirror({
ADD_CHANNEL_MEMBERS: null,
FAVORITE_CHANNEL: null,
FOLLOW_THREAD: null,
INFO_COPIED: null,
LINK_COPIED: null,
MESSAGE_COPIED: null,
MUTE_CHANNEL: null,
@ -43,6 +44,12 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
iconName: 'check',
canUndo: true,
},
INFO_COPIED: {
id: t('snack.bar.info.copied'),
defaultMessage: 'Info copied to clipboard',
iconName: 'content-copy',
canUndo: false,
},
LINK_COPIED: {
id: t('snack.bar.link.copied'),
defaultMessage: 'Link copied to clipboard',

View file

@ -4,7 +4,7 @@
export const MIN_REQUIRED_VERSION = '5.26.2';
export const FULL_VERSION = '7.8.0';
export const MAJOR_VERSION = 7;
export const MIN_VERSION = 1;
export const MIN_VERSION = 8;
export const PATCH_VERSION = 0;
export default {

View file

@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const GM_AS_DM_VERSION = [9, 1, 0];

View file

@ -24,6 +24,7 @@ export const SEARCH_INPUT_MARGIN = 5;
export const JOIN_CALL_BAR_HEIGHT = 38;
export const CURRENT_CALL_BAR_HEIGHT = 68;
export const CALL_ERROR_BAR_HEIGHT = 62;
export const CALL_NOTIFICATION_BAR_HEIGHT = 60;
export const ANNOUNCEMENT_BAR_HEIGHT = 40;

View file

@ -20,6 +20,7 @@ const WebsocketEvents = {
CHANNEL_UNARCHIVED: 'channel_restored',
CHANNEL_UPDATED: 'channel_updated',
CHANNEL_VIEWED: 'channel_viewed',
MULTIPLE_CHANNELS_VIEWED: 'multiple_channels_viewed',
CHANNEL_MEMBER_UPDATED: 'channel_member_updated',
CHANNEL_SCHEME_UPDATED: 'channel_scheme_updated',
DIRECT_ADDED: 'direct_added',
@ -75,6 +76,7 @@ const WebsocketEvents = {
CALLS_USER_REACTED: `custom_${Calls.PluginId}_user_reacted`,
CALLS_RECORDING_STATE: `custom_${Calls.PluginId}_call_recording_state`,
CALLS_HOST_CHANGED: `custom_${Calls.PluginId}_call_host_changed`,
CALLS_USER_DISMISSED_NOTIFICATION: `custom_${Calls.PluginId}_user_dismissed_notification`,
GROUP_RECEIVED: 'received_group',
GROUP_MEMBER_ADD: 'group_member_add',
GROUP_MEMBER_DELETE: 'group_member_delete',

View file

@ -1,14 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useLayoutEffect} from 'react';
import Animated, {makeMutable, runOnUI} from 'react-native-reanimated';
import {useEffect, useLayoutEffect} from 'react';
import Animated, {makeMutable, runOnUI, type AnimatedRef} from 'react-native-reanimated';
import type {GalleryManagerSharedValues} from '@typings/screens/gallery';
export interface GalleryManagerItem {
index: number;
ref: React.RefObject<unknown>;
ref: AnimatedRef<any>;
}
export interface GalleryManagerItems {
@ -79,7 +79,7 @@ class Gallery {
})();
}
public registerItem(index: number, ref: React.RefObject<unknown>) {
public registerItem(index: number, ref: AnimatedRef<any>) {
if (this.items.has(index)) {
return;
}

View file

@ -45,7 +45,7 @@ export default class BaseDataOperator {
* @param {(existing: Model, newElement: RawValue) => boolean} inputsArg.buildKeyRecordBy
* @returns {Promise<{ProcessRecordResults}>}
*/
processRecords = async <T extends Model>({createOrUpdateRawValues = [], deleteRawValues = [], tableName, buildKeyRecordBy, fieldName}: ProcessRecordsArgs): Promise<ProcessRecordResults<T>> => {
processRecords = async <T extends Model>({createOrUpdateRawValues = [], deleteRawValues = [], tableName, buildKeyRecordBy, fieldName, shouldUpdate}: ProcessRecordsArgs): Promise<ProcessRecordResults<T>> => {
const getRecords = async (rawValues: RawValue[]) => {
// We will query a table where one of its fields can match a range of values. Hence, here we are extracting all those potential values.
const columnValues: string[] = getRangeOfValues({fieldName, raws: rawValues});
@ -92,6 +92,10 @@ export default class BaseDataOperator {
// We found a record in the database that matches this element; hence, we'll proceed for an UPDATE operation
if (existingRecord) {
if (shouldUpdate && !shouldUpdate(existingRecord, newElement)) {
continue;
}
// Some raw value has an update_at field. We'll proceed to update only if the update_at value is different from the record's value in database
const updateRecords = getValidRecordsForUpdate({
tableName,
@ -205,7 +209,7 @@ export default class BaseDataOperator {
* @param {string} handleRecordsArgs.tableName
* @returns {Promise<Model[]>}
*/
async handleRecords<T extends Model>({buildKeyRecordBy, fieldName, transformer, createOrUpdateRawValues, deleteRawValues = [], tableName, prepareRecordsOnly = true}: HandleRecordsArgs<T>, description: string): Promise<T[]> {
async handleRecords<T extends Model>({buildKeyRecordBy, fieldName, transformer, createOrUpdateRawValues, deleteRawValues = [], tableName, prepareRecordsOnly = true, shouldUpdate}: HandleRecordsArgs<T>, description: string): Promise<T[]> {
if (!createOrUpdateRawValues.length) {
logWarning(
`An empty "rawValues" array has been passed to the handleRecords method for tableName ${tableName}`,
@ -219,6 +223,7 @@ export default class BaseDataOperator {
tableName,
buildKeyRecordBy,
fieldName,
shouldUpdate,
});
let models: T[] = [];

View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type FileModel from '@typings/database/models/servers/file';
export const shouldUpdateFileRecord = (e: FileModel, n: FileInfo): boolean => {
return Boolean(
(n.post_id !== e.postId) ||
(n.name !== e.name) ||
(n.extension !== e.extension) ||
(n.size !== e.size) ||
((n.mime_type || '') !== e.mimeType) ||
(n.width && n.width !== e.width) ||
(n.height && n.height !== e.height) ||
(n.mini_preview && n.mini_preview !== e.imageThumbnail) ||
(n.localPath && n.localPath !== e.localPath),
);
};

View file

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type ThreadModel from '@typings/database/models/servers/thread';
export const shouldUpdateThreadRecord = (e: ThreadModel, n: ThreadWithLastFetchedAt): boolean => {
return (
((n.last_reply_at != null) && n.last_reply_at !== e.lastReplyAt) ||
((n.lastFetchedAt || 0) > e.lastFetchedAt) ||
((n.last_viewed_at != null) && e.lastViewedAt !== n.last_viewed_at) ||
(e.replyCount !== n.reply_count) ||
((n.is_following != null) && e.isFollowing !== n.is_following) ||
((n.unread_replies != null) && e.unreadReplies !== n.unread_replies) ||
((n.unread_mentions != null) && e.unreadMentions !== n.unread_mentions)
);
};

View file

@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type UserModel from '@typings/database/models/servers/user';
export function shouldUpdateUserRecord(e: UserModel, n: UserProfile) {
return Boolean(n.update_at > e.updateAt || (n.status && n.status !== e.status));
}

View file

@ -6,6 +6,7 @@ import {Q} from '@nozbe/watermelondb';
import {ActionType} from '@constants';
import {MM_TABLES} from '@constants/database';
import {buildDraftKey} from '@database/operator/server_data_operator/comparators';
import {shouldUpdateFileRecord} from '@database/operator/server_data_operator/comparators/files';
import {
transformDraftRecord,
transformFileRecord,
@ -234,6 +235,7 @@ const PostHandler = <TBase extends Constructor<ServerDataOperatorBase>>(supercla
deleteRawValues: pendingPostsToDelete,
tableName,
fieldName: 'id',
shouldUpdate: (e: PostModel, n: Post) => n.update_at > e.updateAt,
}));
const preparedPosts = (await this.prepareRecords({
@ -309,6 +311,7 @@ const PostHandler = <TBase extends Constructor<ServerDataOperatorBase>>(supercla
tableName: FILE,
fieldName: 'id',
deleteRawValues: [],
shouldUpdate: shouldUpdateFileRecord,
}));
const postFiles = await this.prepareRecords({

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {shouldUpdateThreadRecord} from '@database/operator/server_data_operator/comparators/thread';
import {transformThreadRecord, transformThreadParticipantRecord, transformThreadInTeamRecord, transformTeamThreadsSyncRecord} from '@database/operator/server_data_operator/transformers/thread';
import type ServerDataOperator from '..';
@ -59,6 +60,7 @@ describe('*** Operator: Thread Handlers tests ***', () => {
createOrUpdateRawValues: threads,
tableName: 'Thread',
prepareRecordsOnly: true,
shouldUpdate: shouldUpdateThreadRecord,
}, 'handleThreads(NEVER)');
// Should handle participants

View file

@ -4,6 +4,7 @@
import {Q} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import {shouldUpdateThreadRecord} from '@database/operator/server_data_operator/comparators/thread';
import {
transformThreadRecord,
transformThreadParticipantRecord,
@ -110,6 +111,7 @@ const ThreadHandler = <TBase extends Constructor<ServerDataOperatorBase>>(superc
prepareRecordsOnly: true,
createOrUpdateRawValues: createOrUpdateThreads,
tableName: THREAD,
shouldUpdate: shouldUpdateThreadRecord,
}, 'handleThreads(NEVER)');
// Add the models to be batched here

View file

@ -3,6 +3,7 @@
import DatabaseManager from '@database/manager';
import {buildPreferenceKey} from '@database/operator/server_data_operator/comparators';
import {shouldUpdateUserRecord} from '@database/operator/server_data_operator/comparators/user';
import {
transformPreferenceRecord,
transformUserRecord,
@ -102,6 +103,7 @@ describe('*** Operator: User Handlers tests ***', () => {
tableName: 'User',
prepareRecordsOnly: false,
transformer: transformUserRecord,
shouldUpdate: shouldUpdateUserRecord,
}, 'handleUsers');
});

View file

@ -3,6 +3,7 @@
import {MM_TABLES} from '@constants/database';
import {buildPreferenceKey} from '@database/operator/server_data_operator/comparators';
import {shouldUpdateUserRecord} from '@database/operator/server_data_operator/comparators/user';
import {
transformPreferenceRecord,
transformUserRecord,
@ -36,14 +37,16 @@ const UserHandler = <TBase extends Constructor<ServerDataOperatorBase>>(supercla
*/
handlePreferences = async ({preferences, prepareRecordsOnly = true, sync = false}: HandlePreferencesArgs): Promise<PreferenceModel[]> => {
const records: PreferenceModel[] = [];
const filtered = filterPreferences(preferences);
if (!filtered?.length) {
if (!preferences?.length) {
logWarning(
'An empty or undefined "preferences" array has been passed to the handlePreferences method',
);
return records;
}
const filtered = filterPreferences(preferences);
// WE NEED TO SYNC THE PREFS FROM WHAT WE GOT AND WHAT WE HAVE
const deleteValues: PreferenceModel[] = [];
const stored = await this.database.get(PREFERENCE).query().fetch() as PreferenceModel[];
@ -126,6 +129,7 @@ const UserHandler = <TBase extends Constructor<ServerDataOperatorBase>>(supercla
createOrUpdateRawValues,
tableName: USER,
prepareRecordsOnly,
shouldUpdate: shouldUpdateUserRecord,
}, 'handleUsers');
};
};

View file

@ -53,8 +53,8 @@ export function getSidebarPreferenceAsBool(preferences: Preference[], name: stri
return getPreferenceAsBool(preferences, Preferences.CATEGORIES.SIDEBAR_SETTINGS, name, defaultValue);
}
export function filterPreferences(preferences?: PreferenceType[]) {
if (!preferences?.length) {
export function filterPreferences(preferences: PreferenceType[]) {
if (!preferences.length) {
return preferences;
}

View file

@ -5,7 +5,6 @@ import xRegExp from 'xregexp';
import {General} from '@constants';
import type Model from '@nozbe/watermelondb/Model';
import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
@ -26,11 +25,6 @@ type NotifyProps = {
NotifyProps,
]
export const extractRecordsForTable = <T>(records: Model[], tableName: string): T[] => {
// @ts-expect-error constructor.table not exposed in type definition
return records.filter((r) => r.constructor.table === tableName) as T[];
};
export function extractChannelDisplayName(raw: Pick<Channel, 'type' | 'display_name' | 'fake'>, record?: ChannelModel) {
let displayName = '';
switch (raw.type) {

View file

@ -22,7 +22,7 @@ const useAndroidHardwareBackHandler = (componentId: AvailableScreens | undefined
return () => {
backHandler.remove();
};
}, [componentId]);
}, [componentId, callback]);
};
export default useAndroidHardwareBackHandler;

View file

@ -95,7 +95,7 @@ export const useCollapsibleHeader = <T>(isLargeTitle: boolean, onSnap?: (offset:
return;
}
if (ctx.dragging || autoScroll.value || snapping.value) {
if (ctx?.dragging || autoScroll.value || snapping.value) {
scrollValue.value = e.contentOffset.y;
} else {
// here we want to ensure that the scroll position
@ -105,25 +105,29 @@ export const useCollapsibleHeader = <T>(isLargeTitle: boolean, onSnap?: (offset:
}
},
onEndDrag: (e, ctx) => {
if (ctx.start !== undefined) {
if (ctx?.start !== undefined) {
const dir = e.contentOffset.y < ctx.start ? 'down' : 'up';
const offset = Math.abs(e.contentOffset.y);
snapIfNeeded(dir, offset);
}
},
onMomentumBegin: (e, ctx) => {
ctx.momentum = e.contentOffset.y < (ctx.start || 0) ? 'down' : 'up';
if (ctx) {
ctx.momentum = e.contentOffset.y < (ctx.start || 0) ? 'down' : 'up';
}
},
onMomentumEnd: (e, ctx) => {
ctx.start = undefined;
ctx.dragging = false;
if (ctx.momentum === 'down') {
const offset = Math.abs(e.contentOffset.y);
if (ctx) {
ctx.start = undefined;
ctx.dragging = false;
if (ctx.momentum === 'down') {
const offset = Math.abs(e.contentOffset.y);
if (onSnap && offset < largeHeight) {
runOnJS(onSnap)(0);
if (onSnap && offset < largeHeight) {
runOnJS(onSnap)(0);
}
ctx.momentum = undefined;
}
ctx.momentum = undefined;
}
},
}, [insets, defaultHeight, largeHeight, animatedRef]);

View file

@ -5,12 +5,14 @@ import Emm from '@mattermost/react-native-emm';
import {Alert, AppState, DeviceEventEmitter, Linking, Platform} from 'react-native';
import {Notifications} from 'react-native-notifications';
import {switchToChannelById} from '@actions/remote/channel';
import {appEntry, pushNotificationEntry, upgradeEntry} from '@actions/remote/entry';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import LocalConfig from '@assets/config.json';
import {DeepLink, Events, Launch, PushNotification} from '@constants';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials';
import {getOnboardingViewed} from '@queries/app/global';
import {getLastViewedChannelIdAndServer, getOnboardingViewed, getLastViewedThreadIdAndServer} from '@queries/app/global';
import {getThemeForCurrentTeam} from '@queries/servers/preference';
import {getCurrentUserId} from '@queries/servers/system';
import {queryMyTeams} from '@queries/servers/team';
@ -72,7 +74,7 @@ const launchApp = async (props: LaunchProps) => {
let serverUrl: string | undefined;
switch (props?.launchType) {
case Launch.DeepLink:
if (props.extra?.type !== DeepLink.Invalid) {
if (props.extra && props.extra.type !== DeepLink.Invalid) {
const extra = props.extra as DeepLinkWithData;
const existingServer = DatabaseManager.searchUrl(extra.data!.serverUrl);
serverUrl = existingServer;
@ -172,6 +174,15 @@ const launchToHome = async (props: LaunchProps) => {
}
case Launch.Normal:
if (props.coldStart) {
const lastViewedChannel = await getLastViewedChannelIdAndServer();
const lastViewedThread = await getLastViewedThreadIdAndServer();
if (lastViewedThread && lastViewedThread.server_url === props.serverUrl && lastViewedThread.thread_id) {
fetchAndSwitchToThread(props.serverUrl!, lastViewedThread.thread_id);
} else if (lastViewedChannel && lastViewedChannel.server_url === props.serverUrl && lastViewedChannel.channel_id) {
switchToChannelById(props.serverUrl!, lastViewedChannel.channel_id);
}
appEntry(props.serverUrl!);
}
break;

View file

@ -25,7 +25,8 @@ export class Analytics {
async init(config: ClientConfig) {
if (LocalConfig.RudderApiKey) {
this.analytics = require('@rudderstack/rudder-sdk-react-native').default;
// Rudder stack has been temporarily removed
// this.analytics = require('@rudderstack/rudder-sdk-react-native').default;
}
if (this.analytics) {

View file

@ -17,7 +17,8 @@ import {queryTeamDefaultChannel} from '@queries/servers/channel';
import {getCommonSystemValues} from '@queries/servers/system';
import {getTeamChannelHistory} from '@queries/servers/team';
import {setScreensOrientation} from '@screens/navigation';
import {handleDeepLink} from '@utils/deep_link';
import {alertInvalidDeepLink, handleDeepLink} from '@utils/deep_link';
import {getIntlShape} from '@utils/general';
type LinkingCallbackArg = {url: string};
@ -50,13 +51,16 @@ class GlobalEventHandler {
}
};
onDeepLink = (event: LinkingCallbackArg) => {
onDeepLink = async (event: LinkingCallbackArg) => {
if (event.url?.startsWith(Sso.REDIRECT_URL_SCHEME) || event.url?.startsWith(Sso.REDIRECT_URL_SCHEME_DEV)) {
return;
}
if (event.url) {
handleDeepLink(event.url);
const {error} = await handleDeepLink(event.url);
if (error) {
alertInvalidDeepLink(getIntlShape(DEFAULT_LOCALE));
}
}
};

View file

@ -9,20 +9,23 @@ import {
RetryTypes,
type APIClientConfiguration,
} from '@mattermost/react-native-network-client';
import {DeviceEventEmitter} from 'react-native';
import {Alert, DeviceEventEmitter} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import urlParse from 'url-parse';
import LocalConfig from '@assets/config.json';
import {Client} from '@client/rest';
import * as ClientConstants from '@client/rest/constants';
import ClientError from '@client/rest/error';
import {CERTIFICATE_ERRORS} from '@constants/network';
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
import ManagedApp from '@init/managed_app';
import {logError} from '@utils/log';
import {logDebug, logError} from '@utils/log';
import {getCSRFFromCookie} from '@utils/security';
const CLIENT_CERTIFICATE_IMPORT_ERROR_CODES = [-103, -104, -105, -108];
const CLIENT_CERTIFICATE_MISSING_ERROR_CODE = -200;
const SERVER_CERTIFICATE_INVALID = -299;
class NetworkManager {
private clients: Record<string, Client> = {};
@ -121,6 +124,17 @@ class NetworkManager {
DeviceEventEmitter.emit(CERTIFICATE_ERRORS.CLIENT_CERTIFICATE_IMPORT_ERROR, event.serverUrl);
} else if (CLIENT_CERTIFICATE_MISSING_ERROR_CODE === event.errorCode) {
DeviceEventEmitter.emit(CERTIFICATE_ERRORS.CLIENT_CERTIFICATE_MISSING, event.serverUrl);
} else if (SERVER_CERTIFICATE_INVALID === event.errorCode) {
logDebug('Invalid SSL certificate:', event.errorDescription);
const parsed = urlParse(event.serverUrl);
Alert.alert(
getLocalizedMessage(DEFAULT_LOCALE, t('server.invalid.certificate.title'), 'Invalid SSL certificate'),
getLocalizedMessage(
DEFAULT_LOCALE,
t('server.invalid.certificate.description'),
'The certificate for this server is invalid.\nYou might be connecting to a server that is pretending to be “{hostname}” which could put your confidential information at risk.',
).replace('{hostname}', parsed.hostname),
);
}
};
}

View file

@ -22,7 +22,6 @@ import {getThemeFromState} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {deleteFileCache, deleteFileCacheByDir} from '@utils/file';
import {isMainActivity} from '@utils/helpers';
import {invalidateKeychainCache} from '@utils/mattermost_managed';
import {addNewServer} from '@utils/server';
import type {LaunchType} from '@typings/launch';
@ -116,7 +115,6 @@ class SessionManager {
private terminateSession = async (serverUrl: string, removeServer: boolean) => {
cancelSessionNotification(serverUrl);
await removeServerCredentials(serverUrl);
invalidateKeychainCache(serverUrl);
PushNotifications.removeServerNotifications(serverUrl);
NetworkManager.invalidateClient(serverUrl);

View file

@ -147,7 +147,11 @@ class WebsocketManager {
if (error) {
client.close(false);
}
this.firstConnectionSynced[serverUrl] = true;
// Makes sure a client still exist, and therefore we haven't been logged out
if (this.clients[serverUrl]) {
this.firstConnectionSynced[serverUrl] = true;
}
}
}
};

View file

@ -67,6 +67,7 @@ const mockClient = {
enableChannelCalls: jest.fn(),
startCallRecording: jest.fn(),
stopCallRecording: jest.fn(),
dismissCall: jest.fn(),
};
jest.mock('@calls/connection/connection', () => ({
@ -98,7 +99,8 @@ jest.mock('react-native-navigation', () => ({
}));
const addFakeCall = (serverUrl: string, channelId: string) => {
const call = {
const call: Call = {
id: 'call',
participants: {
xohi8cki9787fgiryne716u84o: {id: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0},
xohi8cki9787fgiryne716u841: {id: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0},
@ -113,7 +115,8 @@ const addFakeCall = (serverUrl: string, channelId: string) => {
threadId: 'abcd1234567',
ownerId: 'xohi8cki9787fgiryne716u84o',
hostId: 'xohi8cki9787fgiryne716u84o',
} as Call;
dismissed: {},
};
act(() => {
State.setCallsState(serverUrl, {myUserId: 'myUserId', calls: {}, enabled: {}});
State.callStarted(serverUrl, call);
@ -158,7 +161,7 @@ describe('Actions.Calls', () => {
setCallsState('server1', DefaultCallsState);
setChannelsWithCalls('server1', {});
setCurrentCall(null);
setCallsConfig('server1', DefaultCallsConfig);
setCallsConfig('server1', {...DefaultCallsConfig, EnableRinging: true});
});
});
@ -385,4 +388,12 @@ describe('Actions.Calls', () => {
expect(needsRecordingErrorAlert).toBeCalled();
expect(needsRecordingWillBePostedAlert).toBeCalled();
});
it('dismissIncomingCall', async () => {
await act(async () => {
await CallsActions.dismissIncomingCall('server1', 'channel-id');
});
expect(mockClient.dismissCall).toBeCalledWith('channel-id');
});
});

View file

@ -7,21 +7,21 @@ import InCallManager from 'react-native-incall-manager';
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {updateThreadFollowing} from '@actions/remote/thread';
import {fetchUsersByIds} from '@actions/remote/user';
import {leaveAndJoinWithAlert, needsRecordingWillBePostedAlert, needsRecordingErrorAlert} from '@calls/alerts';
import {leaveAndJoinWithAlert, needsRecordingErrorAlert, needsRecordingWillBePostedAlert} from '@calls/alerts';
import {
getCallsConfig,
getCallsState,
getChannelsWithCalls,
getCurrentCall,
myselfLeftCall,
newCurrentCall,
setCallForChannel,
setCalls,
setChannelEnabled,
setConfig,
setPluginEnabled,
setScreenShareURL,
setSpeakerPhone,
setCallForChannel,
newCurrentCall,
myselfLeftCall,
getCurrentCall,
getChannelsWithCalls,
} from '@calls/state';
import {General, Preferences} from '@constants';
import Calls from '@constants/calls';
@ -39,12 +39,7 @@ import {displayUsername, getUserIdFromChannelName, isSystemAdmin} from '@utils/u
import {newConnection} from '../connection/connection';
import type {
AudioDevice,
Call,
CallParticipant,
CallsConnection,
} from '@calls/types/calls';
import type {AudioDevice, Call, CallParticipant, CallsConnection} from '@calls/types/calls';
import type {CallChannelState, CallState, EmojiData} from '@mattermost/calls/lib/types';
import type {IntlShape} from 'react-intl';
@ -150,12 +145,14 @@ const createCallAndAddToIds = (channelId: string, call: CallState, ids: Set<stri
return accum;
}, {} as Dictionary<CallParticipant>),
channelId,
id: call.id,
startTime: call.start_at,
screenOn: call.screen_sharing_id,
threadId: call.thread_id,
ownerId: call.owner_id,
hostId: call.host_id,
recState: call.recording,
dismissed: call.dismissed_notification || {},
} as Call;
};
@ -417,6 +414,21 @@ export const stopCallRecording = async (serverUrl: string, callId: string) => {
}
};
export const dismissIncomingCall = async (serverUrl: string, channelId: string) => {
if (!getCallsConfig(serverUrl).EnableRinging) {
return {};
}
try {
const client = NetworkManager.getClient(serverUrl);
return await client.dismissCall(channelId);
} catch (error) {
logDebug('error on dismissIncomingCall', getFullErrorMessage(error));
await forceLogoutIfNecessary(serverUrl, error);
return error;
}
};
// handleCallsSlashCommand will return true if the slash command was handled
export const handleCallsSlashCommand = async (value: string, serverUrl: string, channelId: string, rootId: string, currentUserId: string, intl: IntlShape):
Promise<{ handled?: boolean; error?: string }> => {

View file

@ -15,6 +15,7 @@ export {
handleCallsSlashCommand,
startCallRecording,
stopCallRecording,
dismissIncomingCall,
} from './calls';
export {hasMicrophonePermission} from './permissions';

View file

@ -4,12 +4,14 @@
import {Alert} from 'react-native';
import {hasMicrophonePermission, joinCall, leaveCall, unmuteMyself} from '@calls/actions';
import {dismissIncomingCall} from '@calls/actions/calls';
import {hasBluetoothPermission} from '@calls/actions/permissions';
import {
getCallsConfig,
getCallsState,
getChannelsWithCalls,
getCurrentCall,
removeIncomingCall,
setMicPermissionsGranted,
} from '@calls/state';
import {errorAlert} from '@calls/utils';
@ -199,6 +201,13 @@ const doJoinCall = async (
const hasPermission = await hasMicrophonePermission();
setMicPermissionsGranted(hasPermission);
if (!newCall && joinChannelIsDMorGM) {
// we're joining an existing call, so dismiss any notifications (for all clients, too)
const callId = getCallsState(serverUrl).calls[channelId].id;
dismissIncomingCall(serverUrl, channelId);
removeIncomingCall(serverUrl, callId, channelId);
}
const res = await joinCall(serverUrl, channelId, user.id, hasPermission, title, rootId);
if (res.error) {
const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'See server logs'});

View file

@ -15,6 +15,7 @@ export interface ClientCallsMix {
genTURNCredentials: () => Promise<RTCIceServer[]>;
startCallRecording: (callId: string) => Promise<ApiResp | CallRecordingState>;
stopCallRecording: (callId: string) => Promise<ApiResp | CallRecordingState>;
dismissCall: (channelId: string) => Promise<ApiResp>;
}
const ClientCalls = (superclass: any) => class extends superclass {
@ -85,6 +86,13 @@ const ClientCalls = (superclass: any) => class extends superclass {
{method: 'post'},
);
};
dismissCall = async (channelID: string) => {
return this.doFetch(
`${this.getCallsRoute()}/calls/${channelID}/dismiss-notification`,
{method: 'post'},
);
};
};
export default ClientCalls;

View file

@ -14,7 +14,7 @@ import {Device} from '@constants';
import {useTheme} from '@context/theme';
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {typography} from '@utils/typography';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
pressableStyle: StyleProp<ViewStyle>;
@ -23,13 +23,16 @@ type Props = {
currentCall: CurrentCall;
}
const style = {
bold: typography('Body', 200, 'SemiBold'),
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => ({
checkIcon: {
color: theme.buttonBg,
},
}));
export const AudioDeviceButton = ({pressableStyle, iconStyle, buttonTextStyle, currentCall}: Props) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const {bottom} = useSafeAreaInsets();
const isTablet = Device.IS_TABLET; // not `useIsTablet` because even if we're in splitView, we're still using a tablet.
const color = theme.awayIndicator;
@ -38,10 +41,14 @@ export const AudioDeviceButton = ({pressableStyle, iconStyle, buttonTextStyle, c
const tabletLabel = intl.formatMessage({id: 'mobile.calls_tablet', defaultMessage: 'Tablet'});
const speakerLabel = intl.formatMessage({id: 'mobile.calls_speaker', defaultMessage: 'SpeakerPhone'});
const bluetoothLabel = intl.formatMessage({id: 'mobile.calls_bluetooth', defaultMessage: 'Bluetooth'});
const headsetLabel = intl.formatMessage({id: 'mobile.calls_headset', defaultMessage: 'Headset'});
const deviceSelector = useCallback(async () => {
const currentDevice = audioDeviceInfo.selectedAudioDevice;
const available = audioDeviceInfo.availableAudioDeviceList;
let available = audioDeviceInfo.availableAudioDeviceList;
if (available.includes(AudioDevice.WiredHeadset)) {
available = available.filter((d) => d !== AudioDevice.Earpiece);
}
const selectDevice = (device: AudioDevice) => {
setPreferredAudioRoute(device);
dismissBottomSheet();
@ -52,34 +59,47 @@ export const AudioDeviceButton = ({pressableStyle, iconStyle, buttonTextStyle, c
<View>
{available.includes(AudioDevice.Earpiece) && isTablet &&
<SlideUpPanelItem
icon={'tablet'}
leftIcon={'tablet'}
onPress={() => selectDevice(AudioDevice.Earpiece)}
text={tabletLabel}
textStyles={currentDevice === AudioDevice.Earpiece ? {...style.bold, color} : {}}
rightIcon={currentDevice === AudioDevice.Earpiece ? 'check' : undefined}
rightIconStyles={currentDevice === AudioDevice.Earpiece ? style.checkIcon : {}}
/>
}
{available.includes(AudioDevice.Earpiece) && !isTablet &&
<SlideUpPanelItem
icon={'cellphone'}
leftIcon={'cellphone'}
onPress={() => selectDevice(AudioDevice.Earpiece)}
text={phoneLabel}
textStyles={currentDevice === AudioDevice.Earpiece ? {...style.bold, color} : {}}
rightIcon={currentDevice === AudioDevice.Earpiece ? 'check' : undefined}
rightIconStyles={currentDevice === AudioDevice.Earpiece ? style.checkIcon : {}}
/>
}
{available.includes(AudioDevice.Speakerphone) &&
<SlideUpPanelItem
icon={'volume-high'}
leftIcon={'volume-high'}
onPress={() => selectDevice(AudioDevice.Speakerphone)}
text={speakerLabel}
textStyles={currentDevice === AudioDevice.Speakerphone ? {...style.bold, color} : {}}
rightIcon={currentDevice === AudioDevice.Speakerphone ? 'check' : undefined}
rightIconStyles={currentDevice === AudioDevice.Speakerphone ? style.checkIcon : {}}
/>
}
{available.includes(AudioDevice.Bluetooth) &&
<SlideUpPanelItem
icon={'bluetooth'}
leftIcon={'bluetooth'}
onPress={() => selectDevice(AudioDevice.Bluetooth)}
text={bluetoothLabel}
textStyles={currentDevice === AudioDevice.Bluetooth ? {...style.bold, color} : {}}
rightIcon={currentDevice === AudioDevice.Bluetooth ? 'check' : undefined}
rightIconStyles={currentDevice === AudioDevice.Bluetooth ? style.checkIcon : {}}
/>
}
{available.includes(AudioDevice.WiredHeadset) &&
<SlideUpPanelItem
leftIcon={'headphones'}
onPress={() => selectDevice(AudioDevice.WiredHeadset)}
text={headsetLabel}
rightIcon={currentDevice === AudioDevice.WiredHeadset ? 'check' : undefined}
rightIconStyles={currentDevice === AudioDevice.WiredHeadset ? style.checkIcon : {}}
/>
}
</View>
@ -89,7 +109,7 @@ export const AudioDeviceButton = ({pressableStyle, iconStyle, buttonTextStyle, c
await bottomSheet({
closeButtonId: 'close-other-actions',
renderContent,
snapPoints: [1, bottomSheetSnapPoint(audioDeviceInfo.availableAudioDeviceList.length + 1, ITEM_HEIGHT, bottom)],
snapPoints: [1, bottomSheetSnapPoint(available.length + 1, ITEM_HEIGHT, bottom)],
title: intl.formatMessage({id: 'mobile.calls_audio_device', defaultMessage: 'Select audio device'}),
theme,
});
@ -106,6 +126,10 @@ export const AudioDeviceButton = ({pressableStyle, iconStyle, buttonTextStyle, c
icon = 'bluetooth';
label = bluetoothLabel;
break;
case AudioDevice.WiredHeadset:
icon = 'headphones';
label = headsetLabel;
break;
}
return (

View file

@ -17,7 +17,7 @@ import type UserModel from '@typings/database/models/servers/user';
type Props = {
userModel?: UserModel;
volume: number;
volume?: number;
serverUrl: string;
size: number;
muted?: boolean;
@ -149,7 +149,7 @@ const getStyleSheet = ({
});
};
const CallAvatar = ({userModel, volume, serverUrl, sharingScreen, size, muted, raisedHand, reaction}: Props) => {
const CallAvatar = ({userModel, volume = 0, serverUrl, sharingScreen, size, muted, raisedHand, reaction}: Props) => {
const theme = useTheme();
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
const style = useMemo(() => getStyleSheet({theme: callsTheme, volume, size}), [callsTheme, volume, size]);

View file

@ -0,0 +1,227 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import {useIntl} from 'react-intl';
import {Pressable, StyleSheet, Text, View} from 'react-native';
import {switchToChannelById} from '@actions/remote/channel';
import {fetchProfilesInChannel} from '@actions/remote/user';
import {dismissIncomingCall} from '@calls/actions/calls';
import {leaveAndJoinWithAlert} from '@calls/alerts';
import {removeIncomingCall} from '@calls/state';
import {ChannelType, type IncomingCallNotification} from '@calls/types/calls';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import ProfilePicture from '@components/profile_picture';
import {Preferences} from '@constants';
import {CALL_NOTIFICATION_BAR_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
import DatabaseManager from '@database/manager';
import WebsocketManager from '@managers/websocket_manager';
import ChannelMembershipModel from '@typings/database/models/servers/channel_membership';
import {changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import {displayUsername} from '@utils/user';
const style = StyleSheet.create({
outerContainer: {
backgroundColor: Preferences.THEMES.denim.onlineIndicator,
borderRadius: 8,
height: CALL_NOTIFICATION_BAR_HEIGHT,
marginLeft: 8,
marginRight: 8,
},
outerOnCallsScreen: {
backgroundColor: changeOpacity(Preferences.THEMES.denim.onlineIndicator, 0.40),
},
innerContainer: {
flexDirection: 'row',
width: '100%',
height: '100%',
paddingTop: 8,
paddingBottom: 8,
paddingLeft: 12,
paddingRight: 12,
borderRadius: 8,
borderWidth: 2,
borderStyle: 'solid',
borderColor: changeOpacity(Preferences.THEMES.denim.buttonColor, 0.16),
gap: 8,
alignItems: 'center',
backgroundColor: changeOpacity('#000', 0.16),
},
innerOnCallsScreen: {
borderColor: changeOpacity(Preferences.THEMES.denim.buttonColor, 0.16),
backgroundColor: changeOpacity('#000', 0.12),
},
text: {
flex: 1,
...typography('Body', 200),
lineHeight: 20,
color: Preferences.THEMES.denim.buttonColor,
},
boldText: {
...typography('Body', 200, 'SemiBold'),
lineHeight: 20,
},
join: {
flexDirection: 'row',
alignItems: 'flex-end',
height: 40,
gap: 7,
backgroundColor: Preferences.THEMES.denim.buttonColor,
paddingTop: 10,
paddingRight: 20,
paddingBottom: 10,
paddingLeft: 20,
borderRadius: 30,
},
joinOnCallsScreen: {
backgroundColor: changeOpacity(Preferences.THEMES.denim.buttonColor, 0.12),
},
joinLabel: {
...typography('Body', 100, 'SemiBold'),
},
joinIconLabel: {
color: Preferences.THEMES.denim.onlineIndicator,
},
joinIconLabelOnCallsScreen: {
color: Preferences.THEMES.denim.buttonColor,
},
dismiss: {
height: 40,
width: 40,
borderRadius: 20,
padding: 0,
backgroundColor: changeOpacity(Preferences.THEMES.denim.buttonColor, 0.08),
alignItems: 'center',
justifyContent: 'center',
},
dismissOnCallsScreen: {
backgroundColor: 'transparent',
},
dismissIcon: {
color: Preferences.THEMES.denim.buttonColor,
},
});
type Props = {
incomingCall: IncomingCallNotification;
currentUserId: string;
teammateNameDisplay: string;
members?: ChannelMembershipModel[];
onCallsScreen?: boolean;
}
export const CallNotification = ({
incomingCall,
currentUserId,
teammateNameDisplay,
members,
onCallsScreen,
}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
useEffect(() => {
const channelMembers = members?.filter((m) => m.userId !== currentUserId);
if (!channelMembers?.length) {
fetchProfilesInChannel(serverUrl, incomingCall.channelID, currentUserId, undefined, false);
}
}, []);
const onJoinPress = useCallback(() => {
leaveAndJoinWithAlert(intl, incomingCall.serverUrl, incomingCall.channelID);
}, [intl, incomingCall]);
const onContainerPress = useCallback(async () => {
if (serverUrl !== incomingCall.serverUrl) {
await DatabaseManager.setActiveServerDatabase(incomingCall.serverUrl);
await WebsocketManager.initializeClient(incomingCall.serverUrl);
}
switchToChannelById(incomingCall.serverUrl, incomingCall.channelID);
}, [serverUrl, incomingCall]);
const onDismissPress = useCallback(() => {
removeIncomingCall(serverUrl, incomingCall.callID, incomingCall.channelID);
dismissIncomingCall(incomingCall.serverUrl, incomingCall.channelID);
}, [incomingCall]);
let message: React.ReactElement;
if (incomingCall.type === ChannelType.DM) {
message = (
<FormattedText
id={'mobile.calls_incoming_dm'}
defaultMessage={'<b>{name}</b> is inviting you to a call'}
values={{
b: (text: string) => <Text style={style.boldText}>{text}</Text>,
name: displayUsername(incomingCall.callerModel, intl.locale, teammateNameDisplay),
}}
style={style.text}
numberOfLines={2}
ellipsizeMode={'tail'}
/>
);
} else {
message = (
<FormattedText
id={'mobile.calls_incoming_gm'}
defaultMessage={'<b>{name}</b> is inviting you to a call with <b>{num, plural, one {# other} other {# others}}</b>'}
values={{
b: (text: string) => <Text style={style.boldText}>{text}</Text>,
name: displayUsername(incomingCall.callerModel, intl.locale, teammateNameDisplay),
num: (members?.length || 2) - 1,
}}
style={style.text}
numberOfLines={2}
ellipsizeMode={'tail'}
/>
);
}
const joinLabel = (
<FormattedText
id={'mobile.calls_join_button'}
defaultMessage={'Join'}
style={[style.joinIconLabel, style.joinLabel, onCallsScreen && style.joinIconLabelOnCallsScreen]}
/>
);
return (
<View style={[style.outerContainer, onCallsScreen && style.outerOnCallsScreen]}>
<Pressable
style={[style.innerContainer, onCallsScreen && style.innerOnCallsScreen]}
onPress={onContainerPress}
>
<ProfilePicture
author={incomingCall.callerModel}
url={incomingCall.serverUrl}
size={32}
showStatus={false}
/>
{message}
<Pressable
style={[style.join, onCallsScreen && style.joinOnCallsScreen]}
onPress={onJoinPress}
>
<CompassIcon
name='phone-in-talk'
size={18}
style={[style.joinIconLabel, onCallsScreen && style.joinIconLabelOnCallsScreen]}
/>
{joinLabel}
</Pressable>
<Pressable
style={[style.dismiss, onCallsScreen && style.dismissOnCallsScreen]}
onPress={onDismissPress}
>
<CompassIcon
name={'close'}
size={24}
style={style.dismissIcon}
/>
</Pressable>
</Pressable>
</View>
);
};

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {CallNotification} from '@calls/components/call_notification/call_notification';
import DatabaseManager from '@database/manager';
import {observeChannelMembers} from '@queries/servers/channel';
import {observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import type {IncomingCallNotification} from '@calls/types/calls';
type OwnProps = {
incomingCall: IncomingCallNotification;
}
const enhanced = withObservables(['incomingCall'], ({incomingCall}: OwnProps) => {
const database = of$(DatabaseManager.serverDatabases[incomingCall.serverUrl]?.database);
const currentUserId = database.pipe(
switchMap((db) => (db ? observeCurrentUserId(db) : of$(''))),
distinctUntilChanged(),
);
const teammateNameDisplay = database.pipe(
switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
distinctUntilChanged(),
);
const members = database.pipe(
switchMap((db) => (db ? observeChannelMembers(db, incomingCall.channelID) : of$([]))),
distinctUntilChanged(),
);
return {
currentUserId,
teammateNameDisplay,
members,
};
});
export default enhanced(CallNotification);

View file

@ -39,10 +39,7 @@ type Props = {
const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => {
return {
wrapper: {
marginTop: 8,
marginRight: 6,
marginBottom: 8,
marginLeft: 6,
margin: 8,
backgroundColor: theme.callsBg,
borderRadius: 8,
},

View file

@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CallNotification from '@calls/components/call_notification';
import {useCurrentCall, useGlobalCallsState, useIncomingCalls} from '@calls/state';
import {
CALL_ERROR_BAR_HEIGHT,
CURRENT_CALL_BAR_HEIGHT,
DEFAULT_HEADER_HEIGHT,
JOIN_CALL_BAR_HEIGHT,
} from '@constants/view';
const topBarHeight = DEFAULT_HEADER_HEIGHT;
const style = StyleSheet.create({
wrapper: {
position: 'absolute',
width: '100%',
marginTop: 8,
gap: 8,
},
});
type Props = {
channelId: string;
showingJoinCallBanner: boolean;
showingCurrentCallBanner: boolean;
threadScreen?: boolean;
}
export const IncomingCallsContainer = ({
channelId,
showingJoinCallBanner,
showingCurrentCallBanner,
threadScreen,
}: Props) => {
const incomingCalls = useIncomingCalls().incomingCalls;
const insets = useSafeAreaInsets();
const micPermissionsGranted = useGlobalCallsState().micPermissionsGranted;
const currentCall = useCurrentCall();
// If we're in the channel for the incoming call, don't show the incoming call banner.
const calls = incomingCalls.filter((ic) => ic.channelID !== channelId);
if (calls.length === 0) {
return null;
}
const micPermissionsError = !micPermissionsGranted && (currentCall ? !currentCall.micPermissionsErrorDismissed : false);
const qualityAlert = showingCurrentCallBanner && (currentCall ? currentCall.callQualityAlert && currentCall.callQualityAlertDismissed === 0 : false);
const top = insets.top + (threadScreen ? 0 : topBarHeight) +
(showingJoinCallBanner ? JOIN_CALL_BAR_HEIGHT : 0) +
(showingCurrentCallBanner ? CURRENT_CALL_BAR_HEIGHT - 2 : 0) +
(micPermissionsError ? CALL_ERROR_BAR_HEIGHT + 8 : 0) +
(qualityAlert ? CALL_ERROR_BAR_HEIGHT + 8 : 0);
const wrapperTop = {top};
return (
<View style={[style.wrapper, wrapperTop]}>
{calls.map((ic) => (
<CallNotification
key={ic.callID}
incomingCall={ic}
/>
))}
</View>
);
};

View file

@ -28,7 +28,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => (
zIndex: 10,
},
errorWrapper: {
padding: 10,
padding: 8,
paddingTop: 0,
},
errorBar: {

View file

@ -3,7 +3,7 @@
import {RTCMonitor, RTCPeer} from '@mattermost/calls/lib';
import {deflate} from 'pako';
import {DeviceEventEmitter, type EmitterSubscription, Platform} from 'react-native';
import {DeviceEventEmitter, type EmitterSubscription, NativeEventEmitter, NativeModules, Platform} from 'react-native';
import InCallManager from 'react-native-incall-manager';
import {mediaDevices, MediaStream, MediaStreamTrack, RTCPeerConnection} from 'react-native-webrtc';
@ -24,6 +24,8 @@ import type {EmojiData} from '@mattermost/calls/lib/types';
const peerConnectTimeout = 5000;
const rtcMonitorInterval = 4000;
const InCallManagerEmitter = new NativeEventEmitter(NativeModules.InCallManager);
export async function newConnection(
serverUrl: string,
channelID: string,
@ -40,6 +42,7 @@ export async function newConnection(
let isClosed = false;
let onCallEnd: EmitterSubscription | null = null;
let audioDeviceChanged: EmitterSubscription | null = null;
let wiredHeadsetEvent: EmitterSubscription | null = null;
const streams: MediaStream[] = [];
let rtcMonitor: RTCMonitor | null = null;
const logger = {
@ -106,6 +109,7 @@ export async function newConnection(
peer = null;
InCallManager.stop();
audioDeviceChanged?.remove();
wiredHeadsetEvent?.remove();
if (closeCb) {
closeCb();
@ -224,29 +228,50 @@ export async function newConnection(
let btInitialized = false;
let speakerInitialized = false;
audioDeviceChanged = DeviceEventEmitter.addListener('onAudioDeviceChanged', (data: AudioDeviceInfoRaw) => {
const info: AudioDeviceInfo = {
availableAudioDeviceList: JSON.parse(data.availableAudioDeviceList),
selectedAudioDevice: data.selectedAudioDevice,
};
setAudioDeviceInfo(info);
if (Platform.OS === 'android') {
audioDeviceChanged = DeviceEventEmitter.addListener('onAudioDeviceChanged', (data: AudioDeviceInfoRaw) => {
const info: AudioDeviceInfo = {
availableAudioDeviceList: JSON.parse(data.availableAudioDeviceList),
selectedAudioDevice: data.selectedAudioDevice,
};
setAudioDeviceInfo(info);
logDebug('AudioDeviceChanged. info:', info);
// Auto switch to bluetooth the first time we connect to bluetooth, but not after.
if (!btInitialized) {
if (info.availableAudioDeviceList.includes(AudioDevice.Bluetooth)) {
setPreferredAudioRoute(AudioDevice.Bluetooth);
btInitialized = true;
} else if (!speakerInitialized) {
// If we don't have bluetooth available, default to speakerphone on.
setPreferredAudioRoute(AudioDevice.Speakerphone);
speakerInitialized = true;
// Auto switch to bluetooth the first time we connect to bluetooth, but not after.
if (!btInitialized) {
if (info.availableAudioDeviceList.includes(AudioDevice.Bluetooth)) {
setPreferredAudioRoute(AudioDevice.Bluetooth);
btInitialized = true;
} else if (!speakerInitialized) {
// If we don't have bluetooth available, default to speakerphone on.
setPreferredAudioRoute(AudioDevice.Speakerphone);
speakerInitialized = true;
}
}
}
});
});
}
// We default to speakerphone (Android is handled above in the onAudioDeviceChanged handler above).
// We default to speakerphone, but not if the WiredHeadset is plugged in.
if (Platform.OS === 'ios') {
setSpeakerphoneOn(true);
wiredHeadsetEvent = InCallManagerEmitter.addListener('WiredHeadset', (data) => {
// Log for customer debugging. For the moment we're not changing output labels because of incall-manager iOS
// limitations with how it reports Bluetooth -- namely that it doesn't, so we don't know when Bluetooth is
// overriding the earpiece and/or headset.
logDebug('WiredHeadset plugged in. Data:', data);
// iOS switches to the headset when we connect it, so turn off speakerphone to keep UI in sync.
if (data.isPlugged) {
setSpeakerphoneOn(false);
}
});
// If headset is plugged in when the call starts, use it.
const report = await InCallManager.getIsWiredHeadsetPluggedIn();
if (report.isWiredHeadsetPluggedIn) {
setSpeakerphoneOn(false);
} else {
setSpeakerphoneOn(true);
}
}
peer = new RTCPeer({

View file

@ -60,9 +60,9 @@ export class WebSocketClient extends EventEmitter {
};
this.ws.onclose = () => {
this.ws = null;
this.emit('close');
if (!this.closed) {
this.close();
this.reconnect();
}
};
@ -143,40 +143,39 @@ export class WebSocketClient extends EventEmitter {
}
close() {
if (this.ws) {
this.closed = true;
this.ws.close();
this.ws = null;
this.seqNo = 1;
this.serverSeqNo = 0;
this.connID = '';
this.originalConnID = '';
} else {
this.emit('close');
this.closed = true;
this.ws?.close();
this.ws = null;
this.seqNo = 1;
this.serverSeqNo = 0;
this.connID = '';
this.originalConnID = '';
}
const now = Date.now();
if (this.lastDisconnect === 0) {
this.lastDisconnect = now;
}
if ((now - this.lastDisconnect) >= wsReconnectionTimeout) {
this.closed = true;
this.emit('error', wsReconnectionTimeoutErr);
return;
}
setTimeout(() => {
if (!this.ws && !this.closed) {
this.init(true);
}
}, this.reconnectRetryTime);
this.reconnectRetryTime += wsReconnectTimeIncrement;
reconnect() {
const now = Date.now();
if (this.lastDisconnect === 0) {
this.lastDisconnect = now;
}
if ((now - this.lastDisconnect) >= wsReconnectionTimeout) {
this.closed = true;
this.emit('error', wsReconnectionTimeoutErr);
return;
}
setTimeout(() => {
if (!this.closed) {
logDebug(`calls: attempting ws reconnection to ${this.serverUrl + this.wsPath}`);
this.init(true);
}
}, this.reconnectRetryTime);
this.reconnectRetryTime += wsReconnectTimeIncrement;
}
state() {
if (!this.ws) {
if (this.closed || !this.ws) {
return WebSocket.CLOSED;
}
return this.ws.readyState;

View file

@ -7,6 +7,7 @@ import {fetchUsersByIds} from '@actions/remote/user';
import {
callEnded,
callStarted,
removeIncomingCall,
setCallScreenOff,
setCallScreenOn,
setChannelEnabled,
@ -21,14 +22,21 @@ import {
} from '@calls/state';
import {WebsocketEvents} from '@constants';
import DatabaseManager from '@database/manager';
import {getCurrentUserId} from '@queries/servers/system';
import type {
CallHostChangedData, CallRecordingStateData,
CallHostChangedData,
CallRecordingStateData,
CallStartData,
EmptyData,
UserConnectedData,
UserDisconnectedData, UserMutedUnmutedData, UserRaiseUnraiseHandData,
UserReactionData, UserScreenOnOffData, UserVoiceOnOffData,
UserDisconnectedData,
UserDismissedNotification,
UserMutedUnmutedData,
UserRaiseUnraiseHandData,
UserReactionData,
UserScreenOnOffData,
UserVoiceOnOffData,
} from '@mattermost/calls/lib/types';
export const handleCallUserConnected = (serverUrl: string, msg: WebSocketMessage<UserConnectedData>) => {
@ -59,12 +67,8 @@ export const handleCallUserVoiceOff = (msg: WebSocketMessage<UserVoiceOnOffData>
};
export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage<CallStartData>) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
callStarted(serverUrl, {
id: msg.data.id,
channelId: msg.data.channelID,
startTime: msg.data.start_at,
threadId: msg.data.thread_id,
@ -72,6 +76,7 @@ export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage<CallS
participants: {},
ownerId: msg.data.owner_id,
hostId: msg.data.host_id,
dismissed: {},
});
};
@ -118,3 +123,18 @@ export const handleCallRecordingState = (serverUrl: string, msg: WebSocketMessag
export const handleCallHostChanged = (serverUrl: string, msg: WebSocketMessage<CallHostChangedData>) => {
setHost(serverUrl, msg.broadcast.channel_id, msg.data.hostID);
};
export const handleUserDismissedNotification = async (serverUrl: string, msg: WebSocketMessage<UserDismissedNotification>) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
// For now we are only handling our own dismissed.
const myUserId = await getCurrentUserId(database);
if (myUserId !== msg.data.userID) {
return;
}
removeIncomingCall(serverUrl, msg.data.callID);
};

View file

@ -27,6 +27,7 @@ import {recordingAlert, recordingWillBePostedAlert, recordingErrorAlert} from '@
import {AudioDeviceButton} from '@calls/components/audio_device_button';
import CallAvatar from '@calls/components/call_avatar';
import CallDuration from '@calls/components/call_duration';
import CallNotification from '@calls/components/call_notification';
import CallsBadge, {CallsBadgeType} from '@calls/components/calls_badge';
import EmojiList from '@calls/components/emoji_list';
import MessageBar from '@calls/components/message_bar';
@ -34,7 +35,12 @@ import ReactionBar from '@calls/components/reaction_bar';
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
import {usePermissionsChecker} from '@calls/hooks';
import {RaisedHandBanner} from '@calls/screens/call_screen/raised_hand_banner';
import {setCallQualityAlertDismissed, setMicPermissionsErrorDismissed, useCallsConfig} from '@calls/state';
import {
setCallQualityAlertDismissed,
setMicPermissionsErrorDismissed,
useCallsConfig,
useIncomingCalls,
} from '@calls/state';
import {getHandsRaised, makeCallsTheme, sortParticipants} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
@ -177,6 +183,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => ({
marginTop: 0,
width: usernameM,
},
incomingCallContainer: {
width: '100%',
marginBottom: 8,
gap: 8,
},
buttonsContainer: {
alignItems: 'center',
},
@ -316,6 +327,8 @@ const CallScreen = ({
const serverUrl = useServerUrl();
const {EnableRecordings} = useCallsConfig(serverUrl);
usePermissionsChecker(micPermissionsGranted);
const incomingCalls = useIncomingCalls();
const [showControlsInLandscape, setShowControlsInLandscape] = useState(false);
const [showReactions, setShowReactions] = useState(false);
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
@ -330,6 +343,7 @@ const CallScreen = ({
const smallerAvatar = isLandscape || screenShareOn;
const avatarSize = smallerAvatar ? avatarM : avatarL;
const numParticipants = Object.keys(participantsDict).length;
const showIncomingCalls = incomingCalls.incomingCalls.length > 0;
const callThreadOptionTitle = intl.formatMessage({id: 'mobile.calls_call_thread', defaultMessage: 'Call Thread'});
const recordOptionTitle = intl.formatMessage({id: 'mobile.calls_record', defaultMessage: 'Record'});
@ -463,7 +477,7 @@ const CallScreen = ({
{
showStartRecording &&
<SlideUpPanelItem
icon={'record-circle-outline'}
leftIcon={'record-circle-outline'}
onPress={startRecording}
text={recordOptionTitle}
/>
@ -471,14 +485,14 @@ const CallScreen = ({
{
showStopRecording &&
<SlideUpPanelItem
icon={'record-square-outline'}
leftIcon={'record-square-outline'}
onPress={stopRecording}
text={stopRecordingOptionTitle}
textStyles={style.denimDND}
/>
}
<SlideUpPanelItem
icon='message-text-outline'
leftIcon='message-text-outline'
onPress={switchToThread}
text={callThreadOptionTitle}
/>
@ -498,9 +512,11 @@ const CallScreen = ({
recordOptionTitle, stopRecording, stopRecordingOptionTitle, style, switchToThread, callThreadOptionTitle,
openChannelOptionTitle]);
useAndroidHardwareBackHandler(componentId, () => {
const collapse = useCallback(() => {
popTopScreen(componentId);
});
}, [componentId]);
useAndroidHardwareBackHandler(componentId, collapse);
useEffect(() => {
const didDismissListener = Navigation.events().registerComponentDidDisappearListener(async ({componentId: screen}) => {
@ -651,7 +667,7 @@ const CallScreen = ({
teammateNameDisplay={teammateNameDisplay}
/>
<Pressable
onPress={() => popTopScreen()}
onPress={collapse}
style={style.collapseIconContainer}
>
<CompassIcon
@ -675,6 +691,17 @@ const CallScreen = ({
{!isLandscape && currentCall.reactionStream.length > 0 &&
<EmojiList reactionStream={currentCall.reactionStream}/>
}
{showIncomingCalls &&
<View style={style.incomingCallContainer}>
{incomingCalls.incomingCalls.map((ic) => (
<CallNotification
key={ic.callID}
incomingCall={ic}
onCallsScreen={true}
/>
))}
</View>
}
{micPermissionsError &&
<MessageBar
type={Calls.MessageBarType.Microphone}
@ -737,7 +764,6 @@ const CallScreen = ({
iconStyle={[
style.buttonIcon,
isLandscape && style.buttonIconLandscape,
style.speakerphoneIcon,
currentCall.speakerphoneOn && style.buttonOn,
]}
buttonTextStyle={style.buttonText}

View file

@ -8,9 +8,12 @@ import {act, renderHook} from '@testing-library/react-hooks';
import {needsRecordingAlert} from '@calls/alerts';
import {
newCurrentCall,
processIncomingCalls,
processMeanOpinionScore,
removeIncomingCall,
setAudioDeviceInfo,
setCallQualityAlertDismissed,
setCallsConfig,
setCallsState,
setChannelsWithCalls,
setCurrentCall,
@ -23,6 +26,7 @@ import {
useChannelsWithCalls,
useCurrentCall,
useGlobalCallsState,
useIncomingCalls,
userReacted,
} from '@calls/state';
import {
@ -52,6 +56,7 @@ import {
DefaultCallsState,
DefaultCurrentCall,
DefaultGlobalCallsState,
DefaultIncomingCalls,
type GlobalCallsState,
} from '@calls/types/calls';
import {License} from '@constants';
@ -75,6 +80,18 @@ jest.mock('@queries/servers/thread', () => ({
})),
}));
jest.mock('@queries/servers/channel', () => ({
getChannelById: jest.fn(() => Promise.resolve({
type: 'D',
})),
}));
jest.mock('@queries/servers/user', () => ({
getUserById: jest.fn(() => Promise.resolve({
username: 'user-5',
})),
}));
jest.mock('react-native-navigation', () => ({
Navigation: {
pop: jest.fn(() => Promise.resolve({
@ -84,6 +101,7 @@ jest.mock('react-native-navigation', () => ({
}));
const call1: Call = {
id: 'call1',
participants: {
'user-1': {id: 'user-1', muted: false, raisedHand: 0},
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
@ -94,8 +112,10 @@ const call1: Call = {
threadId: 'thread-1',
ownerId: 'user-1',
hostId: 'user-1',
dismissed: {},
};
const call2: Call = {
id: 'call2',
participants: {
'user-3': {id: 'user-3', muted: false, raisedHand: 0},
'user-4': {id: 'user-4', muted: true, raisedHand: 0},
@ -106,8 +126,10 @@ const call2: Call = {
threadId: 'thread-2',
ownerId: 'user-3',
hostId: 'user-3',
dismissed: {},
};
const call3: Call = {
id: 'call3',
participants: {
'user-5': {id: 'user-5', muted: false, raisedHand: 0},
'user-6': {id: 'user-6', muted: true, raisedHand: 0},
@ -118,6 +140,20 @@ const call3: Call = {
threadId: 'thread-3',
ownerId: 'user-5',
hostId: 'user-5',
dismissed: {},
};
const callDM: Call = {
id: 'callDM',
participants: {
'user-5': {id: 'user-5', muted: false, raisedHand: 0},
},
channelId: 'channel-private',
startTime: 123,
screenOn: '',
threadId: 'thread-4',
ownerId: 'user-5',
hostId: 'user-5',
dismissed: {},
};
describe('useCallsState', () => {
@ -153,7 +189,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], {});
});
it('setCalls, two callsState hooks, channelsWithCalls hook, ', () => {
it('setCalls, two callsState hooks, channelsWithCalls hook, ', async () => {
const initialCallsState = {
...DefaultCallsState,
calls: {'channel-1': call1},
@ -217,7 +253,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[3], initialCurrentCallState);
// test
act(() => setCalls('server1', 'myId', test.calls, test.enabled));
await act(async () => 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);
@ -242,6 +278,7 @@ describe('useCallsState', () => {
};
const expectedCallsState = {
'channel-1': {
id: 'call1',
participants: {
'user-1': {id: 'user-1', muted: false, raisedHand: 0},
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
@ -253,6 +290,7 @@ describe('useCallsState', () => {
threadId: 'thread-1',
ownerId: 'user-1',
hostId: 'user-1',
dismissed: {},
},
};
const expectedChannelsWithCallsState = initialChannelsWithCallsState;
@ -302,6 +340,7 @@ describe('useCallsState', () => {
};
const expectedCallsState = {
'channel-1': {
id: 'call1',
participants: {
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
},
@ -311,6 +350,7 @@ describe('useCallsState', () => {
threadId: 'thread-1',
ownerId: 'user-1',
hostId: 'user-1',
dismissed: {},
},
};
const expectedChannelsWithCallsState = initialChannelsWithCallsState;
@ -366,6 +406,7 @@ describe('useCallsState', () => {
};
const expectedCallsState = {
'channel-1': {
id: 'call1',
participants: {
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
},
@ -375,6 +416,7 @@ describe('useCallsState', () => {
ownerId: 'user-1',
hostId: 'user-1',
screenOn: '',
dismissed: {},
},
};
const expectedChannelsWithCallsState = initialChannelsWithCallsState;
@ -563,6 +605,7 @@ describe('useCallsState', () => {
};
const expectedCalls = {
'channel-1': {
id: 'call1',
participants: {
'user-1': {id: 'user-1', muted: false, raisedHand: 0},
'user-2': {id: 'user-2', muted: true, raisedHand: 345},
@ -573,6 +616,7 @@ describe('useCallsState', () => {
threadId: 'thread-1',
ownerId: 'user-1',
hostId: 'user-1',
dismissed: {},
},
};
const initialCurrentCallState: CurrentCall = {
@ -989,7 +1033,7 @@ describe('useCallsState', () => {
assert.deepEqual((result.current[1] as CurrentCall).callQualityAlert, true);
});
it('voiceOn and Off', () => {
it('voiceOn and Off', async () => {
const initialCallsState = {
...DefaultCallsState,
myUserId: 'myUserId',
@ -1025,7 +1069,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[0], initialCallsState);
// test that voice state is cleared on reconnect
act(() => setCalls('server1', 'myUserId', initialCallsState.calls, {}));
await act(() => setCalls('server1', 'myUserId', initialCallsState.calls, {}));
assert.deepEqual(result.current[1], initialCurrentCallState);
assert.deepEqual(result.current[0], initialCallsState);
});
@ -1248,4 +1292,111 @@ describe('useCallsState', () => {
act(() => setHost('server1', 'channel-1', 'myUserId'));
expect(needsRecordingAlert).toBeCalled();
});
it('incoming calls', async () => {
const calls = {'channel-dm': callDM};
const afterLoadCallsState: CallsState = {
myUserId: 'myId',
calls,
enabled: {},
};
const initialCurrentCallState: CurrentCall | null = null;
const initialIncomingCalls = DefaultIncomingCalls;
const expectedIncomingCalls = {
incomingCalls: [{
callID: 'callDM',
callerID: 'user-5',
callerModel: {username: 'user-5'},
channelID: 'channel-private',
myUserId: 'myId',
serverUrl: 'server1',
startAt: 123,
type: 0,
}],
};
const dismissedCalls = {
'channel-dm': {...callDM, dismissed: {myId: true}},
};
const callIStarted: Call = {
id: 'callIStartedid',
participants: {
'user-5': {id: 'user-5', muted: false, raisedHand: 0},
},
channelId: 'channel-private2',
startTime: 123,
screenOn: '',
threadId: 'thread-4',
ownerId: 'myId',
hostId: 'user-5',
dismissed: {},
};
const callImIn: Call = {
id: 'callImInId',
participants: {},
channelId: 'channel-private2',
startTime: 123,
screenOn: '',
threadId: 'thread-4',
ownerId: 'user-5',
hostId: 'user-5',
dismissed: {},
};
const currentCallStateImIn: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myId',
...callImIn,
};
// setup
await DatabaseManager.init(['server1']);
const {result} = renderHook(() => {
return [
useCallsState('server1'),
useCurrentCall(),
useIncomingCalls(),
];
});
act(() => {
setCallsConfig('server1', {...DefaultCallsConfig, EnableRinging: true});
});
assert.deepEqual(result.current[0], DefaultCallsState);
assert.deepEqual(result.current[1], initialCurrentCallState);
assert.deepEqual(result.current[2], initialIncomingCalls);
// test incoming call on load
await act(async () => setCalls('server1', 'myId', afterLoadCallsState.calls, {}));
assert.deepEqual(result.current[0], afterLoadCallsState);
assert.deepEqual(result.current[1], initialCurrentCallState);
assert.deepEqual(result.current[2], expectedIncomingCalls);
// test dismissing (same path for manually dismissing, joining that call,
// or receiving ws event from dismissing/joining from another client)
act(() => removeIncomingCall('server1', 'callDM'));
assert.deepEqual(result.current[0], afterLoadCallsState);
assert.deepEqual(result.current[1], initialCurrentCallState);
assert.deepEqual(result.current[2], DefaultIncomingCalls);
// test load call, but call has been dismissed
await act(async () => setCalls('server1', 'myId', dismissedCalls, {}));
assert.deepEqual(result.current[2], DefaultIncomingCalls);
// test load call, then load same call again (eg from ws event): should only have one notification
await act(async () => setCalls('server1', 'myId', afterLoadCallsState.calls, {}));
assert.deepEqual(result.current[2], expectedIncomingCalls);
await act(async () => processIncomingCalls('server1', [calls['channel-dm']]));
assert.deepEqual(result.current[2], expectedIncomingCalls);
// test received ws event for a call I started
await act(async () => processIncomingCalls('server1', [callIStarted]));
assert.deepEqual(result.current[2], expectedIncomingCalls);
// test received ws event for a call I am in
await act(async () => {
setCurrentCall(currentCallStateImIn);
await processIncomingCalls('server1', [callImIn]);
});
assert.deepEqual(result.current[1], currentCallStateImIn);
assert.deepEqual(result.current[2], expectedIncomingCalls);
});
});

View file

@ -12,30 +12,37 @@ import {
getChannelsWithCalls,
getCurrentCall,
getGlobalCallsState,
getIncomingCalls,
setCallsConfig,
setCallsState,
setChannelsWithCalls,
setCurrentCall,
setGlobalCallsState,
setIncomingCalls,
} from '@calls/state';
import {
type AudioDeviceInfo,
type Call,
type CallsConfigState,
type ChannelsWithCalls,
ChannelType,
type CurrentCall,
DefaultCall,
DefaultCurrentCall,
type IncomingCallNotification,
type ReactionStreamEmoji,
} from '@calls/types/calls';
import {Calls, Screens} from '@constants';
import {Calls, General, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {getChannelById} from '@queries/servers/channel';
import {getThreadById} from '@queries/servers/thread';
import {getUserById} from '@queries/servers/user';
import {isDMorGM} from '@utils/channel';
import {logDebug} from '@utils/log';
import type {CallRecordingState, UserReactionData} from '@mattermost/calls/lib/types';
export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary<Call>, enabled: Dictionary<boolean>) => {
export const setCalls = async (serverUrl: string, myUserId: string, calls: Dictionary<Call>, enabled: Dictionary<boolean>) => {
const channelsWithCalls = Object.keys(calls).reduce(
(accum, next) => {
accum[next] = true;
@ -45,6 +52,8 @@ export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary<
setCallsState(serverUrl, {myUserId, calls, enabled});
await processIncomingCalls(serverUrl, Object.values(calls), false);
// Does the current call need to be updated?
const currentCall = getCurrentCall();
if (!currentCall || !calls[currentCall.channelId]) {
@ -61,6 +70,118 @@ export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary<
setCurrentCall(nextCall);
};
export const processIncomingCalls = async (serverUrl: string, calls: Call[], keepExisting = true) => {
if (!getCallsConfig(serverUrl).EnableRinging) {
return;
}
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return;
}
// Do we have incoming calls we should notify about?
const incomingCalls = getIncomingCalls().incomingCalls;
const existingCalls = getCallsState(serverUrl).calls;
const myUserId = getCallsState(serverUrl).myUserId;
const newIncoming: IncomingCallNotification[] = [];
for await (const call of calls) {
// dismissed already?
if (call.dismissed[myUserId] || existingCalls[call.channelId]?.dismissed[myUserId]) {
continue;
}
// already in our incomingCalls notifications?
if (incomingCalls.findIndex((c) => c.callID === call.id) >= 0) {
continue;
}
// Never send a notification for a call you started, or a call you are currently in.
if (myUserId === call.ownerId || getCurrentCall()?.id === call.id) {
continue;
}
const channel = await getChannelById(database, call.channelId);
if (!channel) {
logDebug('calls: processIncomingCalls could not find channel by id', call.channelId, 'for serverUrl', serverUrl);
continue;
}
if (!isDMorGM(channel)) {
continue;
}
const callerModel = await getUserById(database, call.ownerId);
newIncoming.push({
serverUrl,
myUserId,
callID: call.id,
callerID: call.ownerId,
callerModel,
channelID: call.channelId,
startAt: call.startTime,
type: channel.type === General.DM_CHANNEL ? ChannelType.DM : ChannelType.GM,
});
}
if (newIncoming.length === 0 && keepExisting) {
return;
}
if (keepExisting) {
newIncoming.push(...incomingCalls);
} else {
const removedThisServer = incomingCalls.filter((ic) => ic.serverUrl !== serverUrl);
newIncoming.push(...removedThisServer);
}
if (newIncoming.length === 0 && incomingCalls.length === 0) {
return;
}
newIncoming.sort((a, b) => a.startAt - b.startAt);
setIncomingCalls({incomingCalls: newIncoming});
};
const getChannelIdFromCallId = (serverUrl: string, callId: string) => {
const callsState = getCallsState(serverUrl);
for (const call of Object.values(callsState.calls)) {
if (call.id === callId) {
return call.channelId;
}
}
return undefined;
};
export const removeIncomingCall = (serverUrl: string, callId: string, channelId?: string) => {
if (!getCallsConfig(serverUrl).EnableRinging) {
return;
}
const incomingCalls = getIncomingCalls();
const newIncomingCalls = incomingCalls.incomingCalls.filter((ic) => ic.callID !== callId);
if (incomingCalls.incomingCalls.length !== newIncomingCalls.length) {
setIncomingCalls({incomingCalls: newIncomingCalls});
}
let chId = channelId;
if (!chId) {
chId = getChannelIdFromCallId(serverUrl, callId);
if (!chId) {
return;
}
}
const callsState = getCallsState(serverUrl);
const nextCalls = {...callsState.calls};
if (nextCalls[chId]) {
nextCalls[chId].dismissed[callsState.myUserId] = true;
}
setCallsState(serverUrl, {...callsState, calls: nextCalls});
};
export const setCallForChannel = (serverUrl: string, channelId: string, enabled?: boolean, call?: Call) => {
const callsState = getCallsState(serverUrl);
let nextEnabled = callsState.enabled;
@ -136,6 +257,10 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str
setCurrentCall(nextCurrentCall);
}
if (userId === callsState.myUserId) {
removeIncomingCall(serverUrl, callsState.calls[channelId].id, channelId);
}
};
export const userLeftCall = (serverUrl: string, channelId: string, userId: string) => {
@ -159,6 +284,9 @@ export const userLeftCall = (serverUrl: string, channelId: string, userId: strin
if (Object.keys(nextCall.participants).length === 0) {
delete nextCalls[channelId];
const callId = callsState.calls[channelId].id;
removeIncomingCall(serverUrl, callId, channelId);
const channelsWithCalls = getChannelsWithCalls(serverUrl);
const nextChannelsWithCalls = {...channelsWithCalls};
delete nextChannelsWithCalls[channelId];
@ -230,6 +358,8 @@ export const callStarted = async (serverUrl: string, call: Call) => {
nextCalls[call.channelId] = call;
setCallsState(serverUrl, {...callsState, calls: nextCalls});
await processIncomingCalls(serverUrl, [call]);
const nextChannelsWithCalls = {...getChannelsWithCalls(serverUrl), [call.channelId]: true};
setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
@ -263,6 +393,7 @@ export const callStarted = async (serverUrl: string, call: Call) => {
export const callEnded = (serverUrl: string, channelId: string) => {
const callsState = getCallsState(serverUrl);
const nextCalls = {...callsState.calls};
const callId = nextCalls[channelId]?.id || '';
delete nextCalls[channelId];
setCallsState(serverUrl, {...callsState, calls: nextCalls});
@ -271,6 +402,8 @@ export const callEnded = (serverUrl: string, channelId: string) => {
delete nextChannelsWithCalls[channelId];
setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
removeIncomingCall(serverUrl, callId, channelId);
// currentCall is set to null by the disconnect.
};

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