Merge branch 'main' into MM-50354-not-show-push-notifications-warning-if-disabled
This commit is contained in:
commit
499114f4f9
239 changed files with 19665 additions and 7568 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 480
|
||||
versionName "2.7.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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {getActiveServerUrl} from '@app/init/credentials';
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -249,13 +249,14 @@ export async function createChannel(serverUrl: string, displayName: string, purp
|
|||
}
|
||||
}
|
||||
|
||||
export async function patchChannel(serverUrl: string, channelPatch: Partial<Channel> & {id: string}) {
|
||||
export async function patchChannel(serverUrl: string, channelId: string, channelPatch: ChannelPatch) {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
||||
const channelData = await client.patchChannel(channelPatch.id, channelPatch);
|
||||
const channelData = await client.patchChannel(channelId, channelPatch);
|
||||
const models = [];
|
||||
|
||||
const channelInfo = (await getChannelInfo(database, channelData.id));
|
||||
if (channelInfo && (channelInfo.purpose !== channelData.purpose || channelInfo.header !== channelData.header)) {
|
||||
channelInfo.prepareUpdate((v) => {
|
||||
|
|
@ -264,10 +265,14 @@ export async function patchChannel(serverUrl: string, channelPatch: Partial<Chan
|
|||
});
|
||||
models.push(channelInfo);
|
||||
}
|
||||
|
||||
const channel = await getChannelById(database, channelData.id);
|
||||
if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type)) {
|
||||
channel.prepareUpdate((v) => {
|
||||
v.displayName = channelData.display_name;
|
||||
// DM and GM display names cannot be patched and are formatted client-side; do not overwrite
|
||||
if (channelData.type !== General.DM_CHANNEL && channelData.type !== General.GM_CHANNEL) {
|
||||
v.displayName = channelData.display_name;
|
||||
}
|
||||
v.type = channelData.type;
|
||||
});
|
||||
models.push(channel);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -600,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);
|
||||
|
|
@ -668,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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
handleCallUserUnraiseHand,
|
||||
handleCallUserVoiceOff,
|
||||
handleCallUserVoiceOn,
|
||||
handleUserDismissedNotification,
|
||||
} from '@calls/connection/websocket_event_handlers';
|
||||
import {isSupportedServerCalls} from '@calls/utils';
|
||||
import {Screens, WebsocketEvents} from '@constants';
|
||||
|
|
@ -58,6 +59,7 @@ import {handleChannelConvertedEvent, handleChannelCreatedEvent,
|
|||
handleChannelUnarchiveEvent,
|
||||
handleChannelUpdatedEvent,
|
||||
handleChannelViewedEvent,
|
||||
handleMultipleChannelsViewedEvent,
|
||||
handleDirectAddedEvent,
|
||||
handleUserAddedToChannelEvent,
|
||||
handleUserRemovedFromChannelEvent} from './channel';
|
||||
|
|
@ -254,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;
|
||||
|
|
@ -387,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);
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ const ClientChannels = <TBase extends Constructor<ClientBase>>(superclass: TBase
|
|||
);
|
||||
};
|
||||
|
||||
patchChannel = async (channelId: string, channelPatch: Partial<Channel>) => {
|
||||
patchChannel = async (channelId: string, channelPatch: ChannelPatch) => {
|
||||
this.analytics?.trackAPI('api_channels_patch', {channel_id: channelId});
|
||||
|
||||
return this.doFetch(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'})}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AttachmentAuthor it matches snapshot when both name and icon are provided 1`] = `
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"height": 12,
|
||||
"marginRight": 3,
|
||||
"width": 12,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<FastImageView
|
||||
defaultSource={null}
|
||||
resizeMode="cover"
|
||||
source={
|
||||
{
|
||||
"uri": "https://images.com/image.png",
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
onPress={[Function]}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "rgba(63,67,80,0.5)",
|
||||
"fontSize": 11,
|
||||
},
|
||||
{
|
||||
"color": "rgba(28,88,217,0.5)",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
jhondoe
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`AttachmentAuthor it matches snapshot when only icon is provided 1`] = `
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"height": 12,
|
||||
"marginRight": 3,
|
||||
"width": 12,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<FastImageView
|
||||
defaultSource={null}
|
||||
resizeMode="cover"
|
||||
source={
|
||||
{
|
||||
"uri": "https://images.com/image.png",
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`AttachmentAuthor it matches snapshot when only name is provided 1`] = `
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
onPress={[Function]}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "rgba(63,67,80,0.5)",
|
||||
"fontSize": 11,
|
||||
},
|
||||
{
|
||||
"color": "rgba(28,88,217,0.5)",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
jhondoe
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Preferences from '@constants/preferences';
|
||||
import {renderWithIntl} from '@test/intl-test-helper';
|
||||
|
||||
import AttachmentAuthor from './attachment_author';
|
||||
|
||||
describe('AttachmentAuthor', () => {
|
||||
const baseProps = {
|
||||
link: 'http://linktoatachment.com',
|
||||
name: 'jhondoe',
|
||||
icon: 'https://images.com/image.png',
|
||||
theme: Preferences.THEMES.denim,
|
||||
};
|
||||
|
||||
test('it matches snapshot when both name and icon are provided', () => {
|
||||
const wrapper = renderWithIntl(<AttachmentAuthor {...baseProps}/>);
|
||||
expect(wrapper.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('it matches snapshot when only name is provided', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
icon: undefined,
|
||||
};
|
||||
|
||||
const wrapper = renderWithIntl(<AttachmentAuthor {...props}/>);
|
||||
expect(wrapper.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('it matches snapshot when only icon is provided', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
name: undefined,
|
||||
};
|
||||
|
||||
const wrapper = renderWithIntl(<AttachmentAuthor {...props}/>);
|
||||
expect(wrapper.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -80,7 +80,7 @@ export default function MessageAttachment({attachment, channelId, layoutWidth, l
|
|||
value={attachment.pretext}
|
||||
/>
|
||||
<View style={[style.container, style.border, borderStyle]}>
|
||||
{Boolean(attachment.author_icon && attachment.author_name) &&
|
||||
{Boolean(attachment.author_icon || attachment.author_name) &&
|
||||
<AttachmentAuthor
|
||||
icon={attachment.author_icon}
|
||||
link={attachment.author_link}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,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[];
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -74,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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
};
|
||||
|
||||
|
|
|
|||
37
app/products/calls/state/incoming_calls.ts
Normal file
37
app/products/calls/state/incoming_calls.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {BehaviorSubject} from 'rxjs';
|
||||
|
||||
import {DefaultIncomingCalls, type IncomingCalls} from '@calls/types/calls';
|
||||
|
||||
const incomingCallsSubject = new BehaviorSubject(DefaultIncomingCalls);
|
||||
|
||||
export const getIncomingCalls = () => {
|
||||
return incomingCallsSubject.value;
|
||||
};
|
||||
|
||||
export const setIncomingCalls = (state: IncomingCalls) => {
|
||||
incomingCallsSubject.next(state);
|
||||
};
|
||||
|
||||
export const observeIncomingCalls = () => {
|
||||
return incomingCallsSubject.asObservable();
|
||||
};
|
||||
|
||||
export const useIncomingCalls = () => {
|
||||
const [state, setState] = useState(DefaultIncomingCalls);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = incomingCallsSubject.subscribe((incomingCalls) => {
|
||||
setState(incomingCalls);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
|
@ -7,3 +7,4 @@ export * from './calls_config';
|
|||
export * from './current_call';
|
||||
export * from './channels_with_calls';
|
||||
export * from './global_calls_state';
|
||||
export * from './incoming_calls';
|
||||
|
|
|
|||
|
|
@ -24,7 +24,32 @@ export const DefaultCallsState: CallsState = {
|
|||
enabled: {},
|
||||
};
|
||||
|
||||
export enum ChannelType {
|
||||
DM,
|
||||
GM
|
||||
}
|
||||
|
||||
export type IncomingCallNotification = {
|
||||
serverUrl: string;
|
||||
myUserId: string;
|
||||
callID: string;
|
||||
channelID: string;
|
||||
callerID: string;
|
||||
callerModel?: UserModel;
|
||||
startAt: number;
|
||||
type: ChannelType;
|
||||
}
|
||||
|
||||
export type IncomingCalls = {
|
||||
incomingCalls: IncomingCallNotification[];
|
||||
}
|
||||
|
||||
export const DefaultIncomingCalls: IncomingCalls = {
|
||||
incomingCalls: [],
|
||||
};
|
||||
|
||||
export type Call = {
|
||||
id: string;
|
||||
participants: Dictionary<CallParticipant>;
|
||||
channelId: string;
|
||||
startTime: number;
|
||||
|
|
@ -33,9 +58,11 @@ export type Call = {
|
|||
ownerId: string;
|
||||
recState?: CallRecordingState;
|
||||
hostId: string;
|
||||
dismissed: Dictionary<boolean>;
|
||||
}
|
||||
|
||||
export const DefaultCall: Call = {
|
||||
id: '',
|
||||
participants: {},
|
||||
channelId: '',
|
||||
startTime: 0,
|
||||
|
|
@ -43,6 +70,7 @@ export const DefaultCall: Call = {
|
|||
threadId: '',
|
||||
ownerId: '',
|
||||
hostId: '',
|
||||
dismissed: {},
|
||||
};
|
||||
|
||||
export enum AudioDevice {
|
||||
|
|
@ -123,6 +151,7 @@ export const DefaultCallsConfig: CallsConfigState = {
|
|||
MaxRecordingDuration: 60,
|
||||
AllowScreenSharing: true,
|
||||
EnableSimulcast: false,
|
||||
EnableRinging: false,
|
||||
};
|
||||
|
||||
export type ApiResp = {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls/lib/utils';
|
||||
import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls';
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import {Calls, Post} from '@constants';
|
||||
|
|
|
|||
18
app/queries/servers/features.ts
Normal file
18
app/queries/servers/features.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {GM_AS_DM_VERSION} from '@constants/versions';
|
||||
import {isMinimumServerVersion} from '@utils/helpers';
|
||||
|
||||
import {observeConfigValue} from './system';
|
||||
|
||||
import type {Database} from '@nozbe/watermelondb';
|
||||
|
||||
export const observeHasGMasDMFeature = (database: Database) => {
|
||||
return observeConfigValue(database, 'Version').pipe(
|
||||
switchMap((v) => of$(isMinimumServerVersion(v, ...GM_AS_DM_VERSION))),
|
||||
);
|
||||
};
|
||||
|
|
@ -101,3 +101,17 @@ export function observeCanManageChannelMembers(database: Database, channelId: st
|
|||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeCanManageChannelSettings(database: Database, channelId: string, user: UserModel) {
|
||||
return observeChannel(database, channelId).pipe(
|
||||
switchMap((c) => {
|
||||
if (!c || c.deleteAt !== 0 || isDMorGM(c)) {
|
||||
return of$(false);
|
||||
}
|
||||
|
||||
const permission = c.type === General.OPEN_CHANNEL ? Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES : Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES;
|
||||
return observePermissionForChannel(database, c, user, permission, true);
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,11 +42,6 @@ export default function DropdownSlideup({
|
|||
const style = getStyleFromTheme(theme);
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
const commonProps = {
|
||||
rightIcon: true,
|
||||
iconStyles: style.checkIcon,
|
||||
};
|
||||
|
||||
const handlePublicPress = useCallback(() => {
|
||||
dismissBottomSheet();
|
||||
onPress(PUBLIC);
|
||||
|
|
@ -73,16 +68,16 @@ export default function DropdownSlideup({
|
|||
onPress={handlePublicPress}
|
||||
testID='browse_channels.dropdown_slideup_item.public_channels'
|
||||
text={intl.formatMessage({id: 'browse_channels.publicChannels', defaultMessage: 'Public Channels'})}
|
||||
icon={selected === PUBLIC ? 'check' : undefined}
|
||||
{...commonProps}
|
||||
rightIcon={selected === PUBLIC ? 'check' : undefined}
|
||||
rightIconStyles={style.checkIcon}
|
||||
/>
|
||||
{canShowArchivedChannels && (
|
||||
<SlideUpPanelItem
|
||||
onPress={handleArchivedPress}
|
||||
testID='browse_channels.dropdown_slideup_item.archived_channels'
|
||||
text={intl.formatMessage({id: 'browse_channels.archivedChannels', defaultMessage: 'Archived Channels'})}
|
||||
icon={selected === ARCHIVED ? 'check' : undefined}
|
||||
{...commonProps}
|
||||
rightIcon={selected === ARCHIVED ? 'check' : undefined}
|
||||
rightIconStyles={style.checkIcon}
|
||||
/>
|
||||
)}
|
||||
{sharedChannelsEnabled && (
|
||||
|
|
@ -90,8 +85,8 @@ export default function DropdownSlideup({
|
|||
onPress={handleSharedPress}
|
||||
testID='browse_channels.dropdown_slideup_item.shared_channels'
|
||||
text={intl.formatMessage({id: 'browse_channels.sharedChannels', defaultMessage: 'Shared Channels'})}
|
||||
icon={selected === SHARED ? 'check' : undefined}
|
||||
{...commonProps}
|
||||
rightIcon={selected === SHARED ? 'check' : undefined}
|
||||
rightIconStyles={style.checkIcon}
|
||||
/>
|
||||
)}
|
||||
</BottomSheetContent>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area
|
|||
|
||||
import {storeLastViewedChannelIdAndServer, removeLastViewedChannelIdAndServer} from '@actions/app/global';
|
||||
import FloatingCallContainer from '@calls/components/floating_call_container';
|
||||
import {IncomingCallsContainer} from '@calls/components/incoming_calls_container';
|
||||
import {RoundedHeaderCalls} from '@calls/components/join_call_banner/rounded_header_calls';
|
||||
import FreezeScreen from '@components/freeze_screen';
|
||||
import PostDraft from '@components/post_draft';
|
||||
|
|
@ -23,18 +24,24 @@ import EphemeralStore from '@store/ephemeral_store';
|
|||
|
||||
import ChannelPostList from './channel_post_list';
|
||||
import ChannelHeader from './header';
|
||||
import useGMasDMNotice from './use_gm_as_dm_notice';
|
||||
|
||||
import type PreferenceModel from '@typings/database/models/servers/preference';
|
||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||
import type {KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view';
|
||||
|
||||
type ChannelProps = {
|
||||
channelId: string;
|
||||
componentId?: AvailableScreens;
|
||||
isCallInCurrentChannel: boolean;
|
||||
showJoinCallBanner: boolean;
|
||||
isInACall: boolean;
|
||||
isInCurrentChannelCall: boolean;
|
||||
isCallsEnabledInChannel: boolean;
|
||||
showIncomingCalls: boolean;
|
||||
isTabletView?: boolean;
|
||||
dismissedGMasDMNotice: PreferenceModel[];
|
||||
currentUserId: string;
|
||||
channelType: ChannelType;
|
||||
hasGMasDMFeature: boolean;
|
||||
};
|
||||
|
||||
const edges: Edge[] = ['left', 'right'];
|
||||
|
|
@ -49,12 +56,17 @@ const styles = StyleSheet.create({
|
|||
const Channel = ({
|
||||
channelId,
|
||||
componentId,
|
||||
isCallInCurrentChannel,
|
||||
showJoinCallBanner,
|
||||
isInACall,
|
||||
isInCurrentChannelCall,
|
||||
isCallsEnabledInChannel,
|
||||
showIncomingCalls,
|
||||
isTabletView,
|
||||
dismissedGMasDMNotice,
|
||||
channelType,
|
||||
currentUserId,
|
||||
hasGMasDMFeature,
|
||||
}: ChannelProps) => {
|
||||
useGMasDMNotice(currentUserId, channelType, dismissedGMasDMNotice, hasGMasDMFeature);
|
||||
const isTablet = useIsTablet();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [shouldRenderPosts, setShouldRenderPosts] = useState(false);
|
||||
|
|
@ -64,9 +76,9 @@ const Channel = ({
|
|||
const postDraftRef = useRef<KeyboardTrackingViewRef>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const shouldRender = !switchingTeam && !switchingChannels && shouldRenderPosts && Boolean(channelId);
|
||||
const handleBack = () => {
|
||||
const handleBack = useCallback(() => {
|
||||
popTopScreen(componentId);
|
||||
};
|
||||
}, [componentId]);
|
||||
|
||||
useKeyboardTrackingPaused(postDraftRef, channelId, trackKeyboardForScreens);
|
||||
useAndroidHardwareBackHandler(componentId, handleBack);
|
||||
|
|
@ -98,8 +110,7 @@ const Channel = ({
|
|||
setContainerHeight(e.nativeEvent.layout.height);
|
||||
}, []);
|
||||
|
||||
const showJoinCallBanner = isCallInCurrentChannel && !isInCurrentChannelCall;
|
||||
const renderCallsComponents = showJoinCallBanner || isInACall;
|
||||
const showFloatingCallContainer = showJoinCallBanner || isInACall;
|
||||
|
||||
return (
|
||||
<FreezeScreen>
|
||||
|
|
@ -139,13 +150,20 @@ const Channel = ({
|
|||
/>
|
||||
</>
|
||||
}
|
||||
{renderCallsComponents &&
|
||||
{showFloatingCallContainer &&
|
||||
<FloatingCallContainer
|
||||
channelId={channelId}
|
||||
showJoinCallBanner={showJoinCallBanner}
|
||||
isInACall={isInACall}
|
||||
/>
|
||||
}
|
||||
{showIncomingCalls &&
|
||||
<IncomingCallsContainer
|
||||
channelId={channelId}
|
||||
showingJoinCallBanner={showJoinCallBanner}
|
||||
showingCurrentCallBanner={isInACall}
|
||||
/>
|
||||
}
|
||||
</SafeAreaView>
|
||||
</FreezeScreen>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,10 +19,11 @@ import EphemeralStore from '@store/ephemeral_store';
|
|||
import Intro from './intro';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type {AnimatedStyle} from 'react-native-reanimated';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<AnimatedStyle<ViewStyle>>;
|
||||
isCRTEnabled: boolean;
|
||||
lastViewedAt: number;
|
||||
nativeID: string;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useMemo} from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
import {defineMessages} from 'react-intl';
|
||||
import {Text, View, type TextStyle} from 'react-native';
|
||||
|
||||
import {fetchProfilesInChannel} from '@actions/remote/user';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {BotTag} from '@components/tag';
|
||||
import {General} from '@constants';
|
||||
import {General, NotificationLevel} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
|
@ -27,6 +28,9 @@ type Props = {
|
|||
isBot: boolean;
|
||||
members?: ChannelMembershipModel[];
|
||||
theme: Theme;
|
||||
hasGMasDMFeature: boolean;
|
||||
channelNotifyProps?: Partial<ChannelNotifyProps>;
|
||||
userNotifyProps?: Partial<UserNotifyProps> | null;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
|
|
@ -52,6 +56,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
textAlign: 'center',
|
||||
...typography('Body', 200, 'Regular'),
|
||||
},
|
||||
boldText: {
|
||||
...typography('Body', 200, 'SemiBold'),
|
||||
},
|
||||
profilesContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
|
@ -67,7 +74,56 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const DirectChannel = ({channel, currentUserId, isBot, members, theme}: Props) => {
|
||||
const gmIntroMessages = defineMessages({
|
||||
muted: {id: 'intro.group_message.muted', defaultMessage: 'This group message is currently <b>muted</b>, so you will not be notified.'},
|
||||
[NotificationLevel.ALL]: {id: 'intro.group_message.all', defaultMessage: 'You\'ll be notified <b>for all activity</b> in this group message.'},
|
||||
[NotificationLevel.DEFAULT]: {id: 'intro.group_message.all', defaultMessage: 'You\'ll be notified <b>for all activity</b> in this group message.'},
|
||||
[NotificationLevel.MENTION]: {id: 'intro.group_message.mention', defaultMessage: 'You have selected to be notified <b>only when mentioned</b> in this group message.'},
|
||||
[NotificationLevel.NONE]: {id: 'intro.group_message.none', defaultMessage: 'You have selected to <b>never</b> be notified in this group message.'},
|
||||
});
|
||||
|
||||
const getGMIntroMessageSpecificPart = (userNotifyProps: Partial<UserNotifyProps> | undefined | null, channelNotifyProps: Partial<ChannelNotifyProps> | undefined, boldStyle: TextStyle) => {
|
||||
const isMuted = channelNotifyProps?.mark_unread === 'mention';
|
||||
if (isMuted) {
|
||||
return (
|
||||
<FormattedText
|
||||
{...gmIntroMessages.muted}
|
||||
values={{
|
||||
b: (chunk: string) => <Text style={boldStyle}>{chunk}</Text>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const channelNotifyProp = channelNotifyProps?.push || NotificationLevel.DEFAULT;
|
||||
const userNotifyProp = userNotifyProps?.push || NotificationLevel.MENTION;
|
||||
let notifyLevelToUse = channelNotifyProp;
|
||||
if (notifyLevelToUse === NotificationLevel.DEFAULT) {
|
||||
notifyLevelToUse = userNotifyProp;
|
||||
}
|
||||
if (channelNotifyProp === NotificationLevel.DEFAULT && userNotifyProp === NotificationLevel.MENTION) {
|
||||
notifyLevelToUse = NotificationLevel.ALL;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormattedText
|
||||
{...gmIntroMessages[notifyLevelToUse]}
|
||||
values={{
|
||||
b: (chunk: string) => <Text style={boldStyle}>{chunk}</Text>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const DirectChannel = ({
|
||||
channel,
|
||||
currentUserId,
|
||||
isBot,
|
||||
members,
|
||||
theme,
|
||||
hasGMasDMFeature,
|
||||
channelNotifyProps,
|
||||
userNotifyProps,
|
||||
}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
|
|
@ -89,14 +145,26 @@ const DirectChannel = ({channel, currentUserId, isBot, members, theme}: Props) =
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (!hasGMasDMFeature) {
|
||||
return (
|
||||
<FormattedText
|
||||
defaultMessage={'This is the start of your conversation with this group. Messages and files shared here are not shown to anyone else outside of the group.'}
|
||||
id='intro.group_message.after_gm_as_dm'
|
||||
style={styles.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormattedText
|
||||
defaultMessage={'This is the start of your conversation with this group. Messages and files shared here are not shown to anyone else outside of the group.'}
|
||||
id='intro.group_message'
|
||||
style={styles.message}
|
||||
/>
|
||||
<Text style={styles.message}>
|
||||
<FormattedText
|
||||
defaultMessage={'This is the start of your conversation with this group.'}
|
||||
id='intro.group_message.common'
|
||||
/>
|
||||
<Text> </Text>
|
||||
{getGMIntroMessageSpecificPart(userNotifyProps, channelNotifyProps, styles.boldText)}
|
||||
</Text>
|
||||
);
|
||||
}, [channel.displayName, theme]);
|
||||
}, [channel.displayName, theme, channelNotifyProps, userNotifyProps]);
|
||||
|
||||
const profiles = useMemo(() => {
|
||||
if (channel.type === General.DM_CHANNEL) {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import {of as of$} from 'rxjs';
|
|||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {General} from '@constants';
|
||||
import {observeChannelMembers} from '@queries/servers/channel';
|
||||
import {observeChannelMembers, observeNotifyPropsByChannels} from '@queries/servers/channel';
|
||||
import {observeHasGMasDMFeature} from '@queries/servers/features';
|
||||
import {observeCurrentUserId} from '@queries/servers/system';
|
||||
import {observeUser} from '@queries/servers/user';
|
||||
import {observeCurrentUser, observeUser} from '@queries/servers/user';
|
||||
import {getUserIdFromChannelName} from '@utils/user';
|
||||
|
||||
import DirectChannel from './direct_channel';
|
||||
|
|
@ -23,6 +24,13 @@ const observeIsBot = (user: UserModel | undefined) => of$(Boolean(user?.isBot));
|
|||
const enhanced = withObservables([], ({channel, database}: {channel: ChannelModel} & WithDatabaseArgs) => {
|
||||
const currentUserId = observeCurrentUserId(database);
|
||||
const members = observeChannelMembers(database, channel.id);
|
||||
const hasGMasDMFeature = observeHasGMasDMFeature(database);
|
||||
const channelNotifyProps = observeNotifyPropsByChannels(database, [channel]).pipe(
|
||||
switchMap((v) => of$(v[channel.id])),
|
||||
);
|
||||
const userNotifyProps = observeCurrentUser(database).pipe(
|
||||
switchMap((v) => of$(v?.notifyProps)),
|
||||
);
|
||||
let isBot = of$(false);
|
||||
|
||||
if (channel.type === General.DM_CHANNEL) {
|
||||
|
|
@ -40,6 +48,9 @@ const enhanced = withObservables([], ({channel, database}: {channel: ChannelMode
|
|||
currentUserId,
|
||||
isBot,
|
||||
members,
|
||||
hasGMasDMFeature,
|
||||
channelNotifyProps,
|
||||
userNotifyProps,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -74,14 +74,20 @@ const PublicOrPrivateChannel = ({channel, creator, roles, theme}: Props) => {
|
|||
}, []);
|
||||
|
||||
const canManagePeople = useMemo(() => {
|
||||
if (channel.deleteAt !== 0) {
|
||||
return false;
|
||||
}
|
||||
const permission = channel.type === General.OPEN_CHANNEL ? Permissions.MANAGE_PUBLIC_CHANNEL_MEMBERS : Permissions.MANAGE_PRIVATE_CHANNEL_MEMBERS;
|
||||
return hasPermission(roles, permission);
|
||||
}, [channel.type, roles]);
|
||||
}, [channel.type, roles, channel.deleteAt]);
|
||||
|
||||
const canSetHeader = useMemo(() => {
|
||||
if (channel.deleteAt !== 0) {
|
||||
return false;
|
||||
}
|
||||
const permission = channel.type === General.OPEN_CHANNEL ? Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES : Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES;
|
||||
return hasPermission(roles, permission);
|
||||
}, [channel.type, roles]);
|
||||
}, [channel.type, roles, channel.deleteAt]);
|
||||
|
||||
const createdBy = useMemo(() => {
|
||||
const id = channel.type === General.OPEN_CHANNEL ? t('intro.public_channel') : t('intro.private_channel');
|
||||
|
|
|
|||
|
|
@ -6,9 +6,18 @@ import withObservables from '@nozbe/with-observables';
|
|||
import {combineLatest, distinctUntilChanged, of as of$, switchMap} from 'rxjs';
|
||||
|
||||
import {observeIsCallsEnabledInChannel} from '@calls/observers';
|
||||
import {observeChannelsWithCalls, observeCurrentCall} from '@calls/state';
|
||||
import {
|
||||
observeCallsState,
|
||||
observeChannelsWithCalls,
|
||||
observeCurrentCall,
|
||||
observeIncomingCalls,
|
||||
} from '@calls/state';
|
||||
import {Preferences} from '@constants';
|
||||
import {withServerUrl} from '@context/server';
|
||||
import {observeCurrentChannelId} from '@queries/servers/system';
|
||||
import {observeCurrentChannel} from '@queries/servers/channel';
|
||||
import {observeHasGMasDMFeature} from '@queries/servers/features';
|
||||
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
|
||||
import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system';
|
||||
|
||||
import Channel from './channel';
|
||||
|
||||
|
|
@ -34,17 +43,38 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
|
|||
switchMap((call) => of$(Boolean(call?.connected))),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const dismissed = combineLatest([channelId, observeCallsState(serverUrl)]).pipe(
|
||||
switchMap(([id, state]) => of$(Boolean(state.calls[id]?.dismissed[state.myUserId]))),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const isInCurrentChannelCall = combineLatest([channelId, ccChannelId]).pipe(
|
||||
switchMap(([id, ccId]) => of$(id === ccId)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const showJoinCallBanner = combineLatest([isCallInCurrentChannel, dismissed, isInCurrentChannelCall]).pipe(
|
||||
switchMap(([isCall, dism, inCurrCall]) => of$(Boolean(isCall && !dism && !inCurrCall))),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const showIncomingCalls = observeIncomingCalls().pipe(
|
||||
switchMap((ics) => of$(ics.incomingCalls.length > 0)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const dismissedGMasDMNotice = queryPreferencesByCategoryAndName(database, Preferences.CATEGORIES.SYSTEM_NOTICE, Preferences.NOTICES.GM_AS_DM).observe();
|
||||
const channelType = observeCurrentChannel(database).pipe(switchMap((c) => of$(c?.type)));
|
||||
const currentUserId = observeCurrentUserId(database);
|
||||
const hasGMasDMFeature = observeHasGMasDMFeature(database);
|
||||
|
||||
return {
|
||||
channelId,
|
||||
isCallInCurrentChannel,
|
||||
showJoinCallBanner,
|
||||
isInACall,
|
||||
isInCurrentChannelCall,
|
||||
showIncomingCalls,
|
||||
isCallsEnabledInChannel: observeIsCallsEnabledInChannel(database, serverUrl, channelId),
|
||||
dismissedGMasDMNotice,
|
||||
channelType,
|
||||
currentUserId,
|
||||
hasGMasDMFeature,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
65
app/screens/channel/use_gm_as_dm_notice.tsx
Normal file
65
app/screens/channel/use_gm_as_dm_notice.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import {savePreference} from '@actions/remote/preference';
|
||||
import {Preferences} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {getPreferenceAsBool} from '@helpers/api/preference';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
|
||||
import type PreferenceModel from '@typings/database/models/servers/preference';
|
||||
|
||||
const useGMasDMNotice = (userId: string, channelType: ChannelType, dismissedGMasDMNotice: PreferenceModel[], hasGMasDMFeature: boolean) => {
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasGMasDMFeature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferenceValue = getPreferenceAsBool(dismissedGMasDMNotice, Preferences.CATEGORIES.SYSTEM_NOTICE, Preferences.NOTICES.GM_AS_DM);
|
||||
if (preferenceValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (channelType !== 'G') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (EphemeralStore.noticeShown.has(Preferences.NOTICES.GM_AS_DM)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onRemindMeLaterPress = () => {
|
||||
EphemeralStore.noticeShown.add(Preferences.NOTICES.GM_AS_DM);
|
||||
};
|
||||
|
||||
const onHideAndForget = () => {
|
||||
EphemeralStore.noticeShown.add(Preferences.NOTICES.GM_AS_DM);
|
||||
savePreference(serverUrl, [{category: Preferences.CATEGORIES.SYSTEM_NOTICE, name: Preferences.NOTICES.GM_AS_DM, value: 'true', user_id: userId}]);
|
||||
};
|
||||
|
||||
// Show the GM as DM notice if needed
|
||||
Alert.alert(
|
||||
intl.formatMessage({id: 'system_notice.title.gm_as_dm', defaultMessage: 'Updates to Group Messages'}),
|
||||
intl.formatMessage({id: 'system_noticy.body.gm_as_dm', defaultMessage: 'You will now be notified for all activity in your group messages along with a notification badge for every new message.\n\nYou can configure this in notification preferences for each group message.'}),
|
||||
[
|
||||
{
|
||||
text: intl.formatMessage({id: 'system_notice.remind_me', defaultMessage: 'Remind Me Later'}),
|
||||
onPress: onRemindMeLaterPress,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({id: 'system_notice.dont_show', defaultMessage: 'Don\'t Show Again'}),
|
||||
onPress: onHideAndForget,
|
||||
},
|
||||
],
|
||||
);
|
||||
}, []);
|
||||
};
|
||||
|
||||
export default useGMasDMNotice;
|
||||
|
|
@ -30,6 +30,7 @@ type Props = {
|
|||
canEnableDisableCalls: boolean;
|
||||
isCallsEnabledInChannel: boolean;
|
||||
canManageMembers: boolean;
|
||||
canManageSettings: boolean;
|
||||
}
|
||||
|
||||
const edges: Edge[] = ['bottom', 'left', 'right'];
|
||||
|
|
@ -57,6 +58,7 @@ const ChannelInfo = ({
|
|||
canEnableDisableCalls,
|
||||
isCallsEnabledInChannel,
|
||||
canManageMembers,
|
||||
canManageSettings,
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const serverUrl = useServerUrl();
|
||||
|
|
@ -70,7 +72,7 @@ const ChannelInfo = ({
|
|||
return dismissModal({componentId});
|
||||
}, [componentId]);
|
||||
|
||||
useNavButtonPressed(closeButtonId, componentId, onPressed, []);
|
||||
useNavButtonPressed(closeButtonId, componentId, onPressed, [onPressed]);
|
||||
useAndroidHardwareBackHandler(componentId, onPressed);
|
||||
|
||||
return (
|
||||
|
|
@ -103,6 +105,7 @@ const ChannelInfo = ({
|
|||
type={type}
|
||||
callsEnabled={callsAvailable}
|
||||
canManageMembers={canManageMembers}
|
||||
canManageSettings={canManageSettings}
|
||||
/>
|
||||
<View style={styles.separator}/>
|
||||
{canEnableDisableCalls &&
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {observeIsCallsEnabledInChannel} from '@calls/observers';
|
|||
import {observeCallsConfig} from '@calls/state';
|
||||
import {withServerUrl} from '@context/server';
|
||||
import {observeCurrentChannel} from '@queries/servers/channel';
|
||||
import {observeCanManageChannelMembers} from '@queries/servers/role';
|
||||
import {observeCanManageChannelMembers, observeCanManageChannelSettings} from '@queries/servers/role';
|
||||
import {
|
||||
observeConfigValue,
|
||||
observeCurrentChannelId,
|
||||
|
|
@ -105,11 +105,19 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => {
|
|||
switchMap(([u, cId]) => (u ? observeCanManageChannelMembers(database, cId, u) : of$(false))),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const canManageSettings = currentUser.pipe(
|
||||
combineLatestWith(channelId),
|
||||
switchMap(([u, cId]) => (u ? observeCanManageChannelSettings(database, cId, u) : of$(false))),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
return {
|
||||
type,
|
||||
canEnableDisableCalls,
|
||||
isCallsEnabledInChannel,
|
||||
canManageMembers,
|
||||
canManageSettings,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type Props = {
|
|||
type?: ChannelType;
|
||||
callsEnabled: boolean;
|
||||
canManageMembers: boolean;
|
||||
canManageSettings: boolean;
|
||||
}
|
||||
|
||||
const Options = ({
|
||||
|
|
@ -27,6 +28,7 @@ const Options = ({
|
|||
type,
|
||||
callsEnabled,
|
||||
canManageMembers,
|
||||
canManageSettings,
|
||||
}: Props) => {
|
||||
const isDMorGM = isTypeDMorGM(type);
|
||||
|
||||
|
|
@ -50,7 +52,7 @@ const Options = ({
|
|||
testID='channel_info.options.copy_channel_link.option'
|
||||
/>
|
||||
}
|
||||
{type !== General.DM_CHANNEL && type !== General.GM_CHANNEL &&
|
||||
{canManageSettings &&
|
||||
<EditChannel channelId={channelId}/>
|
||||
}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import withObservables from '@nozbe/with-observables';
|
|||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {NotificationLevel} from '@constants';
|
||||
import {observeChannel, observeChannelSettings} from '@queries/servers/channel';
|
||||
import {observeHasGMasDMFeature} from '@queries/servers/features';
|
||||
import {observeCurrentUser} from '@queries/servers/user';
|
||||
import {getNotificationProps} from '@utils/user';
|
||||
|
||||
|
|
@ -19,17 +21,22 @@ type Props = WithDatabaseArgs & {
|
|||
}
|
||||
|
||||
const enhanced = withObservables(['channelId'], ({channelId, database}: Props) => {
|
||||
const displayName = observeChannel(database, channelId).pipe(switchMap((c) => of$(c?.displayName)));
|
||||
const channel = observeChannel(database, channelId);
|
||||
const channelType = channel.pipe(switchMap((c) => of$(c?.type)));
|
||||
const displayName = channel.pipe(switchMap((c) => of$(c?.displayName)));
|
||||
const settings = observeChannelSettings(database, channelId);
|
||||
const userNotifyLevel = observeCurrentUser(database).pipe(switchMap((u) => of$(getNotificationProps(u).push)));
|
||||
const notifyLevel = settings.pipe(
|
||||
switchMap((s) => of$(s?.notifyProps.push)),
|
||||
switchMap((s) => of$(s?.notifyProps.push || NotificationLevel.DEFAULT)),
|
||||
);
|
||||
const hasGMasDMFeature = observeHasGMasDMFeature(database);
|
||||
|
||||
return {
|
||||
displayName,
|
||||
notifyLevel,
|
||||
userNotifyLevel,
|
||||
channelType,
|
||||
hasGMasDMFeature,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {NotificationLevel, Screens} from '@constants';
|
|||
import {useTheme} from '@context/theme';
|
||||
import {t} from '@i18n';
|
||||
import {goToScreen} from '@screens/navigation';
|
||||
import {isTypeDMorGM} from '@utils/channel';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
|
|
@ -20,6 +21,8 @@ type Props = {
|
|||
displayName: string;
|
||||
notifyLevel: NotificationLevel;
|
||||
userNotifyLevel: NotificationLevel;
|
||||
channelType: ChannelType;
|
||||
hasGMasDMFeature: boolean;
|
||||
}
|
||||
|
||||
const notificationLevel = (notifyLevel: NotificationLevel) => {
|
||||
|
|
@ -50,7 +53,14 @@ const notificationLevel = (notifyLevel: NotificationLevel) => {
|
|||
return {id, defaultMessage};
|
||||
};
|
||||
|
||||
const NotificationPreference = ({channelId, displayName, notifyLevel, userNotifyLevel}: Props) => {
|
||||
const NotificationPreference = ({
|
||||
channelId,
|
||||
displayName,
|
||||
notifyLevel,
|
||||
userNotifyLevel,
|
||||
channelType,
|
||||
hasGMasDMFeature,
|
||||
}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const theme = useTheme();
|
||||
const title = formatMessage({id: 'channel_info.mobile_notifications', defaultMessage: 'Mobile Notifications'});
|
||||
|
|
@ -74,13 +84,19 @@ const NotificationPreference = ({channelId, displayName, notifyLevel, userNotify
|
|||
});
|
||||
|
||||
const notificationLevelToText = () => {
|
||||
if (notifyLevel === NotificationLevel.DEFAULT) {
|
||||
const userLevel = notificationLevel(userNotifyLevel);
|
||||
return formatMessage(userLevel);
|
||||
let notifyLevelToUse = notifyLevel;
|
||||
if (notifyLevelToUse === NotificationLevel.DEFAULT) {
|
||||
notifyLevelToUse = userNotifyLevel;
|
||||
}
|
||||
|
||||
const channelLevel = notificationLevel(notifyLevel);
|
||||
return formatMessage(channelLevel);
|
||||
if (hasGMasDMFeature) {
|
||||
if (notifyLevel === NotificationLevel.DEFAULT && notifyLevelToUse === NotificationLevel.MENTION && isTypeDMorGM(channelType)) {
|
||||
notifyLevelToUse = NotificationLevel.ALL;
|
||||
}
|
||||
}
|
||||
|
||||
const messageDescriptor = notificationLevel(notifyLevelToUse);
|
||||
return formatMessage(messageDescriptor);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import {useSharedValue} from 'react-native-reanimated';
|
|||
|
||||
import {updateChannelNotifyProps} from '@actions/remote/channel';
|
||||
import SettingsContainer from '@components/settings/container';
|
||||
import {NotificationLevel} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||
import useDidUpdate from '@hooks/did_update';
|
||||
import useBackNavigation from '@hooks/navigate_back';
|
||||
import {isTypeDMorGM} from '@utils/channel';
|
||||
|
||||
import {popTopScreen} from '../navigation';
|
||||
|
||||
|
|
@ -28,16 +30,29 @@ type Props = {
|
|||
defaultThreadReplies: 'all' | 'mention';
|
||||
isCRTEnabled: boolean;
|
||||
isMuted: boolean;
|
||||
notifyLevel?: NotificationLevel;
|
||||
notifyLevel: NotificationLevel;
|
||||
notifyThreadReplies?: 'all' | 'mention';
|
||||
channelType: ChannelType;
|
||||
hasGMasDMFeature: boolean;
|
||||
}
|
||||
|
||||
const ChannelNotificationPreferences = ({channelId, componentId, defaultLevel, defaultThreadReplies, isCRTEnabled, isMuted, notifyLevel, notifyThreadReplies}: Props) => {
|
||||
const ChannelNotificationPreferences = ({
|
||||
channelId,
|
||||
componentId,
|
||||
defaultLevel,
|
||||
defaultThreadReplies,
|
||||
isCRTEnabled,
|
||||
isMuted,
|
||||
notifyLevel,
|
||||
notifyThreadReplies,
|
||||
channelType,
|
||||
hasGMasDMFeature,
|
||||
}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const defaultNotificationReplies = defaultThreadReplies === 'all';
|
||||
const diffNotificationLevel = notifyLevel !== 'default' && notifyLevel !== defaultLevel;
|
||||
const diffNotificationLevel = notifyLevel !== NotificationLevel.DEFAULT && notifyLevel !== defaultLevel;
|
||||
const notifyTitleTop = useSharedValue((isMuted ? MUTED_BANNER_HEIGHT : 0) + BLOCK_TITLE_HEIGHT);
|
||||
const [notifyAbout, setNotifyAbout] = useState<NotificationLevel>((notifyLevel === undefined || notifyLevel === 'default') ? defaultLevel : notifyLevel);
|
||||
const [notifyAbout, setNotifyAbout] = useState<NotificationLevel>(notifyLevel === NotificationLevel.DEFAULT ? defaultLevel : notifyLevel);
|
||||
const [threadReplies, setThreadReplies] = useState<boolean>((notifyThreadReplies || defaultThreadReplies) === 'all');
|
||||
const [resetDefaultVisible, setResetDefaultVisible] = useState(diffNotificationLevel || defaultNotificationReplies !== threadReplies);
|
||||
|
||||
|
|
@ -64,8 +79,13 @@ const ChannelNotificationPreferences = ({channelId, componentId, defaultLevel, d
|
|||
const save = useCallback(() => {
|
||||
const pushThreads = threadReplies ? 'all' : 'mention';
|
||||
|
||||
if (notifyLevel !== notifyAbout || (isCRTEnabled && pushThreads !== notifyThreadReplies)) {
|
||||
const props: Partial<ChannelNotifyProps> = {push: notifyAbout};
|
||||
let notifyAboutToUse = notifyAbout;
|
||||
if (notifyAbout === defaultLevel) {
|
||||
notifyAboutToUse = NotificationLevel.DEFAULT;
|
||||
}
|
||||
|
||||
if (notifyLevel !== notifyAboutToUse || (isCRTEnabled && pushThreads !== notifyThreadReplies)) {
|
||||
const props: Partial<ChannelNotifyProps> = {push: notifyAboutToUse};
|
||||
if (isCRTEnabled) {
|
||||
props.push_threads = pushThreads;
|
||||
}
|
||||
|
|
@ -73,11 +93,15 @@ const ChannelNotificationPreferences = ({channelId, componentId, defaultLevel, d
|
|||
updateChannelNotifyProps(serverUrl, channelId, props);
|
||||
}
|
||||
popTopScreen(componentId);
|
||||
}, [channelId, componentId, isCRTEnabled, notifyAbout, notifyLevel, notifyThreadReplies, serverUrl, threadReplies]);
|
||||
}, [defaultLevel, channelId, componentId, isCRTEnabled, notifyAbout, notifyLevel, notifyThreadReplies, serverUrl, threadReplies]);
|
||||
|
||||
useBackNavigation(save);
|
||||
useAndroidHardwareBackHandler(componentId, save);
|
||||
|
||||
const showThreadReplies = isCRTEnabled && (
|
||||
!hasGMasDMFeature ||
|
||||
!isTypeDMorGM(channelType)
|
||||
);
|
||||
return (
|
||||
<SettingsContainer testID='push_notification_settings'>
|
||||
{isMuted && <MutedBanner channelId={channelId}/>}
|
||||
|
|
@ -94,7 +118,7 @@ const ChannelNotificationPreferences = ({channelId, componentId, defaultLevel, d
|
|||
notifyTitleTop={notifyTitleTop}
|
||||
onPress={onNotificationLevel}
|
||||
/>
|
||||
{isCRTEnabled &&
|
||||
{showThreadReplies &&
|
||||
<ThreadReplies
|
||||
isSelected={threadReplies}
|
||||
onPress={onSetThreadReplies}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@
|
|||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
import {switchMap, combineLatestWith} from 'rxjs/operators';
|
||||
|
||||
import {observeChannelSettings, observeIsMutedSetting} from '@queries/servers/channel';
|
||||
import {NotificationLevel} from '@constants';
|
||||
import {observeChannel, observeChannelSettings, observeIsMutedSetting} from '@queries/servers/channel';
|
||||
import {observeHasGMasDMFeature} from '@queries/servers/features';
|
||||
import {observeIsCRTEnabled} from '@queries/servers/thread';
|
||||
import {observeCurrentUser} from '@queries/servers/user';
|
||||
import {isTypeDMorGM} from '@utils/channel';
|
||||
import {getNotificationProps} from '@utils/user';
|
||||
|
||||
import ChannelNotificationPreferences from './channel_notification_preferences';
|
||||
|
|
@ -24,9 +27,11 @@ const enhanced = withObservables([], ({channelId, database}: EnhancedProps) => {
|
|||
const isCRTEnabled = observeIsCRTEnabled(database);
|
||||
const isMuted = observeIsMutedSetting(database, channelId);
|
||||
const notifyProps = observeCurrentUser(database).pipe(switchMap((u) => of$(getNotificationProps(u))));
|
||||
const channelType = observeChannel(database, channelId).pipe(switchMap((c) => of$(c?.type)));
|
||||
const hasGMasDMFeature = observeHasGMasDMFeature(database);
|
||||
|
||||
const notifyLevel = settings.pipe(
|
||||
switchMap((s) => of$(s?.notifyProps.push)),
|
||||
switchMap((s) => of$(s?.notifyProps.push || NotificationLevel.DEFAULT)),
|
||||
);
|
||||
|
||||
const notifyThreadReplies = settings.pipe(
|
||||
|
|
@ -35,7 +40,21 @@ const enhanced = withObservables([], ({channelId, database}: EnhancedProps) => {
|
|||
|
||||
const defaultLevel = notifyProps.pipe(
|
||||
switchMap((n) => of$(n?.push)),
|
||||
combineLatestWith(hasGMasDMFeature, channelType),
|
||||
switchMap(([v, hasFeature, cType]) => {
|
||||
const shouldShowwithGMasDMBehavior = hasFeature && isTypeDMorGM(cType);
|
||||
|
||||
let defaultLevelToUse = v;
|
||||
if (shouldShowwithGMasDMBehavior) {
|
||||
if (v === NotificationLevel.MENTION) {
|
||||
defaultLevelToUse = NotificationLevel.ALL;
|
||||
}
|
||||
}
|
||||
|
||||
return of$(defaultLevelToUse);
|
||||
}),
|
||||
);
|
||||
|
||||
const defaultThreadReplies = notifyProps.pipe(
|
||||
switchMap((n) => of$(n?.push_threads)),
|
||||
);
|
||||
|
|
@ -47,6 +66,8 @@ const enhanced = withObservables([], ({channelId, database}: EnhancedProps) => {
|
|||
notifyThreadReplies,
|
||||
defaultLevel,
|
||||
defaultThreadReplies,
|
||||
channelType,
|
||||
hasGMasDMFeature,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue