Merge branch 'main' into MM-56592-plugin-deeplink
This commit is contained in:
commit
7be7e5f0c4
67 changed files with 1572 additions and 225 deletions
3
.github/workflows/build-android-beta.yml
vendored
3
.github/workflows/build-android-beta.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
23
.github/workflows/build-pr.yml
vendored
23
.github/workflows/build-pr.yml
vendored
|
|
@ -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 }}"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
328
app/components/floating_text_chips_input/index.tsx
Normal file
328
app/components/floating_text_chips_input/index.tsx
Normal 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;
|
||||
62
app/components/floating_text_chips_input/utils.test.ts
Normal file
62
app/components/floating_text_chips_input/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
app/components/floating_text_chips_input/utils.ts
Normal file
19
app/components/floating_text_chips_input/utils.ts
Normal 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];
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
6
app/products/calls/context.ts
Normal file
6
app/products/calls/context.ts
Normal 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[]>([]);
|
||||
5
app/products/calls/errors.ts
Normal file
5
app/products/calls/errors.ts
Normal 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');
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
73
app/screens/gallery/footer/actions/inverted_action.tsx
Normal file
73
app/screens/gallery/footer/actions/inverted_action.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
48
app/utils/helpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.:]*';
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "大文字小文字を区別したあなたの名前",
|
||||
|
|
|
|||
|
|
@ -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} 채널은 이제 비공개 채널입니다.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Ваше регистрозависимое имя",
|
||||
|
|
|
|||
|
|
@ -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ư.",
|
||||
|
|
|
|||
|
|
@ -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": "您的大小写区分名",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
16
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
14
patches/react-native-math-view+3.9.5.patch
Normal file
14
patches/react-native-math-view+3.9.5.patch
Normal 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%',
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "\
|
||||
|
|
|
|||
1
types/api/files.d.ts
vendored
1
types/api/files.d.ts
vendored
|
|
@ -22,6 +22,7 @@ type FileInfo = {
|
|||
uri?: string;
|
||||
user_id: string;
|
||||
width: number;
|
||||
postProps?: Record<string, any>;
|
||||
};
|
||||
|
||||
type FilesState = {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue