diff --git a/.github/workflows/build-android-beta.yml b/.github/workflows/build-android-beta.yml index 4aa661dff..2f6bfaa6e 100644 --- a/.github/workflows/build-android-beta.yml +++ b/.github/workflows/build-android-beta.yml @@ -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 diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 8f6997109..6364a3e74 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -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 }}" diff --git a/android/app/build.gradle b/android/app/build.gradle index ca647e998..5849c9510 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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' } diff --git a/app/actions/remote/preference.ts b/app/actions/remote/preference.ts index 5e390efea..fd1b4b4db 100644 --- a/app/actions/remote/preference.ts +++ b/app/actions/remote/preference.ts @@ -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, diff --git a/app/actions/remote/role.ts b/app/actions/remote/role.ts index ecbc29bd8..8ffb1ed84 100644 --- a/app/actions/remote/role.ts +++ b/app/actions/remote/role.ts @@ -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, diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index af41b7e09..86ab63a8c 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -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 { 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((acc, f) => { + const [allChannelIds, allPostIds] = files.reduce<[Set, Set]>((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(), new Set()]); + 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>((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)); diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index bb5d79902..91a32e548 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -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) { diff --git a/app/components/files/files.tsx b/app/components/files/files.tsx index 3747fea43..da11d676f 100644 --- a/app/components/files/files.tsx +++ b/app/components/files/files.tsx @@ -24,6 +24,7 @@ type FilesProps = { location: string; isReplyPost: boolean; postId: string; + postProps: Record; 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); }); diff --git a/app/components/files/index.ts b/app/components/files/index.ts index 339fa7ad1..f2b7b15b7 100644 --- a/app/components/files/index.ts +++ b/app/components/files/index.ts @@ -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, }; diff --git a/app/components/floating_text_chips_input/index.tsx b/app/components/floating_text_chips_input/index.tsx new file mode 100644 index 000000000..ac5252d06 --- /dev/null +++ b/app/components/floating_text_chips_input/index.tsx @@ -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; + +type Props = TextInputPropsFiltered & { + containerStyle?: StyleProp; + editable?: boolean; + error?: string; + errorIcon?: string; + isKeyboardInput?: boolean; + label: string; + labelTextStyle?: TextStyle; + onBlur?: (event: NativeSyntheticEvent) => void; + onFocus?: (e: NativeSyntheticEvent) => 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(({ + 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(); + + const inputRef = useRef(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) => { + setIsFocusLabel(hasValues); + setIsFocused(false); + + onBlur?.(e); + }, [onBlur, hasValues]); + + const onTextInputFocus = useCallback((e: NativeSyntheticEvent) => { + 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 = [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 ( + + + + + {label} + + + {chipsValues && chipsValues?.length > 0 && chipsValues.map((chipValue) => ( + + ))} + + + + {Boolean(error) && ( + + {showErrorIcon && errorIcon && + + } + + {error} + + + )} + + + ); +}); + +FloatingTextChipsInput.displayName = 'FloatingTextChipsInput'; +export default FloatingTextChipsInput; diff --git a/app/components/floating_text_chips_input/utils.test.ts b/app/components/floating_text_chips_input/utils.test.ts new file mode 100644 index 000000000..417e9f63d --- /dev/null +++ b/app/components/floating_text_chips_input/utils.test.ts @@ -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); + }); +}); diff --git a/app/components/floating_text_chips_input/utils.ts b/app/components/floating_text_chips_input/utils.ts new file mode 100644 index 000000000..77d26e2e9 --- /dev/null +++ b/app/components/floating_text_chips_input/utils.ts @@ -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]; +}; + diff --git a/app/components/floating_text_input_label/index.tsx b/app/components/floating_text_input_label/index.tsx index 74a8bdbb2..708ac8b45 100644 --- a/app/components/floating_text_input_label/index.tsx +++ b/app/components/floating_text_input_label/index.tsx @@ -95,6 +95,7 @@ type FloatingTextInputProps = TextInputProps & { label: string; labelTextStyle?: TextStyle; multiline?: boolean; + multilineInputHeight?: number; onBlur?: (event: NativeSyntheticEvent) => void; onFocus?: (e: NativeSyntheticEvent) => void; onLayout?: (e: LayoutChangeEvent) => void; @@ -117,6 +118,7 @@ const FloatingTextInput = forwardRef { const res: StyleProp = [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; diff --git a/app/components/selected_chip/index.tsx b/app/components/selected_chip/index.tsx index 0b78e7dc2..f961d5259 100644 --- a/app/components/selected_chip/index.tsx +++ b/app/components/selected_chip/index.tsx @@ -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; } 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({ {extra} diff --git a/app/components/syntax_highlight/index.tsx b/app/components/syntax_highlight/index.tsx index 8ddcdd63b..e528e3107 100644 --- a/app/components/syntax_highlight/index.tsx +++ b/app/components/syntax_highlight/index.tsx @@ -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 ( ({ - 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(); + }); }); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index e9c2a3e23..c2b249bc4 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -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; } }; diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index 598fc18d5..9df53b23a 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -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); + } +}; diff --git a/app/products/calls/components/current_call_bar/current_call_bar.tsx b/app/products/calls/components/current_call_bar/current_call_bar.tsx index b4e1d3c51..b618dd94c 100644 --- a/app/products/calls/components/current_call_bar/current_call_bar.tsx +++ b/app/products/calls/components/current_call_bar/current_call_bar.tsx @@ -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: diff --git a/app/products/calls/connection/connection.ts b/app/products/calls/connection/connection.ts index bf96d10ed..2586e29cc 100644 --- a/app/products/calls/connection/connection.ts +++ b/app/products/calls/connection/connection.ts @@ -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); } }; diff --git a/app/products/calls/context.ts b/app/products/calls/context.ts new file mode 100644 index 000000000..3453e1ed5 --- /dev/null +++ b/app/products/calls/context.ts @@ -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([]); diff --git a/app/products/calls/errors.ts b/app/products/calls/errors.ts new file mode 100644 index 000000000..2fea518a8 --- /dev/null +++ b/app/products/calls/errors.ts @@ -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'); diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index 5c7180929..322415d17 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -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: diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index c2bd051d0..562cea70e 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -124,7 +124,7 @@ export type CallSession = { export type ChannelsWithCalls = Dictionary; export type CallsConnection = { - disconnect: () => void; + disconnect: (err?: Error) => void; mute: () => void; unmute: () => void; waitForPeerConnection: () => Promise; @@ -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; +}; diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts index bcfd80a77..04d554b0a 100644 --- a/app/products/calls/utils.ts +++ b/app/products/calls/utils.ts @@ -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 & { captions?: Caption[] }): boolean => { + return !(!postProps || !postProps.captions?.[0]); +}; + +export const getTranscriptionUri = (serverUrl: string, postProps?: Record & { 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}, + }; +}; diff --git a/app/screens/channel_add_members/channel_add_members.tsx b/app/screens/channel_add_members/channel_add_members.tsx index c60efcf15..8f9331d9c 100644 --- a/app/screens/channel_add_members/channel_add_members.tsx +++ b/app/screens/channel_add_members/channel_add_members.tsx @@ -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; diff --git a/app/screens/find_channels/filtered_list/filtered_list.tsx b/app/screens/find_channels/filtered_list/filtered_list.tsx index 77d687d06..6d9414b7d 100644 --- a/app/screens/find_channels/filtered_list/filtered_list.tsx +++ b/app/screens/find_channels/filtered_list/filtered_list.tsx @@ -219,7 +219,8 @@ const FilteredList = ({ /> ); } - if ('teamId' in item || 'team_id' in item) { + + if ('teamId' in item) { return ( ); - } else if ('username' in item) { + } + + if ('username' in item) { return ( { 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) => { diff --git a/app/screens/gallery/footer/actions/index.tsx b/app/screens/gallery/footer/actions/index.tsx index 7c0bf3a5c..6b86d2f8b 100644 --- a/app/screens/gallery/footer/actions/index.tsx +++ b/app/screens/gallery/footer/actions/index.tsx @@ -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(); const canCopyPublicLink = !fileId.startsWith('uid') && enablePublicLinks && managedConfig.copyAndPasteProtection !== 'true'; @@ -41,19 +45,24 @@ const Actions = ({ iconName='link-variant' onPress={onCopyPublicLink} />} + {hasCaptions && + + } {canDownloadFiles && <> } diff --git a/app/screens/gallery/footer/actions/inverted_action.tsx b/app/screens/gallery/footer/actions/inverted_action.tsx new file mode 100644 index 000000000..0e25051dc --- /dev/null +++ b/app/screens/gallery/footer/actions/inverted_action.tsx @@ -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; +} + +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 ( + + + + ); +}; + +export default InvertedAction; diff --git a/app/screens/gallery/footer/footer.tsx b/app/screens/gallery/footer/footer.tsx index 310805fb5..09ce90dbc 100644 --- a/app/screens/gallery/footer/footer.tsx +++ b/app/screens/gallery/footer/footer.tsx @@ -35,6 +35,9 @@ type Props = { post?: PostModel; style: StyleProp; 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('none'); @@ -142,6 +146,9 @@ const Footer = ({ onCopyPublicLink={handleCopyLink} onDownload={handleDownload} onShare={handleShare} + hasCaptions={hasCaptions} + captionEnabled={captionEnabled} + onCaptionsPress={onCaptionsPress} /> } diff --git a/app/screens/gallery/index.tsx b/app/screens/gallery/index.tsx index 40d33290f..9a615e0dc 100644 --- a/app/screens/gallery/index.tsx +++ b/app/screens/gallery/index.tsx @@ -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(new Array(items.length).fill(true)); + const [captionsAvailable, setCaptionsAvailable] = useState([]); const {setControlsHidden, headerStyles, footerStyles} = useGalleryControls(); const dimensions = useMemo(() => ({width: dim.width, height: dim.height}), [dim.width]); const galleryRef = useRef(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 ( - <> +
- + ); }; diff --git a/app/screens/gallery/video_renderer/index.tsx b/app/screens/gallery/video_renderer/index.tsx index f61ed5966..009d83f48 100644 --- a/app/screens/gallery/video_renderer/index.tsx +++ b/app/screens/gallery/video_renderer/index.tsx @@ -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