Merge branch 'main' into MM-56592-plugin-deeplink

This commit is contained in:
Mattermost Build 2024-01-25 08:51:41 +02:00 committed by GitHub
commit 7be7e5f0c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 1572 additions and 225 deletions

View file

@ -5,8 +5,7 @@ on:
push:
branches:
- build-beta-[0-9]+
- build-android-[0-9]+
- build-android-beta-[0-9]+
- build-beta-android-[0-9]+
env:
NODE_VERSION: 18.7.0

View file

@ -1,11 +1,9 @@
---
name: build-pr
on:
push:
branches:
- build-pr-*
- build-pr-android-*
- build-pr-ios-*
pull_request:
types:
- labeled
env:
NODE_VERSION: 18.7.0
@ -14,20 +12,25 @@ env:
jobs:
test:
runs-on: ubuntu-22.04
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for iOS' || github.event.label.name == 'Build App for Android' }}
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/test
uses: ./.github/actions/test
build-ios-pr:
runs-on: macos-12
if: ${{ !contains(github.ref_name, 'android') }}
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for iOS' }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/prepare-ios-build
uses: ./.github/actions/prepare-ios-build
@ -43,7 +46,7 @@ jobs:
- name: ci/build-ios-pr
env:
BRANCH_TO_BUILD: "${{ github.ref_name }}"
BRANCH_TO_BUILD: "${{ github.event.pull_request.head.ref }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_PR_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_PR_AWS_SECRET_ACCESS_KEY }}"
FASTLANE_TEAM_ID: "${{ secrets.MM_MOBILE_FASTLANE_TEAM_ID }}"
@ -64,12 +67,14 @@ jobs:
build-android-pr:
runs-on: ubuntu-22.04
if: ${{ !contains(github.ref_name, 'ios') }}
if: ${{ github.event.label.name == 'Build Apps for PR' || github.event.label.name == 'Build App for Android' }}
needs:
- test
steps:
- name: ci/checkout-repo
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/prepare-android-build
uses: ./.github/actions/prepare-android-build
@ -81,7 +86,7 @@ jobs:
- name: ci/build-android-pr
env:
BRANCH_TO_BUILD: "${{ github.ref_name }}"
BRANCH_TO_BUILD: "${{ github.event.pull_request.head.ref }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_PR_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_PR_AWS_SECRET_ACCESS_KEY }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_PR_MATTERMOST_WEBHOOK_URL }}"

View file

@ -111,8 +111,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 499
versionName "2.12.0"
versionCode 503
versionName "2.13.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {chunk} from 'lodash';
import {DeviceEventEmitter} from 'react-native';
import {handleReconnect} from '@actions/websocket';
@ -93,7 +94,11 @@ export const savePreference = async (serverUrl: string, preferences: PreferenceT
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const userId = await getCurrentUserId(database);
client.savePreferences(userId, preferences);
const chunkSize = 100;
const chunks = chunk(preferences, chunkSize);
chunks.forEach((c: PreferenceType[]) => {
client.savePreferences(userId, c);
});
const preferenceModels = await operator.handlePreferences({
preferences,
prepareRecordsOnly,

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {General} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {queryRoles} from '@queries/servers/role';
@ -42,7 +43,13 @@ export const fetchRolesIfNeeded = async (serverUrl: string, updatedRoles: string
return {roles: []};
}
const roles = await client.getRolesByNames(newRoles);
const getRolesRequests = [];
for (let i = 0; i < newRoles.length; i += General.MAX_GET_ROLES_BY_NAMES) {
const chunk = newRoles.slice(i, i + General.MAX_GET_ROLES_BY_NAMES);
getRolesRequests.push(client.getRolesByNames(chunk));
}
const roles = (await Promise.all(getRolesRequests)).flat();
if (!fetchOnly) {
await operator.handleRole({
roles,

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getPosts} from '@actions/local/post';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
@ -17,6 +18,7 @@ import {fetchPostAuthors, fetchMissingChannelsFromPosts} from './post';
import {forceLogoutIfNecessary} from './session';
import type Model from '@nozbe/watermelondb/Model';
import type PostModel from '@typings/database/models/servers/post';
export async function fetchRecentMentions(serverUrl: string): Promise<PostSearchRequest> {
try {
@ -132,13 +134,27 @@ export const searchFiles = async (serverUrl: string, teamId: string, params: Fil
const client = NetworkManager.getClient(serverUrl);
const result = await client.searchFiles(teamId, params.terms);
const files = result?.file_infos ? Object.values(result.file_infos) : [];
const allChannelIds = files.reduce<string[]>((acc, f) => {
const [allChannelIds, allPostIds] = files.reduce<[Set<string>, Set<string>]>((acc, f) => {
if (f.channel_id) {
acc.push(f.channel_id);
acc[0].add(f.channel_id);
}
if (f.post_id) {
acc[1].add(f.post_id);
}
return acc;
}, []);
const channels = [...new Set(allChannelIds)];
}, [new Set<string>(), new Set<string>()]);
const channels = Array.from(allChannelIds.values());
// Attach the file's post's props to the FileInfo (needed for captioning videos)
const postIds = Array.from(allPostIds);
const posts = await getPosts(serverUrl, postIds);
const idToPost = posts.reduce<Dictionary<PostModel>>((acc, p) => {
acc[p.id] = p;
return acc;
}, {});
files.forEach((f) => {
f.postProps = idToPost[f.post_id]?.props;
});
return {files, channels};
} catch (error) {
logDebug('error on searchFiles', getFullErrorMessage(error));

View file

@ -11,7 +11,9 @@ import {fetchMissingDirectChannelsInfo, fetchMyChannel, fetchChannelStats, fetch
import {fetchPostsForChannel} from '@actions/remote/post';
import {fetchRolesIfNeeded} from '@actions/remote/role';
import {fetchUsersByIds, updateUsersNoLongerVisible} from '@actions/remote/user';
import {loadCallForChannel} from '@calls/actions/calls';
import {loadCallForChannel, leaveCall} from '@calls/actions/calls';
import {userLeftChannelErr, userRemovedFromChannelErr} from '@calls/errors';
import {getCurrentCall} from '@calls/state';
import {Events, General} from '@constants';
import DatabaseManager from '@database/manager';
import {deleteChannelMembership, getChannelById, prepareMyChannelsForTeam, getCurrentChannel} from '@queries/servers/channel';
@ -360,6 +362,9 @@ export async function handleUserRemovedFromChannelEvent(serverUrl: string, msg:
const channelId = msg.data.channel_id || msg.broadcast.channel_id;
if (EphemeralStore.isLeavingChannel(channelId)) {
if (getCurrentCall()?.channelId === channelId) {
leaveCall(userLeftChannelErr);
}
return;
}
@ -382,7 +387,12 @@ export async function handleUserRemovedFromChannelEvent(serverUrl: string, msg:
if (currentChannelId && currentChannelId === channelId) {
await handleKickFromChannel(serverUrl, currentChannelId);
}
await removeCurrentUserFromChannel(serverUrl, channelId);
if (getCurrentCall()?.channelId === channelId) {
leaveCall(userRemovedFromChannelErr);
}
} else {
const {models: deleteMemberModels} = await deleteChannelMembership(operator, userId, channelId, true);
if (deleteMemberModels) {

View file

@ -24,6 +24,7 @@ type FilesProps = {
location: string;
isReplyPost: boolean;
postId: string;
postProps: Record<string, any>;
publicLinkEnabled: boolean;
}
@ -48,7 +49,7 @@ const styles = StyleSheet.create({
},
});
const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, location, postId, publicLinkEnabled}: FilesProps) => {
const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, location, postId, postProps, publicLinkEnabled}: FilesProps) => {
const galleryIdentifier = `${postId}-fileAttachments-${location}`;
const [inViewPort, setInViewPort] = useState(false);
const isTablet = useIsTablet();
@ -63,7 +64,7 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l
};
const handlePreviewPress = preventDoubleTap((idx: number) => {
const items = filesForGallery.value.map((f) => fileToGalleryItem(f, f.user_id));
const items = filesForGallery.value.map((f) => fileToGalleryItem(f, f.user_id, postProps));
openGalleryAtIndex(galleryIdentifier, idx, items);
});

View file

@ -45,6 +45,7 @@ const enhance = withObservables(['post'], ({database, post}: EnhanceProps) => {
return {
canDownloadFiles: observeCanDownloadFiles(database),
postId: of$(post.id),
postProps: of$(post.props),
publicLinkEnabled,
filesInfo,
};

View file

@ -0,0 +1,328 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {
useState,
useRef,
useImperativeHandle,
forwardRef,
useMemo,
useCallback,
} from 'react';
import {
type GestureResponderEvent,
type LayoutChangeEvent,
type NativeSyntheticEvent,
type StyleProp,
type TargetedEvent,
Text,
TextInput,
type TextInputFocusEventData,
type TextInputProps,
type TextStyle,
TouchableWithoutFeedback,
View,
type ViewStyle,
Pressable,
} from 'react-native';
import Animated, {
useAnimatedStyle,
withTiming,
Easing,
} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import SelectedChip, {USER_CHIP_HEIGHT} from '@components/selected_chip';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getLabelPositions} from './utils';
const BORDER_DEFAULT_WIDTH = 1;
const BORDER_FOCUSED_WIDTH = 2;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
width: '100%',
},
errorContainer: {
flexDirection: 'row',
borderColor: 'transparent', // Hack to properly place text in flexbox
borderWidth: 1,
},
errorIcon: {
color: theme.errorTextColor,
marginRight: 7,
top: 5,
...typography('Body', 100),
},
errorText: {
color: theme.errorTextColor,
paddingVertical: 5,
...typography('Body', 75),
},
input: {
backgroundColor: 'transparent',
borderWidth: 0,
paddingHorizontal: 0,
paddingTop: 0,
paddingBottom: 0,
height: USER_CHIP_HEIGHT,
flexGrow: 1,
flexShrink: 0,
flexBasis: 'auto',
alignSelf: 'stretch',
},
label: {
...typography('Body', 200),
position: 'absolute',
lineHeight: 16,
color: changeOpacity(theme.centerChannelColor, 0.64),
left: 16,
zIndex: 10,
},
readOnly: {
backgroundColor: changeOpacity(theme.centerChannelBg, 0.16),
},
smallLabel: {
...typography('Body', 25),
},
textInput: {
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-start',
alignContent: 'flex-start',
alignItems: 'flex-start',
textAlignVertical: 'center',
paddingTop: 12,
paddingBottom: 12,
paddingHorizontal: 16,
color: theme.centerChannelColor,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderRadius: 4,
borderWidth: BORDER_DEFAULT_WIDTH,
backgroundColor: theme.centerChannelBg,
...typography('Body', 200),
},
chipContainer: {
flexGrow: 0,
flexShrink: 1,
flexBasis: 'auto',
alignSelf: 'auto',
},
}));
export type Ref = {
blur: () => void;
focus: () => void;
isFocused: () => boolean;
}
type TextInputPropsFiltered = Omit<TextInputProps, 'value' | 'defaultValue' | 'onChange'>;
type Props = TextInputPropsFiltered & {
containerStyle?: StyleProp<ViewStyle>;
editable?: boolean;
error?: string;
errorIcon?: string;
isKeyboardInput?: boolean;
label: string;
labelTextStyle?: TextStyle;
onBlur?: (event: NativeSyntheticEvent<TargetedEvent>) => void;
onFocus?: (e: NativeSyntheticEvent<TargetedEvent>) => void;
onLayout?: (e: LayoutChangeEvent) => void;
onPress?: (e: GestureResponderEvent) => void;
placeholder?: string;
showErrorIcon?: boolean;
testID?: string;
textInputStyle?: TextStyle;
theme: Theme;
chipsValues?: string[];
textInputValue: string;
onTextInputChange: TextInputProps['onChangeText'];
onChipRemove: (value: string) => void;
onTextInputSubmitted: () => void;
}
const FloatingTextChipsInput = forwardRef<Ref, Props>(({
textInputValue,
textInputStyle,
onTextInputChange,
onTextInputSubmitted,
chipsValues,
onChipRemove,
theme,
containerStyle,
editable = true,
error,
errorIcon = 'alert-outline',
isKeyboardInput = true,
label = '',
labelTextStyle,
onBlur,
onFocus,
onLayout,
onPress,
placeholder,
showErrorIcon = true,
testID,
...restProps
}, ref) => {
const [focused, setIsFocused] = useState(false);
const [focusedLabel, setIsFocusLabel] = useState<boolean | undefined>();
const inputRef = useRef<TextInput>(null);
const styles = getStyleSheet(theme);
const hasValues = textInputValue.length > 0 || (chipsValues?.length ?? 0) > 0;
const shouldShowError = !focused && error;
const positions = useMemo(() => getLabelPositions(styles.textInput, styles.label, styles.smallLabel), [styles]);
// Exposes the blur, focus and isFocused methods to the parent component
useImperativeHandle(ref, () => ({
blur: () => inputRef.current?.blur(),
focus: () => inputRef.current?.focus(),
isFocused: () => inputRef.current?.isFocused() || false,
}), [inputRef]);
const onTextInputBlur = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => {
setIsFocusLabel(hasValues);
setIsFocused(false);
onBlur?.(e);
}, [onBlur, hasValues]);
const onTextInputFocus = useCallback((e: NativeSyntheticEvent<TextInputFocusEventData>) => {
setIsFocusLabel(true);
setIsFocused(true);
onFocus?.(e);
}, [onFocus]);
function handlePressOnContainer() {
if (!focused) {
inputRef?.current?.focus();
}
}
function handleTouchableOnPress(event: GestureResponderEvent) {
if (!isKeyboardInput && editable && onPress) {
onPress(event);
}
}
const textInputContainerStyles = useMemo(() => {
const res: StyleProp<TextStyle> = [styles.textInput];
if (!editable) {
res.push(styles.readOnly);
}
res.push({
borderWidth: focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH,
minHeight: (USER_CHIP_HEIGHT * 2.5) + ((focusedLabel ? BORDER_FOCUSED_WIDTH : BORDER_DEFAULT_WIDTH) * 2),
});
if (focused) {
res.push({borderColor: theme.buttonBg});
} else if (shouldShowError) {
res.push({borderColor: theme.errorTextColor});
}
res.push(textInputStyle);
return res;
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, editable]);
const textAnimatedTextStyle = useAnimatedStyle(() => {
const inputText = placeholder || hasValues;
const index = inputText || focusedLabel ? 1 : 0;
const toValue = positions[index];
const size = [styles.textInput.fontSize, styles.smallLabel.fontSize];
const toSize = size[index] as number;
let color = styles.label.color;
if (shouldShowError) {
color = theme.errorTextColor;
} else if (focused) {
color = theme.buttonBg;
}
return {
top: withTiming(toValue, {duration: 100, easing: Easing.linear}),
fontSize: withTiming(toSize, {duration: 100, easing: Easing.linear}),
backgroundColor: focusedLabel || inputText ? theme.centerChannelBg : 'transparent',
paddingHorizontal: focusedLabel || inputText ? 4 : 0,
color,
};
});
return (
<TouchableWithoutFeedback
onPress={handleTouchableOnPress}
onLayout={onLayout}
>
<View style={[styles.container, containerStyle]}>
<Pressable onPress={handlePressOnContainer}>
<Animated.Text
style={[styles.label, labelTextStyle, textAnimatedTextStyle]}
suppressHighlighting={true}
numberOfLines={1}
>
{label}
</Animated.Text>
<View style={textInputContainerStyles}>
{chipsValues && chipsValues?.length > 0 && chipsValues.map((chipValue) => (
<SelectedChip
key={chipValue}
id={chipValue}
text={chipValue}
onRemove={onChipRemove}
containerStyle={styles.chipContainer}
/>
))}
<TextInput
{...restProps}
ref={inputRef}
testID={testID}
placeholder={placeholder}
placeholderTextColor={styles.label.color}
pointerEvents={isKeyboardInput ? 'auto' : 'none'}
underlineColorAndroid='transparent'
editable={isKeyboardInput && editable}
multiline={false}
style={[styles.textInput, styles.input, textInputStyle]}
onFocus={onTextInputFocus}
onBlur={onTextInputBlur}
onChangeText={onTextInputChange}
onSubmitEditing={onTextInputSubmitted}
value={textInputValue}
/>
</View>
</Pressable>
{Boolean(error) && (
<View style={styles.errorContainer}>
{showErrorIcon && errorIcon &&
<CompassIcon
name={errorIcon}
style={styles.errorIcon}
/>
}
<Text
style={styles.errorText}
testID={`${testID}.error`}
>
{error}
</Text>
</View>
)}
</View>
</TouchableWithoutFeedback>
);
});
FloatingTextChipsInput.displayName = 'FloatingTextChipsInput';
export default FloatingTextChipsInput;

View file

@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getLabelPositions} from './utils';
describe('getLabelPositions', () => {
test('should return correct positions when all styles are provided', () => {
const style = {
paddingTop: 10,
paddingBottom: 10,
height: 50,
fontSize: 14,
padding: 20,
};
const labelStyle = {fontSize: 15};
const smallLabelStyle = {fontSize: 11};
const result = getLabelPositions(style, labelStyle, smallLabelStyle);
expect(result).toEqual([24.4, -6.5]);
});
test('should return correct positions when label and smallLabels styles are missing', () => {
const style = {
paddingTop: 15,
paddingBottom: 15,
height: 50,
fontSize: 14,
padding: 25,
};
const labelStyle = {};
const smallLabelStyle = {};
const result = getLabelPositions(style, labelStyle, smallLabelStyle);
expect(result).toEqual([23.8, -6.5]);
});
test('should return correct positions when all values are empty are provided', () => {
const style = {};
const labelStyle = {};
const smallLabelStyle = {};
const result = getLabelPositions(style, labelStyle, smallLabelStyle);
expect(result[0]).toBeCloseTo(-1.8);
expect(result[1]).toBeCloseTo(-6.5);
});
test('should return correct positions when all values are zero', () => {
const style = {
paddingTop: 0,
paddingBottom: 0,
height: 0,
fontSize: 0,
padding: 0,
};
const labelStyle = {fontSize: 0};
const smallLabelStyle = {fontSize: 0};
const result = getLabelPositions(style, labelStyle, smallLabelStyle);
expect(result[0]).toBeCloseTo(-1.8);
expect(result[1]).toBeCloseTo(-6.5);
});
});

View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform, type TextStyle} from 'react-native';
export const getLabelPositions = (style: TextStyle, labelStyle: TextStyle, smallLabelStyle: TextStyle) => {
const top = style.paddingTop as number || 0;
const bottom = style.paddingBottom as number || 0;
const height = (style.height as number || (top + bottom) || style.padding as number) || 0;
const textInputFontSize = style.fontSize || 13;
const labelFontSize = labelStyle.fontSize || 16;
const smallLabelFontSize = smallLabelStyle.fontSize || 10;
const fontSizeDiff = textInputFontSize - labelFontSize;
const unfocused = (height * 0.5) + (fontSizeDiff * (Platform.OS === 'android' ? 0.5 : 0.6));
const focused = -(labelFontSize + smallLabelFontSize) * 0.25;
return [unfocused, focused];
};

View file

@ -95,6 +95,7 @@ type FloatingTextInputProps = TextInputProps & {
label: string;
labelTextStyle?: TextStyle;
multiline?: boolean;
multilineInputHeight?: number;
onBlur?: (event: NativeSyntheticEvent<TargetedEvent>) => void;
onFocus?: (e: NativeSyntheticEvent<TargetedEvent>) => void;
onLayout?: (e: LayoutChangeEvent) => void;
@ -117,6 +118,7 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
label = '',
labelTextStyle,
multiline,
multilineInputHeight,
onBlur,
onFocus,
onLayout,
@ -199,21 +201,23 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
res.push(textInputStyle);
if (multiline) {
res.push({height: 100, textAlignVertical: 'top'});
const height = multilineInputHeight || 100;
res.push({height, textAlignVertical: 'top'});
}
return res;
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, editable]);
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, multilineInputHeight, editable]);
const combinedTextInputStyle = useMemo(() => {
const res: StyleProp<TextStyle> = [styles.textInput, styles.input, textInputStyle];
if (multiline) {
res.push({height: 80, textAlignVertical: 'top'});
const height = multilineInputHeight ? multilineInputHeight - 20 : 80;
res.push({height, textAlignVertical: 'top'});
}
return res;
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, editable]);
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, multilineInputHeight, editable]);
const textAnimatedTextStyle = useAnimatedStyle(() => {
const inputText = placeholder || value || props.defaultValue;

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {Text, TouchableOpacity, useWindowDimensions} from 'react-native';
import {Text, TouchableOpacity, useWindowDimensions, type StyleProp, type ViewStyle} from 'react-native';
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
@ -17,6 +17,7 @@ type SelectedChipProps = {
extra?: React.ReactNode;
onRemove: (id: string) => void;
testID?: string;
containerStyle?: StyleProp<ViewStyle>;
}
export const USER_CHIP_HEIGHT = 32;
@ -54,11 +55,14 @@ export default function SelectedChip({
extra,
onRemove,
testID,
containerStyle,
}: SelectedChipProps) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const dimensions = useWindowDimensions();
const containerStyles = [style.container, containerStyle];
const onPress = useCallback(() => {
onRemove(id);
}, [onRemove, id]);
@ -67,7 +71,7 @@ export default function SelectedChip({
<Animated.View
entering={FadeIn.duration(FADE_DURATION)}
exiting={FadeOut.duration(FADE_DURATION)}
style={style.container}
style={containerStyles}
testID={testID}
>
{extra}

View file

@ -29,6 +29,12 @@ const styles = StyleSheet.create({
},
});
function getMaximumLineLength(code: string) {
return code.split('\n').reduce((prev, v) => Math.max(prev, v.length), 0);
}
const MAXIMUM_CODE_LINE_LENGTH = 300;
const Highlighter = ({code, language, textStyle, selectable = false}: SyntaxHiglightProps) => {
const theme = useTheme();
const style = codeTheme[theme.codeTheme] || github;
@ -38,6 +44,8 @@ const Highlighter = ({code, language, textStyle, selectable = false}: SyntaxHigl
{backgroundColor: style.hljs.background || theme.centerChannelBg},
],
[theme, selectable, style]);
const maximumLineLength = useMemo(() => getMaximumLineLength(code), [code]);
const languageToUse = maximumLineLength > MAXIMUM_CODE_LINE_LENGTH ? 'text' : language;
const nativeRenderer = useCallback(({rows, stylesheet}: rendererProps) => {
const digits = rows.length.toString().length;
@ -65,7 +73,7 @@ const Highlighter = ({code, language, textStyle, selectable = false}: SyntaxHigl
return (
<SyntaxHighlighter
style={style}
language={language}
language={languageToUse}
horizontal={true}
showLineNumbers={true}
renderer={nativeRenderer}

View file

@ -32,6 +32,7 @@ export default {
MAX_USERS_IN_GM: 7,
MIN_USERS_IN_GM: 3,
MAX_GROUP_CHANNELS_FOR_PROFILES: 50,
MAX_GET_ROLES_BY_NAMES: 100,
DEFAULT_AUTOLINKED_URL_SCHEMES: ['http', 'https', 'ftp', 'mailto', 'tel', 'mattermost'],
PROFILE_CHUNK_SIZE: 100,
SEARCH_TIMEOUT_MILLISECONDS: 500,

View file

@ -4,12 +4,14 @@
import assert from 'assert';
import {act, renderHook} from '@testing-library/react-hooks';
import {createIntl} from 'react-intl';
import InCallManager from 'react-native-incall-manager';
import * as CallsActions from '@calls/actions';
import {getConnectionForTesting} from '@calls/actions/calls';
import * as Permissions from '@calls/actions/permissions';
import {needsRecordingWillBePostedAlert, needsRecordingErrorAlert} from '@calls/alerts';
import {userLeftChannelErr, userRemovedFromChannelErr} from '@calls/errors';
import * as State from '@calls/state';
import {
myselfLeftCall,
@ -32,6 +34,7 @@ import {
DefaultCallsConfig,
DefaultCallsState,
} from '@calls/types/calls';
import {errorAlert} from '@calls/utils';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
@ -71,8 +74,8 @@ const mockClient = {
};
jest.mock('@calls/connection/connection', () => ({
newConnection: jest.fn(() => Promise.resolve({
disconnect: jest.fn(),
newConnection: jest.fn((serverURL, channelId, onClose) => Promise.resolve({
disconnect: jest.fn((err?: Error) => onClose(err)),
mute: jest.fn(),
unmute: jest.fn(),
waitForPeerConnection: jest.fn(() => Promise.resolve()),
@ -89,7 +92,17 @@ jest.mock('@queries/servers/thread', () => ({
})),
}));
jest.mock('@calls/alerts');
jest.mock('@calls/alerts', () => {
const alerts = jest.requireActual('../alerts');
return {
needsRecordingErrorAlert: jest.fn(),
needsRecordingWillBePostedAlert: jest.fn(),
showErrorAlertOnClose: alerts.showErrorAlertOnClose,
};
});
jest.mock('@calls/utils');
jest.mock('react-native-navigation', () => ({
Navigation: {
pop: jest.fn(() => Promise.resolve({
@ -174,7 +187,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true);
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, createIntl({
locale: 'en',
messages: {},
}));
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
@ -201,7 +217,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true);
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, createIntl({
locale: 'en',
messages: {},
}));
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
@ -235,7 +254,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true);
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, createIntl({
locale: 'en',
messages: {},
}));
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
@ -265,7 +287,10 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id', 'mysUserId', true);
response = await CallsActions.joinCall('server1', 'channel-id', 'mysUserId', true, createIntl({
locale: 'en',
messages: {},
}));
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
@ -396,4 +421,125 @@ describe('Actions.Calls', () => {
expect(mockClient.dismissCall).toBeCalledWith('channel-id');
});
it('userLeftChannelErr', async () => {
// setup
const {result} = renderHook(() => {
return [useCallsState('server1'), useCurrentCall()];
});
addFakeCall('server1', 'channel-id');
let response: { data?: string };
const intl = createIntl({
locale: 'en',
messages: {},
});
intl.formatMessage = jest.fn();
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, intl);
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');
assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id');
expect(newConnection).toBeCalled();
expect(newConnection.mock.calls[0][1]).toBe('channel-id');
expect(updateThreadFollowing).toBeCalled();
await act(async () => {
CallsActions.leaveCall(userLeftChannelErr);
});
expect(intl.formatMessage).toBeCalledWith({
id: 'mobile.calls_user_left_channel_error_title',
defaultMessage: 'You left the channel',
});
expect(intl.formatMessage).toBeCalledWith({
id: 'mobile.calls_user_left_channel_error_message',
defaultMessage: 'You have left the channel, and have been disconnected from the call.',
});
});
it('userRemovedFromChannelErr', async () => {
// setup
const {result} = renderHook(() => {
return [useCallsState('server1'), useCurrentCall()];
});
addFakeCall('server1', 'channel-id');
let response: { data?: string };
const intl = createIntl({
locale: 'en',
messages: {},
});
intl.formatMessage = jest.fn();
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, intl);
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');
assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id');
expect(newConnection).toBeCalled();
expect(newConnection.mock.calls[0][1]).toBe('channel-id');
expect(updateThreadFollowing).toBeCalled();
await act(async () => {
CallsActions.leaveCall(userRemovedFromChannelErr);
});
expect(intl.formatMessage).toBeCalledWith({
id: 'mobile.calls_user_removed_from_channel_error_title',
defaultMessage: 'You were removed from channel',
});
expect(intl.formatMessage).toBeCalledWith({
id: 'mobile.calls_user_removed_from_channel_error_message',
defaultMessage: 'You have been removed from the channel, and have been disconnected from the call.',
});
});
it('generic error on close', async () => {
// setup
const {result} = renderHook(() => {
return [useCallsState('server1'), useCurrentCall()];
});
addFakeCall('server1', 'channel-id');
let response: { data?: string };
const intl = createIntl({
locale: 'en',
messages: {},
});
intl.formatMessage = jest.fn();
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, intl);
// manually call newCurrentConnection because newConnection is mocked
newCurrentCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');
assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id');
expect(newConnection).toBeCalled();
expect(newConnection.mock.calls[0][1]).toBe('channel-id');
expect(updateThreadFollowing).toBeCalled();
await act(async () => {
CallsActions.leaveCall(new Error('generic error'));
});
expect(errorAlert).toBeCalled();
});
});

View file

@ -7,7 +7,12 @@ 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, needsRecordingErrorAlert, needsRecordingWillBePostedAlert} from '@calls/alerts';
import {
leaveAndJoinWithAlert,
needsRecordingErrorAlert,
needsRecordingWillBePostedAlert,
showErrorAlertOnClose,
} from '@calls/alerts';
import {
getCallsConfig,
getCallsState,
@ -230,6 +235,7 @@ export const joinCall = async (
channelId: string,
userId: string,
hasMicPermission: boolean,
intl: IntlShape,
title?: string,
rootId?: string,
): Promise<{ error?: unknown; data?: string }> => {
@ -248,8 +254,12 @@ export const joinCall = async (
newCurrentCall(serverUrl, channelId, userId);
try {
connection = await newConnection(serverUrl, channelId, () => {
connection = await newConnection(serverUrl, channelId, (err?: Error) => {
myselfLeftCall();
if (err) {
logDebug('calls: error on close', getFullErrorMessage(err));
showErrorAlertOnClose(err, intl);
}
}, setScreenShareURL, hasMicPermission, title, rootId);
} catch (error) {
await forceLogoutIfNecessary(serverUrl, error);
@ -285,9 +295,9 @@ export const joinCall = async (
}
};
export const leaveCall = () => {
export const leaveCall = (err?: Error) => {
if (connection) {
connection.disconnect();
connection.disconnect(err);
connection = null;
}
};

View file

@ -6,6 +6,7 @@ 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 {userLeftChannelErr, userRemovedFromChannelErr} from '@calls/errors';
import {
getCallsConfig,
getCallsState,
@ -19,6 +20,7 @@ import DatabaseManager from '@database/manager';
import {getChannelById} from '@queries/servers/channel';
import {getCurrentUser} from '@queries/servers/user';
import {isDMorGM} from '@utils/channel';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
import {isSystemAdmin} from '@utils/user';
@ -208,7 +210,7 @@ const doJoinCall = async (
removeIncomingCall(serverUrl, callId, channelId);
}
const res = await joinCall(serverUrl, channelId, user.id, hasPermission, title, rootId);
const res = await joinCall(serverUrl, channelId, user.id, hasPermission, intl, title, rootId);
if (res.error) {
const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'See server logs'});
errorAlert(res.error?.toString() || seeLogs, intl);
@ -243,7 +245,7 @@ export const needsRecordingAlert = () => {
recordingAlertLock = false;
};
export const recordingAlert = (isHost: boolean, intl: IntlShape) => {
export const recordingAlert = (isHost: boolean, transcriptionsEnabled: boolean, intl: IntlShape) => {
if (recordingAlertLock) {
return;
}
@ -251,22 +253,46 @@ export const recordingAlert = (isHost: boolean, intl: IntlShape) => {
const {formatMessage} = intl;
const participantTitle = formatMessage({
id: 'mobile.calls_participant_rec_title',
defaultMessage: 'Recording is in progress',
});
const hostTitle = formatMessage({
id: 'mobile.calls_host_rec_title',
defaultMessage: 'You are recording',
});
const hostMessage = formatMessage({
id: 'mobile.calls_host_rec',
defaultMessage: 'Consider letting everyone know that this meeting is being recorded.',
});
const participantTitle = formatMessage({
id: 'mobile.calls_participant_rec_title',
defaultMessage: 'Recording is in progress',
});
const participantMessage = formatMessage({
id: 'mobile.calls_participant_rec',
defaultMessage: 'The host has started recording this meeting. By staying in the meeting you give consent to being recorded.',
});
const hostMessage = formatMessage({
id: 'mobile.calls_host_rec',
defaultMessage: 'You are recording this meeting. Consider letting everyone know that this meeting is being recorded.',
const hostTranscriptionTitle = formatMessage({
id: 'mobile.calls_host_transcription_title',
defaultMessage: 'Recording and transcription has started',
});
const hostTranscriptionMessage = formatMessage({
id: 'mobile.calls_host_transcription',
defaultMessage: 'Consider letting everyone know that this meeting is being recorded and transcribed.',
});
const participantTranscriptionTitle = formatMessage({
id: 'mobile.calls_participant_transcription_title',
defaultMessage: 'Recording and transcription is in progress',
});
const participantTranscriptionMessage = formatMessage({
id: 'mobile.calls_participant_transcription',
defaultMessage: 'The host has started recording and transcription for this meeting. By staying in the meeting, you give consent to being recorded and transcribed.',
});
const hTitle = transcriptionsEnabled ? hostTranscriptionTitle : hostTitle;
const hMessage = transcriptionsEnabled ? hostTranscriptionMessage : hostMessage;
const pTitle = transcriptionsEnabled ? participantTranscriptionTitle : participantTitle;
const pMessage = transcriptionsEnabled ? participantTranscriptionMessage : participantMessage;
const participantButtons = [
{
@ -295,8 +321,8 @@ export const recordingAlert = (isHost: boolean, intl: IntlShape) => {
}];
Alert.alert(
isHost ? hostTitle : participantTitle,
isHost ? hostMessage : participantMessage,
isHost ? hTitle : pTitle,
isHost ? hMessage : pMessage,
isHost ? hostButton : participantButtons,
);
};
@ -360,3 +386,35 @@ export const recordingErrorAlert = (intl: IntlShape) => {
}],
);
};
export const showErrorAlertOnClose = (err: Error, intl: IntlShape) => {
switch (err) {
case userLeftChannelErr:
Alert.alert(
intl.formatMessage({
id: 'mobile.calls_user_left_channel_error_title',
defaultMessage: 'You left the channel',
}),
intl.formatMessage({
id: 'mobile.calls_user_left_channel_error_message',
defaultMessage: 'You have left the channel, and have been disconnected from the call.',
}),
);
break;
case userRemovedFromChannelErr:
Alert.alert(
intl.formatMessage({
id: 'mobile.calls_user_removed_from_channel_error_title',
defaultMessage: 'You were removed from channel',
}),
intl.formatMessage({
id: 'mobile.calls_user_removed_from_channel_error_message',
defaultMessage: 'You have been removed from the channel, and have been disconnected from the call.',
}),
);
break;
default:
// Fallback with generic error
errorAlert(getFullErrorMessage(err, intl), intl);
}
};

View file

@ -12,11 +12,12 @@ import CallDuration from '@calls/components/call_duration';
import MessageBar from '@calls/components/message_bar';
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
import {usePermissionsChecker} from '@calls/hooks';
import {setCallQualityAlertDismissed, setMicPermissionsErrorDismissed} from '@calls/state';
import {setCallQualityAlertDismissed, setMicPermissionsErrorDismissed, useCallsConfig} from '@calls/state';
import {makeCallsTheme} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import {Calls, Screens} from '@constants';
import {CURRENT_CALL_BAR_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {allOrientations, dismissAllModalsAndPopToScreen} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -140,6 +141,8 @@ const CurrentCallBar = ({
threadScreen,
}: Props) => {
const theme = useTheme();
const serverUrl = useServerUrl();
const {EnableTranscriptions} = useCallsConfig(serverUrl);
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
const style = getStyleSheet(callsTheme);
const intl = useIntl();
@ -209,7 +212,7 @@ const CurrentCallBar = ({
// - Recording has started and recording has not ended.
const isHost = Boolean(currentCall?.hostId === mySession?.userId);
if (currentCall?.recState?.start_at && !currentCall?.recState?.end_at) {
recordingAlert(isHost, intl);
recordingAlert(isHost, EnableTranscriptions, intl);
}
// The user should receive a recording finished alert if all of the following conditions apply:

View file

@ -39,7 +39,7 @@ if (Platform.OS === 'android') {
export async function newConnection(
serverUrl: string,
channelID: string,
closeCb: () => void,
closeCb: (err?: Error) => void,
setScreenShareURL: (url: string) => void,
hasMicPermission: boolean,
title?: string,
@ -93,7 +93,7 @@ export async function newConnection(
initializeVoiceTrack();
}
const disconnect = () => {
const disconnect = (err?: Error) => {
if (isClosed) {
return;
}
@ -126,7 +126,7 @@ export async function newConnection(
}
if (closeCb) {
closeCb();
closeCb(err);
}
};

View file

@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createContext} from 'react';
export const CaptionsEnabledContext = createContext<boolean[]>([]);

View file

@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const userRemovedFromChannelErr = new Error('user was removed from channel');
export const userLeftChannelErr = new Error('user has left channel');

View file

@ -328,7 +328,7 @@ const CallScreen = ({
const {width, height} = useWindowDimensions();
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const {EnableRecordings} = useCallsConfig(serverUrl);
const {EnableRecordings, EnableTranscriptions} = useCallsConfig(serverUrl);
usePermissionsChecker(micPermissionsGranted);
const incomingCalls = useIncomingCalls();
@ -453,7 +453,7 @@ const CallScreen = ({
const isHost = Boolean(currentCall?.hostId === mySession?.userId);
const recording = Boolean(currentCall?.recState?.start_at && !currentCall.recState.end_at);
if (recording) {
recordingAlert(isHost, intl);
recordingAlert(isHost, EnableTranscriptions, intl);
}
// The user should receive a recording finished alert if all of the following conditions apply:

View file

@ -124,7 +124,7 @@ export type CallSession = {
export type ChannelsWithCalls = Dictionary<boolean>;
export type CallsConnection = {
disconnect: () => void;
disconnect: (err?: Error) => void;
mute: () => void;
unmute: () => void;
waitForPeerConnection: () => Promise<void>;
@ -193,3 +193,15 @@ export type CallsVersion = {
version?: string;
build?: string;
};
export type SubtitleTrack = {
title?: string | undefined;
language?: string | undefined;
type: 'application/x-subrip' | 'application/ttml+xml' | 'text/vtt';
uri: string;
};
export type SelectedSubtitleTrack = {
type: 'system' | 'disabled' | 'title' | 'language' | 'index';
value?: string | number | undefined;
};

View file

@ -3,14 +3,16 @@
import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls';
import {Alert} from 'react-native';
import {TextTrackType} from 'react-native-video';
import {buildFileUrl} from '@actions/remote/file';
import {Calls, Post} from '@constants';
import {NOTIFICATION_SUB_TYPE} from '@constants/push_notification';
import {isMinimumServerVersion} from '@utils/helpers';
import {displayUsername} from '@utils/user';
import type {CallSession, CallsTheme, CallsVersion} from '@calls/types/calls';
import type {CallsConfig} from '@mattermost/calls/lib/types';
import type {CallSession, CallsTheme, CallsVersion, SelectedSubtitleTrack, SubtitleTrack} from '@calls/types/calls';
import type {CallsConfig, Caption} from '@mattermost/calls/lib/types';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
import type {IntlShape} from 'react-intl';
@ -201,3 +203,33 @@ export function isCallsStartedMessage(payload?: NotificationData) {
// calls will be >= 0.21.0, and push proxy will be >= 5.27.0
return (payload?.message === 'You\'ve been invited to a call' || callsMessageRegex.test(payload?.message || ''));
}
export const hasCaptions = (postProps?: Record<string, any> & { captions?: Caption[] }): boolean => {
return !(!postProps || !postProps.captions?.[0]);
};
export const getTranscriptionUri = (serverUrl: string, postProps?: Record<string, any> & { captions?: Caption[] }): {
tracks?: SubtitleTrack[];
selected: SelectedSubtitleTrack;
} => {
// Note: We're not using hasCaptions above because this tells typescript that the caption exists later.
// We could use some fancy typescript to do the same, but it's not worth the complexity.
if (!postProps || !postProps.captions?.[0]) {
return {
tracks: undefined,
selected: {type: 'disabled'},
};
}
const tracks: SubtitleTrack[] = postProps.captions.map((t) => ({
title: t.title,
language: t.language,
type: TextTrackType.VTT,
uri: buildFileUrl(serverUrl, t.file_id),
}));
return {
tracks,
selected: {type: 'index', value: 0},
};
};

View file

@ -201,7 +201,7 @@ export default function ChannelAddMembers({
const result = await fetchProfilesNotInChannel(serverUrl, channel.teamId, channel.id, channel.isGroupConstrained, page, General.PROFILE_CHUNK_SIZE);
if (result.users?.length) {
return result.users;
return result.users.filter((u) => !u.delete_at);
}
return [];
@ -213,7 +213,7 @@ export default function ChannelAddMembers({
}
const lowerCasedTerm = searchTerm.toLowerCase();
const results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: channel.teamId, not_in_channel_id: channel.id, allow_inactive: true});
const results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: channel.teamId, not_in_channel_id: channel.id, allow_inactive: false});
if (results.data) {
return results.data;

View file

@ -219,7 +219,8 @@ const FilteredList = ({
/>
);
}
if ('teamId' in item || 'team_id' in item) {
if ('teamId' in item) {
return (
<ChannelItem
channel={item}
@ -230,7 +231,9 @@ const FilteredList = ({
testID='find_channels.filtered_list.channel_item'
/>
);
} else if ('username' in item) {
}
if ('username' in item) {
return (
<UserItem
onUserPress={onOpenDirectMessage}
@ -244,6 +247,7 @@ const FilteredList = ({
return (
<ChannelItem
channel={item}
isOnCenterBg={true}
onPress={onJoinChannel}
showTeamName={showTeamName}
shouldHighlightState={true}

View file

@ -5,7 +5,15 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {Platform, Pressable, type PressableAndroidRippleConfig, type PressableStateCallbackType, type StyleProp, type ViewStyle} from 'react-native';
import {
Platform,
Pressable,
type PressableAndroidRippleConfig,
type PressableStateCallbackType,
type StyleProp,
StyleSheet,
type ViewStyle,
} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {changeOpacity} from '@utils/theme';
@ -26,11 +34,21 @@ const pressedStyle = ({pressed}: PressableStateCallbackType) => {
return [{opacity}];
};
const baseStyle = StyleSheet.create({
container: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
});
const androidRippleConfig: PressableAndroidRippleConfig = {borderless: true, radius: 24, color: '#FFF'};
const Action = ({disabled, iconName, onPress, style}: Props) => {
const pressableStyle = useCallback((pressed: PressableStateCallbackType) => ([
pressedStyle(pressed),
baseStyle.container,
style,
]), [style]);
@ -38,7 +56,7 @@ const Action = ({disabled, iconName, onPress, style}: Props) => {
<Pressable
android_ripple={androidRippleConfig}
disabled={disabled}
hitSlop={24}
hitSlop={4}
onPress={onPress}
style={pressableStyle}
>

View file

@ -5,6 +5,8 @@ import {useManagedConfig} from '@mattermost/react-native-emm';
import React from 'react';
import {StyleSheet, View} from 'react-native';
import InvertedAction from '@screens/gallery/footer/actions/inverted_action';
import Action from './action';
type Props = {
@ -15,20 +17,22 @@ type Props = {
onCopyPublicLink: () => void;
onDownload: () => void;
onShare: () => void;
hasCaptions: boolean;
captionEnabled: boolean;
onCaptionsPress: () => void;
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
},
action: {
marginLeft: 24,
alignItems: 'center',
gap: 8,
},
});
const Actions = ({
canDownloadFiles, disabled, enablePublicLinks, fileId,
onCopyPublicLink, onDownload, onShare,
canDownloadFiles, disabled, enablePublicLinks, fileId, onCopyPublicLink,
onDownload, onShare, hasCaptions, captionEnabled, onCaptionsPress,
}: Props) => {
const managedConfig = useManagedConfig<ManagedConfig>();
const canCopyPublicLink = !fileId.startsWith('uid') && enablePublicLinks && managedConfig.copyAndPasteProtection !== 'true';
@ -41,19 +45,24 @@ const Actions = ({
iconName='link-variant'
onPress={onCopyPublicLink}
/>}
{hasCaptions &&
<InvertedAction
activated={captionEnabled}
iconName='closed-caption-outline'
onPress={onCaptionsPress}
/>
}
{canDownloadFiles &&
<>
<Action
disabled={disabled}
iconName='download-outline'
onPress={onDownload}
style={styles.action}
/>
<Action
disabled={disabled}
iconName='export-variant'
onPress={onShare}
style={styles.action}
/>
</>
}

View file

@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {
Platform,
Pressable,
type PressableAndroidRippleConfig,
type PressableStateCallbackType,
type StyleProp,
StyleSheet,
type ViewStyle,
} from 'react-native';
import CompassIcon from '@components/compass_icon';
type Props = {
activated: boolean;
iconName: string;
onPress: () => void;
style?: StyleProp<ViewStyle>;
}
const pressedStyle = ({pressed}: PressableStateCallbackType) => {
let opacity = 1;
if (Platform.OS === 'ios' && pressed) {
opacity = 0.5;
}
return [{opacity}];
};
const baseStyle = StyleSheet.create({
container: {
width: 40,
height: 40,
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
},
containerActivated: {
backgroundColor: '#fff',
},
});
const androidRippleConfig: PressableAndroidRippleConfig = {borderless: true, radius: 24, color: '#FFF'};
const InvertedAction = ({activated, iconName, onPress, style}: Props) => {
const pressableStyle = useCallback((pressed: PressableStateCallbackType) => ([
pressedStyle(pressed),
baseStyle.container,
activated && baseStyle.containerActivated,
style,
]), [style, activated]);
return (
<Pressable
android_ripple={androidRippleConfig}
hitSlop={4}
onPress={onPress}
style={pressableStyle}
>
<CompassIcon
color={activated ? '#000' : '#fff'}
name={iconName}
size={24}
/>
</Pressable>
);
};
export default InvertedAction;

View file

@ -35,6 +35,9 @@ type Props = {
post?: PostModel;
style: StyleProp<ViewStyle>;
teammateNameDisplay: string;
hasCaptions: boolean;
captionEnabled: boolean;
onCaptionsPress: () => void;
}
const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView);
@ -58,6 +61,7 @@ const Footer = ({
author, canDownloadFiles, channelName, currentUserId,
enablePostIconOverride, enablePostUsernameOverride, enablePublicLink,
hideActions, isDirectChannel, item, post, style, teammateNameDisplay,
hasCaptions, captionEnabled, onCaptionsPress,
}: Props) => {
const showActions = !hideActions && Boolean(item.id) && !item.id?.startsWith('uid');
const [action, setAction] = useState<GalleryAction>('none');
@ -142,6 +146,9 @@ const Footer = ({
onCopyPublicLink={handleCopyLink}
onDownload={handleDownload}
onShare={handleShare}
hasCaptions={hasCaptions}
captionEnabled={captionEnabled}
onCaptionsPress={onCaptionsPress}
/>
}
</View>

View file

@ -1,9 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {NativeModules, useWindowDimensions, Platform} from 'react-native';
import {CaptionsEnabledContext} from '@calls/context';
import {hasCaptions} from '@calls/utils';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import {useGalleryControls} from '@hooks/gallery';
@ -29,10 +31,27 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde
const dim = useWindowDimensions();
const isTablet = useIsTablet();
const [localIndex, setLocalIndex] = useState(initialIndex);
const [captionsEnabled, setCaptionsEnabled] = useState<boolean[]>(new Array(items.length).fill(true));
const [captionsAvailable, setCaptionsAvailable] = useState<boolean[]>([]);
const {setControlsHidden, headerStyles, footerStyles} = useGalleryControls();
const dimensions = useMemo(() => ({width: dim.width, height: dim.height}), [dim.width]);
const galleryRef = useRef<GalleryRef>(null);
useEffect(() => {
const captions = items.reduce((acc, item) => {
acc.push(hasCaptions(item.postProps));
return acc;
}, [] as boolean[]);
setCaptionsAvailable(captions);
}, [items]);
const onCaptionsPressIdx = useCallback((idx: number) => {
const enabled = [...captionsEnabled];
enabled[idx] = !enabled[idx];
setCaptionsEnabled(enabled);
}, [captionsEnabled, setCaptionsEnabled]);
const onCaptionsPress = useCallback(() => onCaptionsPressIdx(localIndex), [localIndex, onCaptionsPressIdx]);
const onClose = useCallback(() => {
// We keep the un freeze here as we want
// the screen to be visible when the gallery
@ -63,7 +82,7 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde
useAndroidHardwareBackHandler(componentId, close);
return (
<>
<CaptionsEnabledContext.Provider value={captionsEnabled}>
<Header
index={localIndex}
onClose={onClose}
@ -84,8 +103,11 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde
hideActions={hideActions}
item={items[localIndex]}
style={footerStyles}
hasCaptions={captionsAvailable[localIndex]}
captionEnabled={captionsEnabled[localIndex]}
onCaptionsPress={onCaptionsPress}
/>
</>
</CaptionsEnabledContext.Provider>
);
};

View file

@ -1,13 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter, Platform, StyleSheet, useWindowDimensions} from 'react-native';
import Animated, {Easing, useAnimatedRef, useAnimatedStyle, useSharedValue, withTiming, type WithTimingConfig} from 'react-native-reanimated';
import Animated, {
Easing,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
withTiming,
type WithTimingConfig,
} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import Video, {type OnPlaybackRateData} from 'react-native-video';
import {updateLocalFilePath} from '@actions/local/file';
import {CaptionsEnabledContext} from '@calls/context';
import {getTranscriptionUri} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import {Events} from '@constants';
import {GALLERY_FOOTER_HEIGHT, VIDEO_INSET} from '@constants/gallery';
@ -59,12 +68,14 @@ const VideoRenderer = ({height, index, initialIndex, item, isPageActive, onShoul
const serverUrl = useServerUrl();
const videoRef = useAnimatedRef<Video>();
const showControls = useRef(!(initialIndex === index));
const captionsEnabled = useContext(CaptionsEnabledContext);
const [paused, setPaused] = useState(!(initialIndex === index));
const [videoReady, setVideoReady] = useState(false);
const [videoUri, setVideoUri] = useState(item.uri);
const [downloading, setDownloading] = useState(false);
const [hasError, setHasError] = useState(false);
const source = useMemo(() => ({uri: videoUri}), [videoUri]);
const {tracks, selected} = useMemo(() => getTranscriptionUri(serverUrl, item.postProps), [serverUrl, item.postProps]);
const setFullscreen = (value: boolean) => {
fullscreen.value = value;
@ -183,6 +194,8 @@ const VideoRenderer = ({height, index, initialIndex, item, isPageActive, onShoul
onReadyForDisplay={onReadyForDisplay}
onEnd={onEnd}
onTouchStart={handleTouchStart}
textTracks={tracks}
selectedTextTrack={captionsEnabled[index] ? selected : {type: 'disabled'}}
/>
{Platform.OS === 'android' && paused && videoReady &&
<Animated.View style={styles.playContainer}>

View file

@ -42,7 +42,6 @@ const DisplayTheme = ({allowedThemeKeys, componentId, currentTeamId, currentUser
const setThemePreference = useCallback(() => {
const allowedTheme = allowedThemeKeys.find((tk) => tk === newTheme);
const themeJson = Preferences.THEMES[allowedTheme as ThemeKey] || initialTheme;
const pref: PreferenceType = {
@ -70,7 +69,7 @@ const DisplayTheme = ({allowedThemeKeys, componentId, currentTeamId, currentUser
/>
{initialTheme.type === 'custom' && (
<CustomTheme
setTheme={setThemePreference}
setTheme={setNewTheme}
displayTheme={initialTheme.type}
/>
)}

View file

@ -117,6 +117,7 @@ const NotificationAutoResponder = ({currentUser, componentId}: NotificationAutoR
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
label={intl.formatMessage(label)}
multiline={true}
multilineInputHeight={154}
onChangeText={setAutoResponderMessage}
placeholder={intl.formatMessage(label)}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.4)}

View file

@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getMentionProps, canSaveSettings, getUniqueKeywordsFromInput, type CanSaveSettings} from './mention_settings';
import type UserModel from '@typings/database/models/servers/user';
describe('getMentionProps', () => {
test('Should have correct return type when input is empty', () => {
const mentionProps = getMentionProps({notifyProps: {} as UserNotifyProps} as UserModel);
expect(mentionProps).toEqual({
mentionKeywords: [],
usernameMention: false,
channel: false,
first_name: false,
comments: '',
notifyProps: {},
});
});
test('Should have correct return type for when channel, first_name, currentUser.username are provided', () => {
const mentionProps = getMentionProps({
username: 'testUser',
notifyProps: {
comments: 'any',
channel: 'true',
first_name: 'true',
mention_keys: 'testUser',
} as UserNotifyProps,
} as UserModel);
expect(mentionProps.mentionKeywords).toEqual([]);
expect(mentionProps.usernameMention).toEqual(true);
expect(mentionProps.channel).toEqual(true);
expect(mentionProps.first_name).toEqual(true);
});
test('Should have correct return type for mention_keys input', () => {
const mentionProps = getMentionProps({
username: 'testUser',
notifyProps: {
mention_keys: 'testUser,testUser2,testKey1,testKey2',
} as UserNotifyProps,
} as UserModel);
expect(mentionProps.mentionKeywords).toHaveLength(3);
expect(mentionProps.mentionKeywords).toEqual(['testUser2', 'testKey1', 'testKey2']);
});
});
describe('canSaveSettings', () => {
test('Should return true when mentionKeywords have changed', () => {
const canSaveSettingParams = {
mentionKeywords: ['test1', 'test2'],
mentionProps: {
mentionKeywords: ['test1', 'test2', 'test3'],
},
} as CanSaveSettings;
expect(canSaveSettings(canSaveSettingParams)).toEqual(true);
});
test('Should return false when mentionKeywords have not changed', () => {
const canSaveSettingParams = {
mentionKeywords: ['test1', 'test2'],
mentionProps: {
mentionKeywords: ['test2', 'test1'],
},
} as CanSaveSettings;
expect(canSaveSettings(canSaveSettingParams)).toEqual(false);
});
test('Should return true when only userName has changed', () => {
const canSaveSettingParams = {
channelMentionOn: true,
replyNotificationType: 'any',
firstNameMentionOn: true,
usernameMentionOn: true,
mentionKeywords: ['test1', 'test2'],
mentionProps: {
channel: true,
comments: 'any' as UserNotifyProps['comments'],
first_name: true,
usernameMention: false,
mentionKeywords: ['test1', 'test2'],
notifyProps: {} as UserNotifyProps,
},
};
expect(canSaveSettings(canSaveSettingParams)).toEqual(true);
});
});
describe('getUniqueKeywordsFromInput', () => {
test('Should return empty if input is empty and keywords are empty', () => {
expect(getUniqueKeywordsFromInput('', [])).toEqual([]);
});
test('Should return same keywords if input is empty', () => {
expect(getUniqueKeywordsFromInput('', ['test1', 'test2'])).toEqual(['test1', 'test2']);
});
test('Should return same input if keywords are empty', () => {
expect(getUniqueKeywordsFromInput('test1', [])).toEqual(['test1']);
});
test('Should filter out commas from input', () => {
expect(getUniqueKeywordsFromInput('tes,,t1,', [])).toEqual(['test1']);
expect(getUniqueKeywordsFromInput(',, ,', ['test1'])).toEqual(['test1']);
});
test('Should filter out spaces from input', () => {
expect(getUniqueKeywordsFromInput('t es t 1', [])).toEqual(['test1']);
});
test('Should filter out duplicate keywords from input', () => {
expect(getUniqueKeywordsFromInput('te,s t1', ['test1', 'test2'])).toEqual(['test1', 'test2']);
});
});

View file

@ -6,7 +6,7 @@ import {useIntl} from 'react-intl';
import {Text} from 'react-native';
import {updateMe} from '@actions/remote/user';
import FloatingTextInput from '@components/floating_text_input_label';
import FloatingTextChipsInput from '@components/floating_text_chips_input';
import SettingBlock from '@components/settings/block';
import SettingOption from '@components/settings/option';
import SettingSeparator from '@components/settings/separator';
@ -17,6 +17,7 @@ import useBackNavigation from '@hooks/navigate_back';
import {t} from '@i18n';
import {popTopScreen} from '@screens/navigation';
import ReplySettings from '@screens/settings/notification_mention/reply_settings';
import {areBothStringArraysEqual} from '@utils/helpers';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getNotificationProps} from '@utils/user';
@ -29,11 +30,12 @@ const mentionHeaderText = {
defaultMessage: 'Keywords that trigger mentions',
};
const COMMA_KEY = ',';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
input: {
color: theme.centerChannelColor,
height: 150,
paddingHorizontal: 15,
...typography('Body', 100, 'Regular'),
},
@ -42,7 +44,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
alignSelf: 'center',
paddingHorizontal: 18.5,
},
labelTextStyle: {left: 32},
keywordLabelStyle: {
paddingHorizontal: 18.5,
marginTop: 4,
@ -52,36 +53,74 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
};
});
const getMentionProps = (currentUser?: UserModel) => {
const notifyProps = getNotificationProps(currentUser);
const mKeys = (notifyProps.mention_keys || '').split(',');
const usernameMentionIndex = currentUser ? mKeys.indexOf(currentUser.username) : -1;
if (usernameMentionIndex > -1) {
mKeys.splice(usernameMentionIndex, 1);
}
return {
mentionKeywords: mKeys.join(','),
usernameMention: usernameMentionIndex > -1,
channel: notifyProps.channel === 'true',
first_name: notifyProps.first_name === 'true',
comments: notifyProps.comments,
notifyProps,
};
};
type MentionSectionProps = {
type Props = {
componentId: AvailableScreens;
currentUser?: UserModel;
isCRTEnabled: boolean;
};
export function getMentionProps(currentUser?: UserModel) {
const notifyProps = getNotificationProps(currentUser);
const mentionKeys = notifyProps?.mention_keys ?? '';
let mentionKeywords: string[] = [];
let usernameMention = false;
mentionKeys.split(',').forEach((mentionKey) => {
if (currentUser && mentionKey === currentUser.username) {
usernameMention = true;
} else if (mentionKey) {
mentionKeywords = [...mentionKeywords, mentionKey];
}
});
return {
mentionKeywords,
usernameMention,
channel: notifyProps.channel === 'true',
first_name: notifyProps.first_name === 'true',
comments: notifyProps.comments || '',
notifyProps,
};
}
const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectionProps) => {
export type CanSaveSettings = {
channelMentionOn: boolean;
replyNotificationType: string;
firstNameMentionOn: boolean;
mentionKeywords: string[];
usernameMentionOn: boolean;
mentionProps: ReturnType<typeof getMentionProps>;
}
export function canSaveSettings({channelMentionOn, replyNotificationType, firstNameMentionOn, mentionKeywords, usernameMentionOn, mentionProps}: CanSaveSettings) {
const channelChanged = channelMentionOn !== mentionProps.channel;
const replyChanged = replyNotificationType !== mentionProps.comments;
const firstNameChanged = firstNameMentionOn !== mentionProps.first_name;
const userNameChanged = usernameMentionOn !== mentionProps.usernameMention;
const mentionKeywordsChanged = !areBothStringArraysEqual(mentionKeywords, mentionProps.mentionKeywords);
return channelChanged || replyChanged || firstNameChanged || userNameChanged || mentionKeywordsChanged;
}
export function getUniqueKeywordsFromInput(inputText: string, keywords: string[]) {
// Replace all the spaces and commas
const formattedInputText = inputText.trim().replace(/ |,/g, '');
// Check if the keyword is not empty and not already in the list
if (formattedInputText.length > 0 && !keywords.includes(formattedInputText)) {
return [...keywords, formattedInputText];
}
return keywords;
}
const MentionSettings = ({componentId, currentUser, isCRTEnabled}: Props) => {
const serverUrl = useServerUrl();
const mentionProps = useMemo(() => getMentionProps(currentUser), []);
const notifyProps = mentionProps.notifyProps;
const [mentionKeywords, setMentionKeywords] = useState(mentionProps.mentionKeywords);
const [mentionKeywordsInput, setMentionKeywordsInput] = useState('');
const [channelMentionOn, setChannelMentionOn] = useState(mentionProps.channel);
const [firstNameMentionOn, setFirstNameMentionOn] = useState(mentionProps.first_name);
const [usernameMentionOn, setUsernameMentionOn] = useState(mentionProps.usernameMention);
@ -93,36 +132,32 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio
const close = () => popTopScreen(componentId);
const canSaveSettings = useCallback(() => {
const channelChanged = channelMentionOn !== mentionProps.channel;
const replyChanged = replyNotificationType !== mentionProps.comments;
const fNameChanged = firstNameMentionOn !== mentionProps.first_name;
const mnKeysChanged = mentionProps.mentionKeywords !== mentionKeywords;
const userNameChanged = usernameMentionOn !== mentionProps.usernameMention;
return fNameChanged || userNameChanged || channelChanged || mnKeysChanged || replyChanged;
}, [firstNameMentionOn, channelMentionOn, usernameMentionOn, mentionKeywords, notifyProps, replyNotificationType]);
const saveMention = useCallback(() => {
if (!currentUser) {
return;
}
const canSave = canSaveSettings();
const canSave = canSaveSettings({
channelMentionOn,
replyNotificationType,
firstNameMentionOn,
usernameMentionOn,
mentionKeywords,
mentionProps,
});
if (canSave) {
const mention_keys = [];
if (mentionKeywords.length > 0) {
mentionKeywords.split(',').forEach((m) => mention_keys.push(m.replace(/\s/g, '')));
}
let mention_keys = [];
if (usernameMentionOn) {
mention_keys.push(`${currentUser.username}`);
}
mention_keys = [...mention_keys, ...mentionKeywords];
const notify_props: UserNotifyProps = {
...notifyProps,
first_name: `${firstNameMentionOn}`,
channel: `${channelMentionOn}`,
first_name: firstNameMentionOn ? 'true' : 'false',
channel: channelMentionOn ? 'true' : 'false',
mention_keys: mention_keys.join(','),
comments: replyNotificationType,
};
@ -131,30 +166,60 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio
close();
}, [
canSaveSettings,
channelMentionOn,
firstNameMentionOn,
usernameMentionOn,
mentionKeywords,
notifyProps,
mentionProps,
replyNotificationType,
serverUrl,
currentUser,
]);
const onToggleFirstName = useCallback(() => {
const handleFirstNameToggle = useCallback(() => {
setFirstNameMentionOn((prev) => !prev);
}, []);
const onToggleUserName = useCallback(() => {
const handleUsernameToggle = useCallback(() => {
setUsernameMentionOn((prev) => !prev);
}, []);
const onToggleChannel = useCallback(() => {
const handleChannelToggle = useCallback(() => {
setChannelMentionOn((prev) => !prev);
}, []);
const onChangeText = useCallback((text: string) => {
setMentionKeywords(text);
}, []);
function appendKeywordsAndClearInput(key: string, list: string[]) {
const keyAppendedToList = getUniqueKeywordsFromInput(key, list);
setMentionKeywordsInput('');
requestAnimationFrame(() => {
setMentionKeywords(keyAppendedToList);
});
}
/**
* Handler on every key press in the input
*/
const handleMentionKeywordsInputChanged = useCallback((text: string) => {
if (text.includes(COMMA_KEY)) {
appendKeywordsAndClearInput(text, mentionKeywords);
} else {
setMentionKeywordsInput(text);
}
}, [mentionKeywords]);
/**
* Handler when the user presses the enter key on keyboard
* Takes unsaved keywords from the input and adds them to the list
*/
const handleMentionKeywordEntered = useCallback(() => {
appendKeywordsAndClearInput(mentionKeywordsInput, mentionKeywords);
}, [mentionKeywordsInput, mentionKeywords]);
const handleMentionKeywordRemoved = useCallback((keyword: string) => {
setMentionKeywords(mentionKeywords.filter((item) => item !== keyword));
}, [mentionKeywords]);
useBackNavigation(saveMention);
@ -168,7 +233,7 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio
{Boolean(currentUser?.firstName) && (
<>
<SettingOption
action={onToggleFirstName}
action={handleFirstNameToggle}
description={intl.formatMessage({id: 'notification_settings.mentions.sensitiveName', defaultMessage: 'Your case sensitive first name'})}
label={currentUser!.firstName}
selected={firstNameMentionOn}
@ -177,11 +242,10 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio
/>
<SettingSeparator/>
</>
)
}
)}
{Boolean(currentUser?.username) && (
<SettingOption
action={onToggleUserName}
action={handleUsernameToggle}
description={intl.formatMessage({id: 'notification_settings.mentions.sensitiveUsername', defaultMessage: 'Your non-case sensitive username'})}
label={currentUser!.username}
selected={usernameMentionOn}
@ -191,7 +255,7 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio
)}
<SettingSeparator/>
<SettingOption
action={onToggleChannel}
action={handleChannelToggle}
description={intl.formatMessage({id: 'notification_settings.mentions.channelWide', defaultMessage: 'Channel-wide mentions'})}
label='@channel, @all, @here'
selected={channelMentionOn}
@ -199,32 +263,38 @@ const MentionSettings = ({componentId, currentUser, isCRTEnabled}: MentionSectio
type='toggle'
/>
<SettingSeparator/>
<FloatingTextInput
<FloatingTextChipsInput
allowFontScaling={true}
autoCapitalize='none'
autoCorrect={false}
blurOnSubmit={true}
containerStyle={styles.containerStyle}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
label={intl.formatMessage({id: 'notification_settings.mentions.keywords', defaultMessage: 'Keywords'})}
multiline={true}
onChangeText={onChangeText}
placeholder={intl.formatMessage({id: 'notification_settings.mentions..keywordsDescription', defaultMessage: 'Other words that trigger a mention'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.4)}
label={intl.formatMessage({
id: 'notification_settings.mentions.keywords',
defaultMessage: 'Enter other keywords',
})}
onTextInputChange={handleMentionKeywordsInputChanged}
onChipRemove={handleMentionKeywordRemoved}
returnKeyType='done'
testID='mention_notification_settings.keywords.input'
textInputStyle={styles.input}
textAlignVertical='top'
textAlignVertical='center'
theme={theme}
underlineColorAndroid='transparent'
value={mentionKeywords}
labelTextStyle={styles.labelTextStyle}
chipsValues={mentionKeywords}
textInputValue={mentionKeywordsInput}
onTextInputSubmitted={handleMentionKeywordEntered}
/>
<Text
style={styles.keywordLabelStyle}
testID='mention_notification_settings.keywords.input.description'
>
{intl.formatMessage({id: 'notification_settings.mentions.keywordsLabel', defaultMessage: 'Keywords are not case-sensitive. Separate keywords with commas.'})}
{intl.formatMessage({
id: 'notification_settings.mentions.keywordsLabel',
defaultMessage:
'Keywords are not case-sensitive. Separate keywords with commas.',
})}
</Text>
</SettingBlock>
{!isCRTEnabled && (

View file

@ -28,7 +28,7 @@ export const clampVelocity = (velocity: number, minVelocity: number, maxVelocity
return Math.max(Math.min(velocity, -minVelocity), -maxVelocity);
};
export const fileToGalleryItem = (file: FileInfo, authorId?: string, lastPictureUpdate = 0): GalleryItemType => {
export const fileToGalleryItem = (file: FileInfo, authorId?: string, postProps?: Record<string, any>, lastPictureUpdate = 0): GalleryItemType => {
let type: GalleryItemType['type'] = 'file';
if (isVideo(file)) {
type = 'video';
@ -50,6 +50,7 @@ export const fileToGalleryItem = (file: FileInfo, authorId?: string, lastPicture
type,
uri: file.localPath || file.uri || '',
width: file.width,
postProps: postProps || file.postProps,
};
};

48
app/utils/helpers.test.ts Normal file
View file

@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {areBothStringArraysEqual} from '@utils/helpers';
describe('areBothStringArraysEqual', () => {
test('Should return false when length of arrays are not equal', () => {
const array1 = ['test1', 'test2'];
const array2 = ['test1'];
expect(areBothStringArraysEqual(array1, array2)).toEqual(false);
});
test('Should return false when arrays are not equal', () => {
const array1 = ['test1', 'test2'];
const array2 = ['test1', 'test2', 'test3'];
expect(areBothStringArraysEqual(array1, array2)).toEqual(false);
});
test('Should return false when either array is empty', () => {
const array1 = ['test1', 'test2'];
const array2: string[] = [];
expect(areBothStringArraysEqual(array1, array2)).toEqual(false);
});
test('Should return true when arrays are equal', () => {
const array1 = ['test1', 'test2'];
const array2 = ['test1', 'test2'];
expect(areBothStringArraysEqual(array1, array2)).toEqual(true);
});
test('Should return true when arrays are equal but in different order', () => {
const array1 = ['test1', 'test2'];
const array2 = ['test2', 'test1'];
expect(areBothStringArraysEqual(array1, array2)).toEqual(true);
});
test('Should return true when both arrays are empty', () => {
const array1: string[] = [];
const array2: string[] = [];
expect(areBothStringArraysEqual(array1, array2)).toEqual(false);
});
});

View file

@ -157,3 +157,19 @@ export function isMainActivity() {
android: ShareModule?.getCurrentActivityName() === 'MainActivity',
});
}
export function areBothStringArraysEqual(a: string[], b: string[]) {
if (a.length !== b.length) {
return false;
}
if (a.length === 0 && b.length === 0) {
return false;
}
const aSorted = a.sort();
const bSorted = b.sort();
const areBothEqual = aSorted.every((value, index) => value === bSorted[index]);
return areBothEqual;
}

View file

@ -14,6 +14,8 @@ export const TEAM_NAME_PATH_PATTERN = '[a-z0-9\\-_]+';
// - Group Channel Name (40 length UID)
// - DM Name (userID__userID)
// - Username prefixed by a @
// - Username prefixed by a @, with colon and remote name e.g. @username:companyname
// - User ID
// - Email
export const IDENTIFIER_PATH_PATTERN = '[@a-zA-Z\\-_0-9][@a-zA-Z\\-_0-9.]*';
export const IDENTIFIER_PATH_PATTERN = '[@a-zA-Z\\-_0-9][@a-zA-Z\\-_0-9.:]*';

View file

@ -5,10 +5,18 @@
"about.enterpriseEditione1": "Enterprise edice",
"about.hash": "Kód sestavení:",
"about.hashee": "Kód sestavení EE:",
"about.teamEditionLearn": "Přidejte se ke komunitě Mattermost na ",
"about.teamEditionLearn": "Přidejte se ke komunitě Mattermost na",
"about.teamEditionSt": "Veškerá komunikace vašeho týmu na jednom místě, okamžitě prohledatelná a dostupná odkudkoliv.",
"about.teamEditiont0": "Team edice",
"about.teamEditiont1": "Enterprise edice",
"account.logout": "Odhlásit",
"account.logout_from": "Odhlásit se z {serverName}",
"account.settings": "Nastavení",
"account.your_profile": "Váš profil",
"alert.channel_deleted.description": "Kanál {displayName} byl archivován.",
"alert.channel_deleted.title": "Archivovaný kanál",
"alert.push_proxy_error.description": "Z důvodu nastavení tohoto serveru nelze příjmat notifikace do mobilní aplikace. Kontaktujte Vašeho správce serveru.",
"alert.push_proxy_error.title": "Z toho serveru nelze obdržet notifikace",
"api.channel.add_guest.added": "{addedUsername} přidán do kanálu jako host uživatelem {username}.",
"api.channel.add_member.added": "{addedUsername} přidán uživatelem {username}.",
"api.channel.guest_join_channel.post_and_forget": "{username} se přidal ke kanálu jako host.",
@ -30,7 +38,7 @@
"apps.error.lookup.error_preparing_request": "Chyba při přípravě vyhledávacího požadavku: {errorMessage}",
"apps.error.malformed_binding": "Toto napojení není správně naformátováno. Kontaktujte vývojáře aplikace.",
"apps.error.parser": "Chyba parsování: {error}",
"apps.error.parser.empty_value": "prázdné hodnoty nejsou povolené",
"apps.error.parser.empty_value": "Prázdné hodnoty nejsou povolené.",
"apps.error.parser.execute_non_leaf": "Musíte vybrat podpříkaz.",
"apps.error.parser.missing_binding": "Chybí spojení příkazu.",
"apps.error.parser.missing_field_value": "Pole není vyplněno.",
@ -55,8 +63,8 @@
"apps.error.responses.form.no_form": "Typ odpovědi je `form`, ale žádný formulár nebyl zahrnut do odpovědi.",
"apps.error.responses.navigate.no_url": "Typ odpovědi je `form`, ale žádný formulár nebyl zahrnut do odpovědi.",
"apps.error.responses.unexpected_error": "Obdržel jsem neočekávanou chybu.",
"apps.error.responses.unexpected_type": "Typ odpovědi aplikace nebyl očekáván. Typ odpovědi: {type}.",
"apps.error.responses.unknown_field_error": "Obdržel jsem chybu pro neznámé pole. Název pole: `{pole}`. Chyba: `{error}`.",
"apps.error.responses.unexpected_type": "Typ odpovědi aplikace nebyl očekáván. Typ odpovědi: {type}",
"apps.error.responses.unknown_field_error": "Obdržena chyba pro neznámé pole. Název pole: `{pole}`. Chyba: `{error}`.",
"apps.error.responses.unknown_type": "Typ odpovědi aplikace nebyl očekáván. Typ odpovědi: {type}.",
"apps.error.unknown": "Došlo k neznámé chybě.",
"apps.suggestion.dynamic.error": "Chyba dynamického výběru",
@ -68,7 +76,7 @@
"camera_type.photo.option": "Zachycení fotografie",
"camera_type.video.option": "Záznam videa",
"center_panel.archived.closeChannel": "Zavřít kanál",
"channel_header.directchannel.you": "{displayname} (já) ",
"channel_header.directchannel.you": "{displayname} (já)",
"channel_info.header": "Hlavička:",
"channel_loader.someone": "Někdo",
"channel_modal.descriptionHelp": "Popište, jak by tento kanál měl být použit.",
@ -199,7 +207,7 @@
"login.password": "Heslo",
"login.signIn": "Přihlašte se",
"login.username": "Uživatelské jméno",
"login_mfa.enterToken": "Pro dokončení přihlášení vlož, prosím, token z autentizační aplikace",
"login_mfa.enterToken": "Pro dokončení přihlášení vlož, prosím, token z autentizační aplikace.",
"login_mfa.token": "MFA token",
"login_mfa.tokenReq": "Zadejte prosím token MFA",
"mobile.about.appVersion": "Verze aplikace: {version} (Build {number})",
@ -216,11 +224,11 @@
"mobile.channel_list.unreads": "NEPŘEČTENÉ",
"mobile.commands.error_title": "Chyba při vykonávání příkazu",
"mobile.components.select_server_view.connect": "Připojit",
"mobile.components.select_server_view.connecting": "Připojuji...",
"mobile.components.select_server_view.connecting": "Připojuji",
"mobile.components.select_server_view.enterServerUrl": "Zadejte URL serveru",
"mobile.components.select_server_view.proceed": "Pokračovat",
"mobile.create_channel": "Vytvořit",
"mobile.create_post.read_only": "Tento kanál je pouze pro čtení",
"mobile.create_post.read_only": "Tento kanál je pouze pro čtení.",
"mobile.custom_list.no_results": "Žádné výsledky",
"mobile.custom_status.choose_emoji": "Výběr emoji",
"mobile.custom_status.clear_after": "Vymazat po",
@ -268,7 +276,7 @@
"mobile.oauth.failed_to_open_link": "Odkaz se nepodařilo otevřít. Zkuste to prosím znovu.",
"mobile.oauth.failed_to_open_link_no_browser": "Odkaz se nepodařilo otevřít. Ověřte prosím, zda je v aktuálním prostoru nainstalován prohlížeč.",
"mobile.oauth.something_wrong": "Něco se pokazilo",
"mobile.oauth.switch_to_browser": "Prosím přejděte do prohlížeče a dokončete přihlášení.",
"mobile.oauth.switch_to_browser": "Prosím přejděte do prohlížeče a dokončete přihlášení",
"mobile.oauth.try_again": "Zkuste to znovu",
"mobile.open_dm.error": "Nepodařilo se otevřít přímou zprávu s {displayName}. Prosím zkontrolujte vaše připojení a zkuste to znovu.",
"mobile.open_gm.error": "Nemohli jsme otevřít skupinovou zprávu s těmito uživateli. Prosím zkontrolujte vaše připojení a zkuste to znovu.",
@ -406,5 +414,5 @@
"user.settings.general.nickname": "Přezdívka",
"user.settings.general.position": "Pozice",
"user.settings.general.username": "Uživatelské jméno",
"user.settings.notifications.email_threads.description": "Upozornit mě na všechny odpovědi na vlákna, která sleduji."
"user.settings.notifications.email_threads.description": "Upozornit mě na všechny odpovědi na vlákna, která sleduji"
}

View file

@ -466,6 +466,8 @@
"mobile.calls_host_rec_stopped": "Du kannst die Aufzeichnung in der Unterhaltung dieses Anrufs finden, sobald die Verarbeitung abgeschlossen ist.",
"mobile.calls_host_rec_stopped_title": "Die Aufnahme wurde gestoppt. Verarbeitung...",
"mobile.calls_host_rec_title": "Sie zeichnest gerade auf",
"mobile.calls_host_transcription": "Informiere alle Teilnehmer darüber, dass dieses Treffen aufgezeichnet und protokolliert wird.",
"mobile.calls_host_transcription_title": "Aufnahme und Transkription haben begonnen",
"mobile.calls_incoming_dm": "<b>{name}</b> lädt dich zu einem Anruf ein",
"mobile.calls_incoming_gm": "<b>{name}</b> lädt dich zu einem Anruf mit <b>{num, plural, one {einem anderen} other {# anderen}}</b> ein",
"mobile.calls_join_call": "Am Anruf teilnehmen",
@ -492,6 +494,8 @@
"mobile.calls_participant_limit_title_GA": "Dieser Aufruf ist ausgelastet",
"mobile.calls_participant_rec": "Der Gastgeber hat mit der Aufzeichnung dieses Treffens begonnen. Wenn du an der Sitzung teilnimmst, erklärst du dich mit der Aufzeichnung einverstanden.",
"mobile.calls_participant_rec_title": "Aufnahme läuft",
"mobile.calls_participant_transcription": "Der Gastgeber hat mit der Aufzeichnung und Transkription dieses Treffens begonnen. Wenn du an der Sitzung teilnimmst, erklärst du dich damit einverstanden, dass die Sitzung aufgezeichnet und transkribiert wird.",
"mobile.calls_participant_transcription_title": "Aufnahme und Transkription sind im Gange",
"mobile.calls_phone": "Telefon",
"mobile.calls_quality_warning": "Die Gesprächsqualität kann sich aufgrund instabiler Netzbedingungen verschlechtern.",
"mobile.calls_raise_hand": "Hand heben",
@ -513,6 +517,10 @@
"mobile.calls_tablet": "Tablett",
"mobile.calls_thread": "Unterhaltung",
"mobile.calls_unmute": "Stumm aufheben",
"mobile.calls_user_left_channel_error_message": "Du hast den Kanal verlassen und wurdest vom Anruf getrennt.",
"mobile.calls_user_left_channel_error_title": "Du hast den Kanal verlassen",
"mobile.calls_user_removed_from_channel_error_message": "Du wurdest aus dem Kanal entfernt und die Verbindung wurde getrennt.",
"mobile.calls_user_removed_from_channel_error_title": "Du wurdest aus dem Kanal entfernt",
"mobile.calls_viewing_screen": "Du siehst {name}'s Bildschirm",
"mobile.calls_you": "(Du)",
"mobile.calls_you_2": "Du",
@ -778,7 +786,7 @@
"notification_settings.mentions": "Erwähnungen",
"notification_settings.mentions..keywordsDescription": "Andere Wörter, die eine Erwähnung auslösen",
"notification_settings.mentions.channelWide": "Kanalweite Erwähnungen",
"notification_settings.mentions.keywords": "Stichwörter",
"notification_settings.mentions.keywords": "Andere Schlüsselwörter eingeben",
"notification_settings.mentions.keywordsLabel": "Bei den Schlüsselwörtern wird nicht zwischen Groß- und Kleinschreibung unterschieden. Trenne die Schlüsselwörter mit Kommas.",
"notification_settings.mentions.keywords_mention": "Schlüsselwörter, die Erwähnungen auslösen",
"notification_settings.mentions.sensitiveName": "Dein schreibweisenabhängiger Vorname",

View file

@ -466,6 +466,8 @@
"mobile.calls_host_rec_stopped": "You can find the recording in this call's chat thread once it's finished processing.",
"mobile.calls_host_rec_stopped_title": "Recording has stopped. Processing...",
"mobile.calls_host_rec_title": "You are recording",
"mobile.calls_host_transcription": "Consider letting everyone know that this meeting is being recorded and transcribed.",
"mobile.calls_host_transcription_title": "Recording and transcription has started",
"mobile.calls_incoming_dm": "<b>{name}</b> is inviting you to a call",
"mobile.calls_incoming_gm": "<b>{name}</b> is inviting you to a call with <b>{num, plural, one {# other} other {# others}}</b>",
"mobile.calls_join_call": "Join call",
@ -492,6 +494,8 @@
"mobile.calls_participant_limit_title_GA": "This call is at capacity",
"mobile.calls_participant_rec": "The host has started recording this meeting. By staying in the meeting you give consent to being recorded.",
"mobile.calls_participant_rec_title": "Recording is in progress",
"mobile.calls_participant_transcription": "The host has started recording and transcription for this meeting. By staying in the meeting, you give consent to being recorded and transcribed.",
"mobile.calls_participant_transcription_title": "Recording and transcription is in progress",
"mobile.calls_phone": "Phone",
"mobile.calls_quality_warning": "Call quality may be degraded due to unstable network conditions.",
"mobile.calls_raise_hand": "Raise hand",
@ -513,6 +517,10 @@
"mobile.calls_tablet": "Tablet",
"mobile.calls_thread": "Thread",
"mobile.calls_unmute": "Unmute",
"mobile.calls_user_left_channel_error_message": "You have left the channel, and have been disconnected from the call.",
"mobile.calls_user_left_channel_error_title": "You left the channel",
"mobile.calls_user_removed_from_channel_error_message": "You have been removed from the channel, and have been disconnected from the call.",
"mobile.calls_user_removed_from_channel_error_title": "You were removed from channel",
"mobile.calls_viewing_screen": "You are viewing {name}'s screen",
"mobile.calls_you": "(you)",
"mobile.calls_you_2": "You",
@ -774,7 +782,7 @@
"notification_settings.mentions_replies": "Mentions and Replies",
"notification_settings.mentions..keywordsDescription": "Other words that trigger a mention",
"notification_settings.mentions.channelWide": "Channel-wide mentions",
"notification_settings.mentions.keywords": "Keywords",
"notification_settings.mentions.keywords": "Enter other keywords",
"notification_settings.mentions.keywords_mention": "Keywords that trigger mentions",
"notification_settings.mentions.keywordsLabel": "Keywords are not case-sensitive. Separate keywords with commas.",
"notification_settings.mentions.sensitiveName": "Your case sensitive first name",

View file

@ -466,6 +466,8 @@
"mobile.calls_host_rec_stopped": "You can find the recording in this call's chat thread once it's finished processing.",
"mobile.calls_host_rec_stopped_title": "Recording has stopped. Processing...",
"mobile.calls_host_rec_title": "You are recording",
"mobile.calls_host_transcription": "Consider informing everyone that this meeting is being recorded and transcribed.",
"mobile.calls_host_transcription_title": "Recording and transcription has started",
"mobile.calls_incoming_dm": "<b>{name}</b> is inviting you to a call",
"mobile.calls_incoming_gm": "<b>{name}</b> is inviting you to a call with <b>{num, plural, one {# other} other {# others}}</b>",
"mobile.calls_join_call": "Join call",
@ -492,6 +494,8 @@
"mobile.calls_participant_limit_title_GA": "This call is at capacity",
"mobile.calls_participant_rec": "The host has started recording this meeting. By staying, you are consenting to being recorded for the duration of the meeting.",
"mobile.calls_participant_rec_title": "Recording is in progress",
"mobile.calls_participant_transcription": "The host has started recording and transcription for this meeting. By staying in the meeting, you give consent to being recorded and transcribed.",
"mobile.calls_participant_transcription_title": "Recording and transcription is in progress",
"mobile.calls_phone": "Phone",
"mobile.calls_quality_warning": "Call quality may be degraded due to unstable network conditions.",
"mobile.calls_raise_hand": "Raise hand",
@ -513,6 +517,10 @@
"mobile.calls_tablet": "Tablet",
"mobile.calls_thread": "Thread",
"mobile.calls_unmute": "Unmute",
"mobile.calls_user_left_channel_error_message": "You have left the channel and have been disconnected from the call.",
"mobile.calls_user_left_channel_error_title": "You left the channel",
"mobile.calls_user_removed_from_channel_error_message": "You have been removed from the channel and have been disconnected from the call.",
"mobile.calls_user_removed_from_channel_error_title": "You were removed from channel",
"mobile.calls_viewing_screen": "You are viewing {name}'s screen",
"mobile.calls_you": "(you)",
"mobile.calls_you_2": "You",
@ -778,7 +786,7 @@
"notification_settings.mentions": "Mentions",
"notification_settings.mentions..keywordsDescription": "Other words that trigger a mention",
"notification_settings.mentions.channelWide": "Channel-wide mentions",
"notification_settings.mentions.keywords": "Keywords",
"notification_settings.mentions.keywords": "Enter other keywords",
"notification_settings.mentions.keywordsLabel": "Keywords are not case-sensitive. Separate keywords with commas.",
"notification_settings.mentions.keywords_mention": "Keywords that trigger mentions",
"notification_settings.mentions.sensitiveName": "Your case sensitive first name",

View file

@ -258,7 +258,7 @@
"custom_status.expiry_time.tomorrow": "明日",
"custom_status.failure_message": "ステータスを更新できませんでした。再度試してみてください",
"custom_status.set_status": "カスタムステータスを設定する",
"custom_status.suggestions.in_a_meeting": "ミーティング中",
"custom_status.suggestions.in_a_meeting": "会議中",
"custom_status.suggestions.on_a_vacation": "休暇中",
"custom_status.suggestions.out_for_lunch": "ランチ中",
"custom_status.suggestions.out_sick": "病欠",
@ -466,6 +466,8 @@
"mobile.calls_host_rec_stopped": "レコーディングは、処理が完了した後、この通話のチャットスレッドから確認できるようになります。",
"mobile.calls_host_rec_stopped_title": "レコーディングが停止しました。処理しています...",
"mobile.calls_host_rec_title": "レコーディングしています",
"mobile.calls_host_transcription": "この会議のレコーディングと書き起こしが実施されていることを全員に知らせることを検討してください。",
"mobile.calls_host_transcription_title": "レコーディングと文字起こしが開始されました",
"mobile.calls_incoming_dm": "<b>{name}</b>があなたを通話に招待しています",
"mobile.calls_incoming_gm": "<b>{name}</b>があなたを<b>{num, plural, one {# 人} other {# 人}}</b>との通話に招待しています",
"mobile.calls_join_call": "通話に参加する",
@ -490,8 +492,10 @@
"mobile.calls_okay": "OK",
"mobile.calls_open_channel": "チャンネルを開く",
"mobile.calls_participant_limit_title_GA": "この通話は定員に達しています",
"mobile.calls_participant_rec": "ホストがこの会議のレコーディングを開始しました。ミーティングに参加し続けることで、レコーディングに同意したことになります。",
"mobile.calls_participant_rec": "ホストがこの会議のレコーディングを開始しました。会議に参加し続けることで、レコーディングに同意したことになります。",
"mobile.calls_participant_rec_title": "レコーディング中",
"mobile.calls_participant_transcription": "ホストがこの会議のレコーディングと文字起こしを開始しました。会議に参加し続けることで、レコーディングと文字起こしに同意したことになります。",
"mobile.calls_participant_transcription_title": "レコーディングと文字起こしが実施されています",
"mobile.calls_phone": "Phone",
"mobile.calls_quality_warning": "ネットワークの状態が不安定なため、通話品質が低下する場合があります。",
"mobile.calls_raise_hand": "手を挙げる",
@ -513,6 +517,10 @@
"mobile.calls_tablet": "タブレット",
"mobile.calls_thread": "スレッド",
"mobile.calls_unmute": "ミュート解除",
"mobile.calls_user_left_channel_error_message": "あなたはチャンネルを脱退し、通話から切断されました。",
"mobile.calls_user_left_channel_error_title": "チャンネルを脱退しました",
"mobile.calls_user_removed_from_channel_error_message": "あなたはチャンネルから削除され、通話から切断されました。",
"mobile.calls_user_removed_from_channel_error_title": "あなたはチャンネルから削除されました",
"mobile.calls_viewing_screen": "{name} の画面を表示しています",
"mobile.calls_you": "(あなた)",
"mobile.calls_you_2": "あなた",
@ -778,7 +786,7 @@
"notification_settings.mentions": "メンション",
"notification_settings.mentions..keywordsDescription": "メンションとなる別の単語",
"notification_settings.mentions.channelWide": "チャンネル全体へのメンション",
"notification_settings.mentions.keywords": "キーワード",
"notification_settings.mentions.keywords": "他のキーワードを入力",
"notification_settings.mentions.keywordsLabel": "キーワードの大文字と小文字は区別されません。キーワードはカンマで区切ってください。",
"notification_settings.mentions.keywords_mention": "メンションのトリガーとなるキーワード",
"notification_settings.mentions.sensitiveName": "大文字小文字を区別したあなたの名前",

View file

@ -119,6 +119,8 @@
"channel_info.close_gm": "그룹 메시지 닫기",
"channel_info.close_gm_channel": "이 그룹 메시지를 닫으시겠어요? 홈 화면에서 채널에 제거되지만 언제든지 다시 열어볼 수 있습니다.",
"channel_info.convert_failed": "{displayName} 채널을 비공개 채널로 변환하지 못했습니다.",
"channel_info.convert_gm_to_channel.team_selector_list.title": "팀 선택",
"channel_info.convert_gm_to_channel.warning.body.yourself": "당신",
"channel_info.convert_private": "비공개 채널로 변환",
"channel_info.convert_private_description": "{displayName} 채널을 비공개 채널로 변환할 때, 기록과 권한은 유지됩니다. 링크를 통해 공개적으로 공유된 파일들은 계속 접근할 수 있습니다. 비공개 채널의 접근은 초대를 통해서만 가능합니다.\n\n이 변경은 영구적이며 되돌릴 수 없습니다.\n\n{displayName} 채널을 비공개 채널로 변환하시겠어요?",
"channel_info.convert_private_success": "{displayName} 채널은 이제 비공개 채널입니다.",

View file

@ -466,6 +466,8 @@
"mobile.calls_host_rec_stopped": "Je kan de opname vinden in de chatdraadje van dit gesprek zodra het verwerkt is.",
"mobile.calls_host_rec_stopped_title": "De opname is gestopt. Verwerking gestart...",
"mobile.calls_host_rec_title": "Je bent aan het opnemen",
"mobile.calls_host_transcription": "Overweeg om iedereen te laten weten dat deze vergadering wordt opgenomen en er een transcriptie gemaakt wordt.",
"mobile.calls_host_transcription_title": "Opname en transcriptie is begonnen",
"mobile.calls_incoming_dm": "<b>{name}</b> nodigt je uit voor een gesprek",
"mobile.calls_incoming_gm": "<b>{name}</b> nodigt je uit voor een gesprek met {num, plural, one {# other} other {# others}} <b></b>",
"mobile.calls_join_call": "Deelnemen aan oproep",
@ -492,6 +494,8 @@
"mobile.calls_participant_limit_title_GA": "Deze oproep is op volle capaciteit",
"mobile.calls_participant_rec": "De gastheer is begonnen met het opnemen van deze vergadering. Door in de vergadering te blijven geef je toestemming voor opname.",
"mobile.calls_participant_rec_title": "Opname bezig",
"mobile.calls_participant_transcription": "De host is begonnen met het opnemen en het maken van een transcriptie van deze vergadering. Door aan de vergadering deel te nemen, geef je toestemming voor opname en transcriptie.",
"mobile.calls_participant_transcription_title": "Opname en transcriptie is bezig",
"mobile.calls_phone": "Telefoon",
"mobile.calls_quality_warning": "De gesprekskwaliteit kan slechter zijn door onstabiele netwerkomstandigheden.",
"mobile.calls_raise_hand": "Handje opsteken",
@ -513,6 +517,10 @@
"mobile.calls_tablet": "Tablet",
"mobile.calls_thread": "Draadje",
"mobile.calls_unmute": "Dempen uitschakelen",
"mobile.calls_user_left_channel_error_message": "Je hebt het kanaal verlaten en de verbinding met het gesprek is verbroken.",
"mobile.calls_user_left_channel_error_title": "Je hebt het kanaal verlaten",
"mobile.calls_user_removed_from_channel_error_message": "Je bent uit het kanaal verwijderd en de verbinding met het gesprek is verbroken.",
"mobile.calls_user_removed_from_channel_error_title": "Je bent uit het kanaal verwijderd",
"mobile.calls_viewing_screen": "Je bekijkt het scherm van {name}",
"mobile.calls_you": "(jij)",
"mobile.calls_you_2": "Jij",
@ -778,7 +786,7 @@
"notification_settings.mentions": "@Vermeldingen",
"notification_settings.mentions..keywordsDescription": "Andere woorden die een vermelding activeren",
"notification_settings.mentions.channelWide": "Kanaalbrede vermeldingen",
"notification_settings.mentions.keywords": "Trefwoorden",
"notification_settings.mentions.keywords": "Geef andere trefwoorden op",
"notification_settings.mentions.keywordsLabel": "Trefwoorden zijn niet hoofdlettergevoelig. Scheid trefwoorden met komma's.",
"notification_settings.mentions.keywords_mention": "Trefwoorden die aanleiding geven tot vermeldingen",
"notification_settings.mentions.sensitiveName": "Jouw hoofdlettergevoelige voornaam",

View file

@ -44,7 +44,7 @@
"apps.error.form.refresh": "Wystąpił błąd podczas wybierania pól. Skontaktuj się z developerem. Szczegóły: {details}",
"apps.error.form.refresh_no_refresh": "Odświeżenie w polu nieodświeżalnym.",
"apps.error.form.submit.pretext": "Wystąpił błąd podczas wysyłania. Skontaktuj się z deweloperem. Szczegóły: {details}",
"apps.error.lookup.error_preparing_request": "Błąd podczas przeszukiwania: {errorMessage}",
"apps.error.lookup.error_preparing_request": "Błąd podczas tworzenia zapytania wyszukiwania: {errorMessage}",
"apps.error.malformed_binding": "To wiązanie nie jest prawidłowo utworzone. Skontaktuj się z twórcą aplikacji.",
"apps.error.parser": "Błąd składni: {error}",
"apps.error.parser.empty_value": "Puste wartości nie są dozwolone.",
@ -466,6 +466,8 @@
"mobile.calls_host_rec_stopped": "Nagranie będzie można znaleźć w wątku czatu tej rozmowy po zakończeniu jej przetwarzania.",
"mobile.calls_host_rec_stopped_title": "Nagrywanie zostało przerwane. Przetwarzanie...",
"mobile.calls_host_rec_title": "Nagrywasz",
"mobile.calls_host_transcription": "Rozważ poinformowanie wszystkich, że to spotkanie jest nagrywane i transkrybowane.",
"mobile.calls_host_transcription_title": "Rozpoczęło się nagrywanie i transkrypcja",
"mobile.calls_incoming_dm": "<b>{name}</b> zaprasza do rozmowy",
"mobile.calls_incoming_gm": "<b>{name}</b> zaprasza do rozmowy z {num, plural, one {# innym} other {# innymi}} <b></b>",
"mobile.calls_join_call": "Dołącz do rozmowy",
@ -492,6 +494,8 @@
"mobile.calls_participant_limit_title_GA": "To połączenie jest na wyczerpaniu",
"mobile.calls_participant_rec": "Prowadzący rozpoczął nagrywanie tego spotkania. Pozostając na spotkaniu wyrażasz zgodę na nagrywanie.",
"mobile.calls_participant_rec_title": "Trwa nagrywanie",
"mobile.calls_participant_transcription": "Gospodarz rozpoczął nagrywanie i transkrypcję tego spotkania. Pozostając w spotkaniu, wyrażasz zgodę na nagrywanie i transkrypcję.",
"mobile.calls_participant_transcription_title": "Nagrywanie i transkrypcja są w toku",
"mobile.calls_phone": "Telefon",
"mobile.calls_quality_warning": "Jakość połączenia może ulec pogorszeniu z powodu niestabilnych warunków sieciowych.",
"mobile.calls_raise_hand": "Zgłoś się",
@ -513,6 +517,10 @@
"mobile.calls_tablet": "Tablet",
"mobile.calls_thread": "Wątek",
"mobile.calls_unmute": "Wyłącz wyciszenie",
"mobile.calls_user_left_channel_error_message": "Opuściłeś kanał i zostałeś odłączony od połączenia.",
"mobile.calls_user_left_channel_error_title": "Opuściłeś kanał",
"mobile.calls_user_removed_from_channel_error_message": "Zostałeś usunięty z kanału i rozłączony z połączeniem.",
"mobile.calls_user_removed_from_channel_error_title": "Zostałeś usunięty z kanału",
"mobile.calls_viewing_screen": "Przeglądasz ekran {name}",
"mobile.calls_you": "(ty)",
"mobile.calls_you_2": "Ty",
@ -778,7 +786,7 @@
"notification_settings.mentions": "Wzmianki",
"notification_settings.mentions..keywordsDescription": "Inne słowa, które uruchamiają wzmianki",
"notification_settings.mentions.channelWide": "Wzmianki na kanale",
"notification_settings.mentions.keywords": "Słowa kluczowe",
"notification_settings.mentions.keywords": "Wprowadź inne słowa kluczowe",
"notification_settings.mentions.keywordsLabel": "W słowach kluczowych nie jest rozróżniana wielkość liter. Słowa kluczowe należy oddzielać przecinkami.",
"notification_settings.mentions.keywords_mention": "Słowa kluczowe, które wywołują wzmianki",
"notification_settings.mentions.sensitiveName": "Twoje imię z rozróżnianiem wielkich i małych liter",

View file

@ -241,7 +241,7 @@
"connection_banner.not_connected": "Нет соединения с интернетом",
"connection_banner.not_reachable": "Сервер недоступен",
"create_direct_message.title": "Создать личное сообщение",
"create_post.deactivated": "Вы просматриваете архивированный канал как деактивированный пользователь.",
"create_post.deactivated": "Вы просматриваете архивированный канал с деактивированным пользователем.",
"create_post.thread_reply": "Ответить в этом обсуждении...",
"create_post.write": "Написать в {channelDisplayName}",
"custom_status.expiry.at": "в",
@ -466,6 +466,8 @@
"mobile.calls_host_rec_stopped": "Вы можете найти запись в чате этого звонка после завершения его обработки.",
"mobile.calls_host_rec_stopped_title": "Запись остановлена. Обработка...",
"mobile.calls_host_rec_title": "Вы записываете",
"mobile.calls_host_transcription": "Подумайте о том, чтобы сообщить всем, что это собрание будет записываться и расшифровываться (транскрибироваться).",
"mobile.calls_host_transcription_title": "Запись и расшифровка уже начались",
"mobile.calls_incoming_dm": "<b>{name}</b> приглашает вас на звонок",
"mobile.calls_incoming_gm": "<b>{name}</b> приглашает Вас на разговор с <b>{num, plural, one {# одним участником} few {# несколькими участниками} other {# несколькими участниками}}</b>",
"mobile.calls_join_call": "Присоединиться к звонку",
@ -492,6 +494,8 @@
"mobile.calls_participant_limit_title_GA": "Этот звонок находится на пределе возможностей",
"mobile.calls_participant_rec": "Ведущий начал запись этой встречи. Оставаясь на встрече, вы даете согласие на запись.",
"mobile.calls_participant_rec_title": "Идет запись",
"mobile.calls_participant_transcription": "Ведущий начал запись и расшифровку этой встречи. Оставаясь на встрече, вы даете согласие на запись и расшифровку.",
"mobile.calls_participant_transcription_title": "Запись и расшифровка в процессе",
"mobile.calls_phone": "Телефон",
"mobile.calls_quality_warning": "Качество звонков может ухудшиться из-за нестабильной работы сети.",
"mobile.calls_raise_hand": "Поднять руку",
@ -513,6 +517,10 @@
"mobile.calls_tablet": "Планшет",
"mobile.calls_thread": "Обсуждение",
"mobile.calls_unmute": "Включить звук",
"mobile.calls_user_left_channel_error_message": "Вы покинули канал и были отключены от звонка.",
"mobile.calls_user_left_channel_error_title": "Вы покинули канал",
"mobile.calls_user_removed_from_channel_error_message": "Вас удалили с канала и отключили от звонка.",
"mobile.calls_user_removed_from_channel_error_title": "Вас удалили с канала",
"mobile.calls_viewing_screen": "Вы просматриваете экран {name}",
"mobile.calls_you": "(это вы)",
"mobile.calls_you_2": "Вы",
@ -778,7 +786,7 @@
"notification_settings.mentions": "Упоминания",
"notification_settings.mentions..keywordsDescription": "Специальные слова, генерирующие упоминания",
"notification_settings.mentions.channelWide": "Общесистемные упоминания",
"notification_settings.mentions.keywords": "Ключевые слова",
"notification_settings.mentions.keywords": "Введите другие ключевые слова",
"notification_settings.mentions.keywordsLabel": "Ключевые слова не чувствительны к регистру. Разделяйте ключевые слова запятыми.",
"notification_settings.mentions.keywords_mention": "Ключевые слова, которые вызывают упоминания",
"notification_settings.mentions.sensitiveName": "Ваше регистрозависимое имя",

View file

@ -2,7 +2,7 @@
"about.date": "Ngày xây dựng:",
"about.enterpriseEditionLearn": "Tìm hiểu thêm về phiên bản Doanh Nghiệp ở ",
"about.enterpriseEditionSt": "Giao tiếp hiện đại từ phía sau tường lửa của bạn.",
"about.enterpriseEditione1": "Phiên bản Doanh nghiệp",
"about.enterpriseEditione1": "Phiên bản doanh nghiệp",
"about.hash": "Build Hash:",
"about.hashee": "EE Build Hash:",
"about.teamEditionLearn": "Tham gia cộng đồng Mattermost",
@ -112,6 +112,8 @@
"channel_info.archive_description.cannot_view_archived": "Thao tác này sẽ lưu trữ kênh từ nhóm và xóa kênh đó khỏi giao diện người dùng.\n\nCác kênh đã lưu trữ có thể được hủy lưu trữ nếu cần lại. Bạn có chắc chắn muốn lưu trữ {term} {name} không?",
"channel_info.archive_failed": "Đã xảy ra lỗi khi lưu trữ kênh {displayName}",
"channel_info.archive_title": "Lưu trữ {term}",
"channel_info.channel_auto_follow_threads": "Theo dõi tất cả các chủ đề trong kênh này",
"channel_info.channel_auto_follow_threads_failed": "Đã xảy ra lỗi khi cố gắng tự động theo dõi tất cả các chủ đề trong kênh {displayName}",
"channel_info.channel_files": "Tệp",
"channel_info.close": "Đóng",
"channel_info.close_dm": "Đóng tin nhắn trực tiếp",
@ -119,6 +121,14 @@
"channel_info.close_gm": "Đóng tin nhắn nhóm",
"channel_info.close_gm_channel": "Bạn có chắc chắn muốn đóng tin nhắn nhóm này không? Thao tác này sẽ xóa ứng dụng khỏi màn hình chính nhưng bạn luôn có thể mở lại ứng dụng.",
"channel_info.convert_failed": "Chúng tôi không thể chuyển đổi {displayName} thành một kênh riêng tư.",
"channel_info.convert_gm_to_channel": "Chuyển đổi sang Kênh riêng tư",
"channel_info.convert_gm_to_channel.button_text": "Chuyển đổi sang kênh riêng tư",
"channel_info.convert_gm_to_channel.button_text_converting": "Đang chuyển đổi...",
"channel_info.convert_gm_to_channel.loading.footer": "Đang lấy chi tiết...",
"channel_info.convert_gm_to_channel.team_selector_list.title": "Chọn đội",
"channel_info.convert_gm_to_channel.warning.body.yourself": "chính bạn",
"channel_info.convert_gm_to_channel.warning.bodyXXXX": "Bạn sắp chuyển đổi tin nhắn nhóm với {memberNames} thành một kênh. Hành động này không thể hoàn tác.",
"channel_info.convert_gm_to_channel.warning.header": "Lịch sử của cuộc hội thoại sẽ được xuất hiện đối với mọi thành viên của kênh",
"channel_info.convert_private": "Chuyển sang kênh riêng tư",
"channel_info.convert_private_description": "Khi bạn chuyển đổi {displayName} thành kênh riêng tư, lịch sử và tư cách thành viên sẽ được giữ nguyên. Các tệp được chia sẻ công khai vẫn có thể truy cập được đối với bất kỳ ai có liên kết. Tư cách thành viên trong một kênh riêng tư chỉ bằng lời mời.\n\nThay đổi là vĩnh viễn và không thể hoàn tác.\n\nBạn có chắc chắn muốn chuyển đổi {displayName} thành một kênh riêng tư không?",
"channel_info.convert_private_success": "{displayName} hiện là kênh riêng tư.",

View file

@ -1,6 +1,6 @@
{
"about.date": "编译日期:",
"about.enterpriseEditionLearn": "了解更多关于企业版 ",
"about.enterpriseEditionLearn": "深入了解企业版 ",
"about.enterpriseEditionSt": "位于防火墙后的现代通讯方式。",
"about.enterpriseEditione1": "企业版",
"about.hash": "编译哈希:",
@ -74,7 +74,7 @@
"apps.error.responses.unexpected_error": "收到未预料的错误。",
"apps.error.responses.unexpected_type": "应用响应类型不是预期的。响应类型:{type}",
"apps.error.responses.unknown_field_error": "收到未知字段的错误。字段名称:`{field}`。错误:`{error}`。",
"apps.error.responses.unknown_type": "不支援应用响应的类型。响应类型:{type}。",
"apps.error.responses.unknown_type": "不支持此应用的响应类型。响应类型:{type}。",
"apps.error.unknown": "发生未知错误。",
"apps.suggestion.dynamic.error": "动态选择错误",
"apps.suggestion.errors.parser_error": "解析错误",
@ -466,6 +466,8 @@
"mobile.calls_host_rec_stopped": "当处理完成时您可以在此通话谈话主题中找到此次录音。",
"mobile.calls_host_rec_stopped_title": "录制已经停止。处理中…",
"mobile.calls_host_rec_title": "您正在录制中",
"mobile.calls_host_transcription": "考虑让每个人都知道这次会议正在被记录。",
"mobile.calls_host_transcription_title": "记录已开始",
"mobile.calls_incoming_dm": "<b>{name}</b>邀请您加入通话",
"mobile.calls_incoming_gm": "<b>{name}</b> 现邀请您与其他<b> {num, plural, one {# 位} other {# 位}} </b>进行通话",
"mobile.calls_join_call": "加入通话",
@ -492,6 +494,8 @@
"mobile.calls_participant_limit_title_GA": "此通话达到容量上限",
"mobile.calls_participant_rec": "主持人已经开始录音本次会议。参加本次会议表示你默许声音被录制。",
"mobile.calls_participant_rec_title": "录制正在进行中",
"mobile.calls_participant_transcription": "主持人已开始会议记录。如果您仍在会议中,表明您已知晓正在被记录。",
"mobile.calls_participant_transcription_title": "正在记录",
"mobile.calls_phone": "电话",
"mobile.calls_quality_warning": "由于网络条件不稳定,通话质量可能会下降。",
"mobile.calls_raise_hand": "举手",
@ -513,6 +517,10 @@
"mobile.calls_tablet": "平板",
"mobile.calls_thread": "主题",
"mobile.calls_unmute": "解除静音",
"mobile.calls_user_left_channel_error_message": "您已离开频道并从通话中断开。",
"mobile.calls_user_left_channel_error_title": "您已离开频道",
"mobile.calls_user_removed_from_channel_error_message": "您已被从频道中移除并从通话中断开。",
"mobile.calls_user_removed_from_channel_error_title": "您已被从频道中移除",
"mobile.calls_viewing_screen": "您正在观看{name}的屏幕",
"mobile.calls_you": "(您)",
"mobile.calls_you_2": "您",
@ -778,7 +786,7 @@
"notification_settings.mentions": "提及",
"notification_settings.mentions..keywordsDescription": "其他触发提及的词语",
"notification_settings.mentions.channelWide": "频道范围的提及",
"notification_settings.mentions.keywords": "关键词",
"notification_settings.mentions.keywords": "输入其他关键词",
"notification_settings.mentions.keywordsLabel": "关键词大小写不区分。用逗号分隔关键词。",
"notification_settings.mentions.keywords_mention": "触发提及的关键词",
"notification_settings.mentions.sensitiveName": "您的大小写区分名",

View file

@ -182,15 +182,15 @@
"combined_system_message.added_to_team.two": "{firstUser}與{secondUser}已由{actor}**加入至此團隊**。",
"combined_system_message.joined_channel.many_expanded": "{users}與{lastUser}已**加入至此頻道**。",
"combined_system_message.joined_channel.one": "{firstUser}已**加入至此頻道**。",
"combined_system_message.joined_channel.one_you": "你**已加入頻道**",
"combined_system_message.joined_channel.one_you": "你**已加入頻道**",
"combined_system_message.joined_channel.two": "{firstUser}與{secondUser}已**加入至此頻道**。",
"combined_system_message.joined_team.many_expanded": "{users}與{lastUser}已**加入至此團隊**。",
"combined_system_message.joined_team.one": "{firstUser}已**加入至此團隊**。",
"combined_system_message.joined_team.one_you": "你**已加入團隊**",
"combined_system_message.joined_team.one_you": "你**已加入團隊**",
"combined_system_message.joined_team.two": "{firstUser}與{secondUser}已**加入至此團隊**。",
"combined_system_message.left_channel.many_expanded": "{users}與{lastUser}已**離開此頻道**。",
"combined_system_message.left_channel.one": "{firstUser}已**離開此頻道**。",
"combined_system_message.left_channel.one_you": "你**已離開頻道**",
"combined_system_message.left_channel.one_you": "你**已離開頻道**",
"combined_system_message.left_channel.two": "{firstUser}與{secondUser}已**離開此頻道**。",
"combined_system_message.left_team.many_expanded": "{users}與{lastUser}已**離開此團隊**。",
"combined_system_message.left_team.one": "{firstUser}已**離開此團隊**。",
@ -361,9 +361,9 @@
"last_users_message.added_to_channel.type": "已由{actor}**加入至此頻道**。",
"last_users_message.added_to_team.type": "已由{actor}**加入至此團隊**。",
"last_users_message.first": "{firstUser}與",
"last_users_message.joined_channel.type": "**加入此頻道**",
"last_users_message.joined_team.type": "**加入此團隊**",
"last_users_message.left_channel.type": "**離開此頻道**",
"last_users_message.joined_channel.type": "**加入此頻道**",
"last_users_message.joined_team.type": "**加入此團隊**",
"last_users_message.left_channel.type": "**離開此頻道**",
"last_users_message.left_team.type": "**離開了團隊**。",
"last_users_message.others": "其他 {numOthers} 位",
"last_users_message.removed_from_channel.type": "已**被移出此頻道**。",
@ -507,7 +507,7 @@
"mobile.edit_post.title": "編輯訊息",
"mobile.error_handler.button": "重新啟動",
"mobile.error_handler.description": "\n點擊重新啟動以再次開啟 app。重新啟動後可以經由設定選單來回報問題。\n",
"mobile.error_handler.title": "發生未預期的錯誤",
"mobile.error_handler.title": "發生未預期的錯誤",
"mobile.file_upload.disabled2": "",
"mobile.file_upload.max_warning": "上傳最多 5 個檔案。",
"mobile.files_paste.error_description": "貼上檔案時發生錯誤。請重新嘗試。",
@ -554,7 +554,7 @@
"mobile.managed.jailbreak": "{vendor} 不信任越獄後的裝置,請關閉應用程式。",
"mobile.managed.jailbreak_no_debug_info": "",
"mobile.managed.jailbreak_no_reason": "",
"mobile.managed.not_secured.android": "這裝置必須要設定螢幕鎖以使用 Mattermost",
"mobile.managed.not_secured.android": "這裝置必須要設定螢幕鎖以使用 Mattermost",
"mobile.managed.not_secured.ios": "這裝置必須要設定密碼以使用 Mattermost前往 設定 > Face ID 與密碼",
"mobile.managed.not_secured.ios.touchId": "這裝置必須要設定密碼以使用 Mattermost。前往 設定 > Touch ID 與密碼",
"mobile.managed.secured_by": "受到 {vendor} 保護",
@ -584,7 +584,7 @@
"mobile.onboarding.sign_in": "",
"mobile.onboarding.sign_in_to_get_started": "",
"mobile.open_dm.error": "",
"mobile.open_gm.error": "無法開啟與這些使用者的直接傳訊。請檢查連線並再試一次。 ",
"mobile.open_gm.error": "無法開啟與這些使用者的直接傳訊。請檢查連線並再試一次。",
"mobile.participants.header": "",
"mobile.permission_denied_dismiss": "不允許",
"mobile.permission_denied_retry": "設定",
@ -661,8 +661,8 @@
"mobile.storage_permission_denied_description": "",
"mobile.storage_permission_denied_title": "{applicationName}想要存取您的檔案",
"mobile.suggestion.members": "成員",
"mobile.system_message.channel_archived_message": "{username} 已封存頻道",
"mobile.system_message.channel_unarchived_message": "{username} 已解除頻道封存",
"mobile.system_message.channel_archived_message": "{username} 已封存頻道",
"mobile.system_message.channel_unarchived_message": "{username} 已解除頻道封存",
"mobile.system_message.update_channel_displayname_message_and_forget.updated_from": "{username} 將頻道顯示名稱從 {oldDisplayName} 改成 {newDisplayName}",
"mobile.system_message.update_channel_header_message_and_forget.removed": "{username} 已移除頻道標題(原為:{oldHeader})",
"mobile.system_message.update_channel_header_message_and_forget.updated_from": "{username} 已更新頻道標題:從 {oldHeader} 改為 {newHeader}",
@ -734,8 +734,8 @@
"onboarding.realtime_collaboration_description": "",
"onboarding.welcome": "",
"onboaring.welcome_description": "",
"password_send.description": "輸入註冊的電子郵件地址以重設密碼",
"password_send.error": "請輸入一個有效的電子郵件地址",
"password_send.description": "輸入註冊的電子郵件地址以重設密碼",
"password_send.error": "請輸入一個有效的電子郵件地址",
"password_send.generic_error": "",
"password_send.link": "如果帳號存在,將會寄送密碼重置郵件至:",
"password_send.link.title": "",

View file

@ -1,3 +1,9 @@
GIT
remote: https://github.com/jonathanneilritchie/fastlane-plugin-find_replace_string
revision: 1b71026318ad40abc710d601be48d67bcb353ddf
specs:
fastlane-plugin-find_replace_string (0.1.0)
GEM
remote: https://rubygems.org/
specs:
@ -108,7 +114,6 @@ GEM
fastlane-plugin-android_change_package_identifier (0.1.0)
fastlane-plugin-android_change_string_app_name (0.1.1)
nokogiri
fastlane-plugin-find_replace_string (0.1.0)
fastlane-plugin-versioning_android (0.1.1)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.53.0)
@ -216,6 +221,7 @@ GEM
PLATFORMS
arm64-darwin-22
arm64-darwin-23
x86_64-darwin-20
x86_64-darwin-23
DEPENDENCIES
@ -223,7 +229,7 @@ DEPENDENCIES
fastlane
fastlane-plugin-android_change_package_identifier
fastlane-plugin-android_change_string_app_name
fastlane-plugin-find_replace_string
fastlane-plugin-find_replace_string!
fastlane-plugin-versioning_android
nokogiri

View file

@ -4,5 +4,5 @@
gem 'fastlane-plugin-android_change_package_identifier'
gem 'fastlane-plugin-android_change_string_app_name'
gem 'fastlane-plugin-find_replace_string'
gem 'fastlane-plugin-find_replace_string', git: "https://github.com/jonathanneilritchie/fastlane-plugin-find_replace_string"
gem 'fastlane-plugin-versioning_android'

View file

@ -1931,7 +1931,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 499;
CURRENT_PROJECT_VERSION = 503;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
HEADER_SEARCH_PATHS = (
@ -1975,7 +1975,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 499;
CURRENT_PROJECT_VERSION = 503;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
HEADER_SEARCH_PATHS = (
@ -2118,7 +2118,7 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 499;
CURRENT_PROJECT_VERSION = 503;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;
@ -2167,7 +2167,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 499;
CURRENT_PROJECT_VERSION = 503;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;

View file

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.12.0</string>
<string>2.13.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -37,7 +37,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>499</string>
<string>503</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View file

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2.12.0</string>
<string>2.13.0</string>
<key>CFBundleVersion</key>
<string>499</string>
<string>503</string>
<key>UIAppFonts</key>
<array>
<string>OpenSans-Bold.ttf</string>

View file

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2.12.0</string>
<string>2.13.0</string>
<key>CFBundleVersion</key>
<string>499</string>
<string>503</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

16
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "mattermost-mobile",
"version": "2.11.0",
"version": "2.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mattermost-mobile",
"version": "2.11.0",
"version": "2.12.0",
"hasInstallScript": true,
"license": "Apache 2.0",
"dependencies": {
@ -18,8 +18,8 @@
"@formatjs/intl-pluralrules": "5.2.10",
"@formatjs/intl-relativetimeformat": "11.2.10",
"@gorhom/bottom-sheet": "4.5.1",
"@mattermost/calls": "github:mattermost/calls-common#v0.21.0",
"@mattermost/compass-icons": "0.1.39",
"@mattermost/calls": "github:mattermost/calls-common#v0.22.0",
"@mattermost/compass-icons": "0.1.40",
"@mattermost/react-native-emm": "1.4.0",
"@mattermost/react-native-network-client": "1.5.0",
"@mattermost/react-native-paste-input": "0.7.0",
@ -3446,7 +3446,7 @@
"node_modules/@mattermost/calls": {
"name": "@calls/common",
"version": "0.14.0",
"resolved": "git+ssh://git@github.com/mattermost/calls-common.git#4bca3651b2eb5d46fab9a29af000d21d9f56727c"
"resolved": "git+ssh://git@github.com/mattermost/calls-common.git#4a45138c02e3ce45d9e26f81c0f421a136ae0e59"
},
"node_modules/@mattermost/commonmark": {
"version": "0.30.1-2",
@ -3468,9 +3468,9 @@
}
},
"node_modules/@mattermost/compass-icons": {
"version": "0.1.39",
"resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.39.tgz",
"integrity": "sha512-rr+grRMg9xs020O4PC2H+/q4+B8hjuGay49i2xEVxGATaHJFkGItDVXqlfPVDcQPVOmZI7m66H+DOnnYrr5pBQ=="
"version": "0.1.40",
"resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.40.tgz",
"integrity": "sha512-Vz4BeTuwk7bgcKNyOn8F3Q0WsZF8s44bJNawg29pKQc4TLVPurceUhlsqi+B2vVTyCSVyNlFWijg/PWemyCgdQ=="
},
"node_modules/@mattermost/react-native-emm": {
"version": "1.4.0",

View file

@ -1,6 +1,6 @@
{
"name": "mattermost-mobile",
"version": "2.12.0",
"version": "2.13.0",
"description": "Mattermost Mobile with React Native",
"repository": "git@github.com:mattermost/mattermost-mobile.git",
"author": "Mattermost, Inc.",
@ -19,8 +19,8 @@
"@formatjs/intl-pluralrules": "5.2.10",
"@formatjs/intl-relativetimeformat": "11.2.10",
"@gorhom/bottom-sheet": "4.5.1",
"@mattermost/calls": "github:mattermost/calls-common#v0.21.0",
"@mattermost/compass-icons": "0.1.39",
"@mattermost/calls": "github:mattermost/calls-common#v0.22.0",
"@mattermost/compass-icons": "0.1.40",
"@mattermost/react-native-emm": "1.4.0",
"@mattermost/react-native-network-client": "1.5.0",
"@mattermost/react-native-paste-input": "0.7.0",

View file

@ -0,0 +1,14 @@
diff --git a/node_modules/react-native-math-view/src/common.tsx b/node_modules/react-native-math-view/src/common.tsx
index a599184..4e4830a 100644
--- a/node_modules/react-native-math-view/src/common.tsx
+++ b/node_modules/react-native-math-view/src/common.tsx
@@ -61,8 +61,7 @@ export const getPreserveAspectRatio = (alignment: string, scale: string) => `${a
export const styles = StyleSheet.create({
container: {
//flexDirection: 'row',
- display: 'flex',
- minHeight: 35
+ display: 'flex'
},
contain: {
maxWidth: '100%',

View file

@ -91,3 +91,91 @@ index becee6a..5d4b2cd 100644
</LinearLayout>
</LinearLayout>
diff --git a/node_modules/react-native-video/ios/Video/RCTVideo.m b/node_modules/react-native-video/ios/Video/RCTVideo.m
index a757c08..2e402e2 100644
--- a/node_modules/react-native-video/ios/Video/RCTVideo.m
+++ b/node_modules/react-native-video/ios/Video/RCTVideo.m
@@ -449,6 +449,7 @@ - (void)playerItemPrepareText:(AVAsset *)asset assetOptions:(NSDictionary * __nu
_allowsExternalPlayback = NO;
// sideload text tracks
+ NSLock *mixCompositionLock = [[NSLock alloc] init]; // mixComposition is not thread-safe; lock in the async blocks below
AVMutableComposition *mixComposition = [[AVMutableComposition alloc] init];
AVAssetTrack *videoAsset = [asset tracksWithMediaType:AVMediaTypeVideo].firstObject;
@@ -466,30 +467,58 @@ - (void)playerItemPrepareText:(AVAsset *)asset assetOptions:(NSDictionary * __nu
error:nil];
NSMutableArray* validTextTracks = [NSMutableArray array];
+ NSLock *validTextTracksLock = [[NSLock alloc] init]; // validTextTracks is not thread-safe
+ dispatch_group_t textTracksDispatchGroup = dispatch_group_create();
+
for (int i = 0; i < _textTracks.count; ++i) {
AVURLAsset *textURLAsset;
- NSString *textUri = [_textTracks objectAtIndex:i][@"uri"];
+ NSDictionary *curTrack = _textTracks[i]; // capture the current track, because we're going to set _textTracks in an async block below
+ NSString *textUri = curTrack[@"uri"];
+
if ([[textUri lowercaseString] hasPrefix:@"http"]) {
textURLAsset = [AVURLAsset URLAssetWithURL:[NSURL URLWithString:textUri] options:assetOptions];
} else {
textURLAsset = [AVURLAsset URLAssetWithURL:[self urlFilePath:textUri] options:nil];
}
- AVAssetTrack *textTrackAsset = [textURLAsset tracksWithMediaType:AVMediaTypeText].firstObject;
- if (!textTrackAsset) continue; // fix when there's no textTrackAsset
- [validTextTracks addObject:[_textTracks objectAtIndex:i]];
- AVMutableCompositionTrack *textCompTrack = [mixComposition
- addMutableTrackWithMediaType:AVMediaTypeText
- preferredTrackID:kCMPersistentTrackID_Invalid];
- [textCompTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, videoAsset.timeRange.duration)
- ofTrack:textTrackAsset
- atTime:kCMTimeZero
- error:nil];
- }
- if (validTextTracks.count != _textTracks.count) {
- [self setTextTracks:validTextTracks];
- }
-
- handler([AVPlayerItem playerItemWithAsset:mixComposition]);
+
+ dispatch_group_enter(textTracksDispatchGroup); // completionHandler runs async
+
+ [textURLAsset loadTracksWithMediaType:AVMediaTypeText completionHandler:^(NSArray<AVAssetTrack *> * trackArr, NSError * error) {
+ if (error) {
+ RCTLogError(@"Error from loadTracksWithMediaType: %@", error);
+ dispatch_group_leave(textTracksDispatchGroup);
+ return;
+ }
+
+ AVAssetTrack *textTrackAsset = trackArr.firstObject;
+
+ if (!textTrackAsset) return;
+
+ [validTextTracksLock lock]; // validTextTracks is not thread-safe
+ [validTextTracks addObject:curTrack];
+ [validTextTracksLock unlock];
+
+ [mixCompositionLock lock]; // mixComposition is not thread-safe
+ AVMutableCompositionTrack *textCompTrack = [mixComposition
+ addMutableTrackWithMediaType:AVMediaTypeText
+ preferredTrackID:kCMPersistentTrackID_Invalid];
+ [textCompTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, videoAsset.timeRange.duration)
+ ofTrack:textTrackAsset
+ atTime:kCMTimeZero
+ error:nil];
+ [mixCompositionLock unlock];
+
+ dispatch_group_leave(textTracksDispatchGroup);
+ }];
+ }
+
+ dispatch_group_notify(textTracksDispatchGroup, dispatch_get_main_queue(), ^{
+ if (validTextTracks.count > 0) {
+ [self setTextTracks:validTextTracks];
+ }
+
+ handler([AVPlayerItem playerItemWithAsset:mixComposition]);
+ });
}
- (void)playerItemForSource:(NSDictionary *)source withCallback:(void(^)(AVPlayerItem *))handler

View file

@ -1,17 +1,18 @@
#!/bin/bash
#shellcheck disable=SC2166,SC2064,SC2015
set -eu -o pipefail
cd "$(dirname "$0")"/..
log () { echo "[$(date +%Y-%m-%dT%H:%M:%S%Z)]" "$@"; }
: ${BRANCH_TO_BUILD:=main}
: ${PR_REVIEWERS:=mattermost/core-build-engineers}
: ${BUMP_BUILD_NUMBER:=} # You can optionally specify the BUILD_NUMBER variable for selecting the next build number
# If you don't, then the Fastlane action will pick the next build number automatically
: ${BUMP_VERSION_NUMBER:=} # If enabled, you must populate the VERSION_NUMBER variable as well
: ${CREATE_PR:=} # Enable CREATE_PR to push the commit to origin, and create a corresponding PR
: ${PR_EXTRA_MESSAGE:=} # Optional message to add in the PR description
: ${GIT_LOCAL_BRANCH:=chore-bump-${BRANCH_TO_BUILD}-$(date +%s)}
: "${BRANCH_TO_BUILD:=main}"
: "${PR_REVIEWERS:=mattermost/core-build-engineers}"
: "${BUMP_BUILD_NUMBER:=}" # You can optionally specify the BUILD_NUMBER variable for selecting the next build number
# If you don't, then the Fastlane action will pick the next build number automatically
: "${BUMP_VERSION_NUMBER:=}" # If enabled, you must populate the VERSION_NUMBER variable as well
: "${CREATE_PR:=}" # Enable CREATE_PR to push the commit to origin, and create a corresponding PR
: "${PR_EXTRA_MESSAGE:=}" # Optional message to add in the PR description
: "${GIT_LOCAL_BRANCH:=chore-bump-${BRANCH_TO_BUILD}-$(date +%s)}"
log "Checking that the configuration is sane"
if [ -z "${BUMP_BUILD_NUMBER}" -a -z "${BUMP_VERSION_NUMBER}" ]; then
@ -19,9 +20,9 @@ if [ -z "${BUMP_BUILD_NUMBER}" -a -z "${BUMP_VERSION_NUMBER}" ]; then
exit 1
fi
if [ -n "$BUMP_VERSION_NUMBER" ]; then
: ${VERSION_NUMBER:?Setting this variable is required when BUMP_VERSION_NUMBER is set.}
: "${VERSION_NUMBER:?Setting this variable is required when BUMP_VERSION_NUMBER is set.}"
VERSION_REGEXP='^[0-9]+\.[0-9]+\.[0-9]+$'
if ! grep -qE $VERSION_REGEXP"" <<<$VERSION_NUMBER; then
if ! grep -qE "$VERSION_REGEXP" <<<"$VERSION_NUMBER"; then
log "Error: the VERSION_NUMBER variable value should match regexp '$VERSION_REGEXP'. Aborting." >&2
exit 2
fi
@ -41,15 +42,15 @@ if [ -n "$BUMP_BUILD_NUMBER" ]; then
if [ -z "${BUILD_NUMBER:-}" ]; then
log "Selecting the next largest build number..."
LATEST_BUILD_NUMBER=$(./scripts/get_latest_build_number.sh)
BUILD_NUMBER=$(($LATEST_BUILD_NUMBER + 1))
BUILD_NUMBER=$((LATEST_BUILD_NUMBER + 1))
fi
log "Build number to use for the beta build: $BUILD_NUMBER"
fi
log "Creating branch '${GIT_LOCAL_BRANCH}' based on branch '$BRANCH_TO_BUILD'"
git checkout $BRANCH_TO_BUILD
git checkout "$BRANCH_TO_BUILD"
git pull
git checkout -b $GIT_LOCAL_BRANCH
git checkout -b "$GIT_LOCAL_BRANCH"
log "Generating env file required by Fastlane..."
tee .env <<EOF
@ -81,17 +82,20 @@ fi
if [ -n "${CREATE_PR}" ]; then
log "Pushing branch ${GIT_LOCAL_BRANCH}, and creating a corresponding PR to ${BRANCH_TO_BUILD}"
git push origin ${GIT_LOCAL_BRANCH}
git push origin "${GIT_LOCAL_BRANCH}"
log "Creating PR"
PR_TITLE="Bump app ${BUMP_BUILD_NUMBER:+build}${BUMP_VERSION_NUMBER:+${BUMP_BUILD_NUMBER:+ and }version} number"
# We won't specify the milestone when opening the PR if either we're not bumping the version number, or if the milestone is not found
MILESTONE_FOUND=$(curl -L -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" https://api.github.com/repos/mattermost/mattermost-mobile/milestones | jq -r ".[] | .title | select(. == \"v${VERSION_NUMBER:-}\")")
#shellcheck disable=SC2046
gh pr create \
--repo mattermost/mattermost-mobile \
--base main \
--base "${BRANCH_TO_BUILD}" \
--head "${GIT_LOCAL_BRANCH}" \
--reviewer "${PR_REVIEWERS}" \
--title "$PR_TITLE" \
$([ -z "$BUMP_VERSION_NUMBER" ] || echo -n "--milestone v${VERSION_NUMBER} --label CherryPick/Approved") \
$([ -z "$BUMP_VERSION_NUMBER" -o -z "${MILESTONE_FOUND}" ] || echo -n "--milestone v${VERSION_NUMBER} --label CherryPick/Approved") \
--body-file - <<EOF
#### Summary
$([ -z "$BUMP_BUILD_NUMBER" ] || echo "\

View file

@ -22,6 +22,7 @@ type FileInfo = {
uri?: string;
user_id: string;
width: number;
postProps?: Record<string, any>;
};
type FilesState = {

View file

@ -1,6 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Caption} from '@mattermost/calls/lib/types';
import type {GestureHandlerGestureEvent} from 'react-native-gesture-handler';
import type Animated from 'react-native-reanimated';
@ -71,6 +74,7 @@ export type GalleryItemType = {
authorId?: string;
size?: number;
postId?: string;
postProps?: Record<string, any> & {captions?: Caption[]};
};
export type GalleryAction = 'none' | 'downloading' | 'copying' | 'sharing' | 'opening' | 'external';