Merge branch 'main' into MM-50354-not-show-push-notifications-warning-if-disabled

This commit is contained in:
Pablo Andrés Vélez Vidal 2023-10-09 16:28:58 +02:00
commit 499114f4f9
239 changed files with 19665 additions and 7568 deletions

View file

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

View file

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

View file

@ -1 +1 @@
18.7.0
18.17

1
.npmrc
View file

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

2
.nvmrc
View file

@ -1 +1 @@
18.7.0
18.17

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) => {

View file

@ -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);

View file

@ -9,11 +9,9 @@ import {addPostAcknowledgement, removePost, removePostAcknowledgement, storePost
import {addRecentReaction} from '@actions/local/reactions';
import {createThreadFromNewPost} from '@actions/local/thread';
import {ActionType, General, Post, ServerErrors} from '@constants';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {filterPostsInOrderedArray} from '@helpers/api/post';
import {getNeededAtMentionedUsernames} from '@helpers/api/user';
import {extractRecordsForTable} from '@helpers/database';
import NetworkManager from '@managers/network_manager';
import {getMyChannel, prepareMissingChannelsForAllTeams, queryAllMyChannel} from '@queries/servers/channel';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
@ -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);

View file

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

View file

@ -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);

View file

@ -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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
`;

View file

@ -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();
});
});

View file

@ -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}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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[];

View file

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

View file

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

View file

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

View file

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

View file

@ -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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;
};

View file

@ -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';

View file

@ -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 = {

View file

@ -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';

View 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))),
);
};

View file

@ -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(),
);
}

View file

@ -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>

View file

@ -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>
);

View file

@ -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;

View file

@ -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) {

View file

@ -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,
};
});

View file

@ -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');

View file

@ -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,
};
});

View 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;

View file

@ -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 &&

View file

@ -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,
};
});

View file

@ -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}/>
}
</>

View file

@ -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,
};
});

View file

@ -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 (

View file

@ -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}

View file

@ -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