Merge branch 'main' into i18n-vi
This commit is contained in:
commit
3d3934e37b
286 changed files with 21219 additions and 7876 deletions
|
|
@ -61,6 +61,7 @@
|
|||
"afterColon": true
|
||||
}}],
|
||||
"@typescript-eslint/member-delimiter-style": 2,
|
||||
"@typescript-eslint/no-unsafe-declaration-merging": "off",
|
||||
"import/order": [
|
||||
2,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
18.7.0
|
||||
18.17
|
||||
|
|
|
|||
1
.npmrc
1
.npmrc
|
|
@ -1 +1,2 @@
|
|||
save-exact=true
|
||||
engine-strict=true
|
||||
|
|
|
|||
2
.nvmrc
2
.nvmrc
|
|
@ -1 +1 @@
|
|||
18.7.0
|
||||
18.17
|
||||
|
|
|
|||
20
.solidarity
20
.solidarity
|
|
@ -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"
|
||||
}
|
||||
],
|
||||
|
|
|
|||
78
NOTICE.txt
78
NOTICE.txt
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: []};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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('@', '');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'})}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const enhance = withObservables(['channelName'], ({channelName, database}: {chan
|
|||
);
|
||||
|
||||
return {
|
||||
authorId,
|
||||
author,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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'})}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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'})}
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>>);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
4
app/constants/versions.ts
Normal file
4
app/constants/versions.ts
Normal 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];
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
};
|
||||
|
|
@ -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)
|
||||
);
|
||||
};
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const useAndroidHardwareBackHandler = (componentId: AvailableScreens | undefined
|
|||
return () => {
|
||||
backHandler.remove();
|
||||
};
|
||||
}, [componentId]);
|
||||
}, [componentId, callback]);
|
||||
};
|
||||
|
||||
export default useAndroidHardwareBackHandler;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }> => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export {
|
|||
handleCallsSlashCommand,
|
||||
startCallRecording,
|
||||
stopCallRecording,
|
||||
dismissIncomingCall,
|
||||
} from './calls';
|
||||
|
||||
export {hasMicrophonePermission} from './permissions';
|
||||
|
|
|
|||
|
|
@ -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'});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
42
app/products/calls/components/call_notification/index.ts
Normal file
42
app/products/calls/components/call_notification/index.ts
Normal 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);
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
71
app/products/calls/components/incoming_calls_container.tsx
Normal file
71
app/products/calls/components/incoming_calls_container.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -28,7 +28,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => (
|
|||
zIndex: 10,
|
||||
},
|
||||
errorWrapper: {
|
||||
padding: 10,
|
||||
padding: 8,
|
||||
paddingTop: 0,
|
||||
},
|
||||
errorBar: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue