Compare commits

...

13 Commits

Author SHA1 Message Date
Avinash Lingaloo
7c9e9357a5 voice record upload - [ IN PROGRESS ] 2022-10-13 11:35:52 +04:00
Avinash Lingaloo
335b8f2925 voice upload ui [ IN PROGRESS ] 2022-10-12 16:03:33 +04:00
Avinash Lingaloo
bd9bb48384 voice upload ui [ IN PROGRESS ] 2022-10-12 12:13:25 +04:00
Avinash Lingaloo
04030a93f7 voice upload ui [ IN PROGRESS ] 2022-10-12 11:48:41 +04:00
Avinash Lingaloo
56987dcf37 voice upload ui [ IN PROGRESS ] 2022-10-11 17:36:59 +04:00
Avinash Lingaloo
494df6e0b1 PR review fix 2022-10-03 14:45:46 +04:00
Avinash Lingaloo
c7040707f4 fix wiring
fix wiring

fix wiring
2022-10-03 12:05:43 +04:00
Avinash Lingaloo
d6eb40776f Merge branch 'voice-message' into voice-message-recording-ui 2022-10-03 10:38:49 +04:00
Avinash Lingaloo
375412833d animated microphone
animated microphone in progress
2022-09-23 11:41:55 -04:00
Avinash Lingaloo
7d2f23b5f4 animated sound wave
sound wave animation - nearly there
2022-09-23 11:41:55 -04:00
Avinash Lingaloo
7fbc59b322 wiring components
some more components

update condition
2022-09-23 11:41:54 -04:00
Avinash Lingaloo
92c18c4893 record action button
added record_action button
2022-09-23 11:41:41 -04:00
Daniel Espino García
9854683321 Voice Messages (#6651)
* Initial skeleton

* Cleanup and fix errors

* Minor refactoring and add config

* Fix lint

* Add selection logic to post body

* Fix naming
2022-09-21 23:06:03 +02:00
22 changed files with 885 additions and 114 deletions

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {StyleSheet, Text, useWindowDimensions, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import PlayBack from '@components/files/voice_recording_file/playback';
import {MIC_SIZE, VOICE_MESSAGE_CARD_RATIO} from '@constants/view';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
//i18n
const VOICE_MESSAGE = 'Voice message';
const UPLOADING_TEXT = 'Uploading..(0%)';
type Props = {
file: FileInfo;
uploading: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
flexDirection: 'row',
borderWidth: StyleSheet.hairlineWidth,
borderColor: changeOpacity(theme.centerChannelColor, 0.56),
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 6,
shadowOffset: {
width: 0,
height: 3,
},
alignItems: 'center',
},
centerContainer: {
marginLeft: 12,
},
title: {
color: theme.centerChannelColor,
...typography('Heading', 200),
},
uploading: {
color: changeOpacity(theme.centerChannelColor, 0.56),
...typography('Body', 75),
},
close: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.56),
},
mic: {
borderRadius: MIC_SIZE / 2,
backgroundColor: changeOpacity(theme.buttonBg, 0.12),
height: MIC_SIZE,
width: MIC_SIZE,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 12,
},
playBackContainer: {
flexDirection: 'row',
alignItems: 'center',
},
};
});
const VoiceRecordingFile = ({file, uploading}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const dimensions = useWindowDimensions();
const isVoiceMessage = file.is_voice_recording;
const voiceStyle = useMemo(() => {
return {
width: dimensions.width * VOICE_MESSAGE_CARD_RATIO,
};
}, [dimensions.width]);
const getUploadingView = useCallback(() => {
return (
<>
<View
style={styles.mic}
>
<CompassIcon
name='microphone'
size={24}
color={theme.buttonBg}
/>
</View>
<View style={styles.centerContainer}>
<Text style={styles.title}>{VOICE_MESSAGE}</Text>
<Text style={styles.uploading}>{UPLOADING_TEXT}</Text>
</View>
</>
);
}, [uploading]);
return (
<View
style={[
styles.container,
isVoiceMessage && voiceStyle,
]}
>
{uploading ? getUploadingView() : <PlayBack/>}
</View>
);
};
export default VoiceRecordingFile;

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {TouchableOpacity, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import SoundWave from '@components/post_draft/draft_input/voice_input/sound_wave';
import TimeElapsed from '@components/post_draft/draft_input/voice_input/time_elapsed';
import {MIC_SIZE} from '@constants/view';
import {useTheme} from '@context/theme';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
mic: {
borderRadius: MIC_SIZE / 2,
backgroundColor: changeOpacity(theme.buttonBg, 0.12),
height: MIC_SIZE,
width: MIC_SIZE,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 12,
},
playBackContainer: {
flexDirection: 'row',
alignItems: 'center',
},
};
});
const PlayBack = () => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const [playing, setPlaying] = useState(false);
const play = preventDoubleTap(() => {
return setPlaying((p) => !p);
});
return (
<View
style={styles.playBackContainer}
>
<TouchableOpacity
style={styles.mic}
onPress={play}
>
<CompassIcon
color={theme.buttonBg}
name='play'
size={24}
/>
</TouchableOpacity>
<SoundWave animating={playing}/>
<TimeElapsed/>
</View>
);
};
export default PlayBack;

View File

@@ -1,24 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useCallback} from 'react'; import React, {useCallback, useState} from 'react';
import {LayoutChangeEvent, Platform, ScrollView, View} from 'react-native'; import {LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
import {Edge, SafeAreaView} from 'react-native-safe-area-context'; import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import QuickActions from '@components/post_draft/quick_actions';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import PostInput from '../post_input'; import RecordAction from '../record_action';
import QuickActions from '../quick_actions';
import SendAction from '../send_action'; import SendAction from '../send_action';
import Typing from '../typing'; import Typing from '../typing';
import Uploads from '../uploads';
import MessageInput from './message_input';
import VoiceInput from './voice_input';
type Props = { type Props = {
testID?: string; testID?: string;
channelId: string; channelId: string;
rootId?: string; rootId?: string;
currentUserId: string; currentUserId: string;
voiceMessageEnabled: boolean;
// Cursor Position Handler // Cursor Position Handler
updateCursorPosition: (pos: number) => void; updateCursorPosition: (pos: number) => void;
@@ -42,16 +45,6 @@ const SAFE_AREA_VIEW_EDGES: Edge[] = ['left', 'right'];
const getStyleSheet = makeStyleSheetFromTheme((theme) => { const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return { return {
actionsContainer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: Platform.select({
ios: 1,
android: 2,
}),
},
inputContainer: { inputContainer: {
flex: 1, flex: 1,
flexDirection: 'column', flexDirection: 'column',
@@ -75,37 +68,92 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
borderTopLeftRadius: 12, borderTopLeftRadius: 12,
borderTopRightRadius: 12, borderTopRightRadius: 12,
}, },
actionsContainer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: Platform.select({
ios: 1,
android: 2,
}),
},
sendVoiceMessage: {
position: 'absolute',
right: -5,
top: 16,
},
}; };
}); });
export default function DraftInput({ export default function DraftInput({
testID, addFiles,
canSend,
channelId, channelId,
currentUserId, currentUserId,
cursorPosition,
files, files,
maxMessageLength, maxMessageLength,
rootId = '', rootId = '',
value,
uploadFileError,
sendMessage, sendMessage,
canSend, testID,
updateValue,
addFiles,
updateCursorPosition, updateCursorPosition,
cursorPosition,
updatePostInputTop, updatePostInputTop,
updateValue,
uploadFileError,
value,
voiceMessageEnabled,
}: Props) { }: Props) {
const [recording, setRecording] = useState(false);
const theme = useTheme(); const theme = useTheme();
const style = getStyleSheet(theme);
const handleLayout = useCallback((e: LayoutChangeEvent) => { const handleLayout = useCallback((e: LayoutChangeEvent) => {
updatePostInputTop(e.nativeEvent.layout.height); updatePostInputTop(e.nativeEvent.layout.height);
}, []); }, []);
// Render const onPresRecording = useCallback(() => {
const postInputTestID = `${testID}.post.input`; setRecording(true);
const quickActionsTestID = `${testID}.quick_actions`; }, []);
const onCloseRecording = useCallback(() => {
setRecording(false);
}, []);
const isHandlingVoice = files[0]?.is_voice_recording || recording;
const sendActionTestID = `${testID}.send_action`; const sendActionTestID = `${testID}.send_action`;
const style = getStyleSheet(theme); const recordActionTestID = `${testID}.record_action`;
const getActionButton = useCallback(() => {
if (value.length === 0 && files.length === 0 && voiceMessageEnabled) {
return (
<RecordAction
onPress={onPresRecording}
testID={recordActionTestID}
/>
);
}
return (
<SendAction
disabled={!canSend}
sendMessage={sendMessage}
testID={sendActionTestID}
/>
);
}, [
canSend,
files.length,
onCloseRecording,
onPresRecording,
sendMessage,
testID,
value.length,
voiceMessageEnabled,
isHandlingVoice,
]);
const quickActionsTestID = `${testID}.quick_actions`;
return ( return (
<> <>
@@ -119,51 +167,61 @@ export default function DraftInput({
style={style.inputWrapper} style={style.inputWrapper}
testID={testID} testID={testID}
> >
<ScrollView <ScrollView
style={style.inputContainer}
contentContainerStyle={style.inputContentContainer} contentContainerStyle={style.inputContentContainer}
keyboardShouldPersistTaps={'always'}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
pinchGestureEnabled={false}
overScrollMode={'never'}
disableScrollViewPanResponder={true} disableScrollViewPanResponder={true}
keyboardShouldPersistTaps={'always'}
overScrollMode={'never'}
pinchGestureEnabled={false}
scrollEnabled={false}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
style={style.inputContainer}
> >
<PostInput {recording && (
testID={postInputTestID} <VoiceInput
channelId={channelId}
maxMessageLength={maxMessageLength}
rootId={rootId}
cursorPosition={cursorPosition}
updateCursorPosition={updateCursorPosition}
updateValue={updateValue}
value={value}
addFiles={addFiles}
sendMessage={sendMessage}
/>
<Uploads
currentUserId={currentUserId}
files={files}
uploadFileError={uploadFileError}
channelId={channelId}
rootId={rootId}
/>
<View style={style.actionsContainer}>
<QuickActions
testID={quickActionsTestID}
fileCount={files.length}
addFiles={addFiles} addFiles={addFiles}
onClose={onCloseRecording}
setRecording={setRecording}
/>
)}
{!recording &&
<MessageInput
addFiles={addFiles}
canSend={canSend}
channelId={channelId}
currentUserId={currentUserId}
cursorPosition={cursorPosition}
files={files}
maxMessageLength={maxMessageLength}
rootId={rootId}
sendMessage={sendMessage}
setRecording={setRecording}
testID={testID}
updateCursorPosition={updateCursorPosition}
updateValue={updateValue} updateValue={updateValue}
uploadFileError={uploadFileError}
value={value} value={value}
/> />
<SendAction }
testID={sendActionTestID} <View style={style.actionsContainer}>
disabled={!canSend} {!isHandlingVoice &&
sendMessage={sendMessage} <QuickActions
/> testID={quickActionsTestID}
fileCount={files.length}
addFiles={addFiles}
updateValue={updateValue}
value={value}
/>
}
{!isHandlingVoice && getActionButton()}
</View> </View>
<SendAction
disabled={!canSend}
sendMessage={sendMessage}
testID={sendActionTestID}
containerStyle={isHandlingVoice && style.sendVoiceMessage}
/>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
</> </>

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {observeVoiceMessagesEnabled} from '@queries/servers/system';
import DraftInput from './draft_input';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
voiceMessageEnabled: observeVoiceMessagesEnabled(database),
};
});
export default withDatabase(enhanced(DraftInput));

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PostInput from '../post_input';
import Uploads from '../uploads';
type Props = {
testID?: string;
channelId: string;
rootId?: string;
currentUserId: string;
// Cursor Position Handler
updateCursorPosition: (pos: number) => void;
cursorPosition: number;
// Send Handler
sendMessage: () => void;
canSend: boolean;
maxMessageLength: number;
// Draft Handler
files: FileInfo[];
value: string;
uploadFileError: React.ReactNode;
updateValue: (value: string) => void;
addFiles: (files: FileInfo[]) => void;
setRecording: (v: boolean) => void;
}
export default function MessageInput({
testID,
channelId,
currentUserId,
files,
maxMessageLength,
rootId = '',
value,
uploadFileError,
sendMessage,
updateValue,
addFiles,
updateCursorPosition,
cursorPosition,
}: Props) {
// Render
const postInputTestID = `${testID}.post.input`;
const isHandlingVoice = files[0]?.is_voice_recording;
return (
<>
{!isHandlingVoice && (
<PostInput
testID={postInputTestID}
channelId={channelId}
maxMessageLength={maxMessageLength}
rootId={rootId}
cursorPosition={cursorPosition}
updateCursorPosition={updateCursorPosition}
updateValue={updateValue}
value={value}
addFiles={addFiles}
sendMessage={sendMessage}
/>
)}
<Uploads
currentUserId={currentUserId}
files={files}
uploadFileError={uploadFileError}
channelId={channelId}
rootId={rootId}
/>
</>
);
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {View} from 'react-native';
import Animated, {interpolate, SharedValue, useAnimatedStyle, useSharedValue, withRepeat, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import {MIC_SIZE} from '@constants/view';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
const iconCommon = {
height: MIC_SIZE,
width: MIC_SIZE,
alignItems: 'center',
justifyContent: 'center',
};
const round = {
borderRadius: MIC_SIZE / 2,
backgroundColor: changeOpacity(theme.buttonBg, 0.12),
};
return {
mic: {
...iconCommon,
...round,
},
abs: {
position: 'absolute',
},
concentric: {
alignItems: 'center',
justifyContent: 'center',
},
};
});
const useConcentricStyles = (circleId: number, sharedValue: SharedValue<number>) => {
const circles = [1.5, 2.5, 3.5];
return useAnimatedStyle(() => {
const scale = interpolate(sharedValue.value, [0, 1], [circles[circleId], 1]);
const opacity = interpolate(sharedValue.value, [0, 1], [1, 0]);
return {
opacity,
transform: [{scale}],
borderRadius: MIC_SIZE / 2,
};
}, [sharedValue]);
};
const AnimatedMicrophone = () => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const val = useSharedValue(0);
const firstCircleAnimx = useConcentricStyles(0, val);
const secondCircleAnimx = useConcentricStyles(1, val);
const thirdCircleAnimx = useConcentricStyles(2, val);
useEffect(() => {
val.value = withRepeat(
withTiming(1, {duration: 1000}),
800,
true,
);
}, []);
return (
<View style={[styles.mic]}>
<View style={styles.concentric} >
<Animated.View style={[styles.mic, styles.abs, firstCircleAnimx]}/>
<Animated.View style={[styles.mic, styles.abs, secondCircleAnimx]}/>
<Animated.View style={[styles.mic, styles.abs, thirdCircleAnimx]}/>
</View>
<View
style={[styles.mic, styles.abs]}
>
<CompassIcon
name='microphone'
size={24}
color={theme.buttonBg}
/>
</View>
</View>
);
};
export default AnimatedMicrophone;

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {TouchableOpacity, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {MIC_SIZE} from '@constants/view';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import AnimatedMicrophone from './animated_microphone';
import SoundWave from './sound_wave';
import TimeElapsed from './time_elapsed';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
const iconCommon = {
height: MIC_SIZE,
width: MIC_SIZE,
alignItems: 'center',
justifyContent: 'center',
};
const round = {
borderRadius: MIC_SIZE / 2,
backgroundColor: changeOpacity(theme.buttonBg, 0.12),
};
return {
mainContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
height: 88,
},
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
},
mic: {
...iconCommon,
...round,
},
check: {
...iconCommon,
...round,
backgroundColor: theme.buttonBg,
},
close: {
...iconCommon,
},
};
});
type VoiceInputProps = {
setRecording: (v: boolean) => void;
addFiles: (f: FileInfo[]) => void;
onClose: () => void;
}
const VoiceInput = ({onClose}: VoiceInputProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View style={styles.mainContainer}>
<AnimatedMicrophone/>
<SoundWave/>
<TimeElapsed/>
<TouchableOpacity
style={styles.close}
onPress={onClose}
>
<CompassIcon
color={theme.buttonBg}
name='close'
size={24}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.check}
onPress={onClose} // to be fixed when wiring is completed
>
<CompassIcon
color={theme.buttonColor}
name='check'
size={24}
/>
</TouchableOpacity>
</View>
);
};
export default VoiceInput;

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {random} from 'lodash';
import React from 'react';
import {View} from 'react-native';
import Animated, {cancelAnimation, Extrapolation, interpolate, useAnimatedStyle, useSharedValue, withRepeat, withSpring} from 'react-native-reanimated';
import {WAVEFORM_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import useDidUpdate from '@hooks/did_update';
import {makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
height: WAVEFORM_HEIGHT,
width: 165,
flexDirection: 'row',
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
},
singleBar: {
height: WAVEFORM_HEIGHT,
width: 2,
backgroundColor: theme.buttonBg,
marginRight: 1,
},
};
});
type SoundWaveProps = {
animating?: boolean;
};
const SoundWave = ({animating = true}: SoundWaveProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const animatedValue = useSharedValue(5);
const animatedStyles = useAnimatedStyle(() => {
const newHeight = interpolate(
animatedValue.value,
[5, 40],
[0, 40],
Extrapolation.EXTEND,
);
return {
height: newHeight,
};
}, []);
useDidUpdate(() => {
if (animating) {
animatedValue.value = withRepeat(
withSpring(40, {
damping: 10,
mass: 0.6,
overshootClamping: true,
}),
800,
true,
);
} else {
cancelAnimation(animatedValue);
}
}, [animating]);
const getAudioBars = () => {
const bars = [];
for (let i = 0; i < 50; i++) {
let height;
if (random(i, 50) % 2 === 0) {
height = random(5, 30);
}
bars.push(
<Animated.View
key={i}
style={[
styles.singleBar,
{height},
!height && animatedStyles,
]}
/>,
);
}
return bars;
};
return (
<View style={styles.container}>
{getAudioBars()}
</View>
);
};
export default SoundWave;

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {Text} from 'react-native';
//fixme: hook up the time elapsed progress from the lib in here
const TimeElapsed = () => {
const [timeElapsed] = useState('00:00');
return (
<Text>
{timeElapsed}
</Text>
);
};
export default TimeElapsed;

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
type Props = {
onPress: () => void;
testID: string;
}
const styles = {
recordButtonContainer: {
justifyContent: 'flex-end',
paddingRight: 8,
},
recordButton: {
borderRadius: 4,
height: 24,
width: 24,
alignItems: 'center',
justifyContent: 'center',
},
};
function RecordButton({onPress, testID}: Props) {
const theme = useTheme();
return (
<TouchableWithFeedback
onPress={onPress}
style={styles.recordButtonContainer}
testID={testID}
type={'opacity'}
>
<CompassIcon
color={theme.centerChannelColor}
name='microphone'
size={24}
/>
</TouchableWithFeedback>
);
}
export default RecordButton;

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useMemo} from 'react'; import React, {useMemo} from 'react';
import {View} from 'react-native'; import {StyleProp, View, ViewStyle} from 'react-native';
import CompassIcon from '@components/compass_icon'; import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback'; import TouchableWithFeedback from '@components/touchable_with_feedback';
@@ -13,6 +13,7 @@ type Props = {
testID: string; testID: string;
disabled: boolean; disabled: boolean;
sendMessage: () => void; sendMessage: () => void;
containerStyle?: StyleProp<ViewStyle>;
} }
const getStyleSheet = makeStyleSheetFromTheme((theme) => { const getStyleSheet = makeStyleSheetFromTheme((theme) => {
@@ -39,6 +40,7 @@ function SendButton({
testID, testID,
disabled, disabled,
sendMessage, sendMessage,
containerStyle,
}: Props) { }: Props) {
const theme = useTheme(); const theme = useTheme();
const sendButtonTestID = disabled ? `${testID}.send.button.disabled` : `${testID}.send.button`; const sendButtonTestID = disabled ? `${testID}.send.button.disabled` : `${testID}.send.button`;
@@ -57,7 +59,7 @@ function SendButton({
<TouchableWithFeedback <TouchableWithFeedback
testID={sendButtonTestID} testID={sendButtonTestID}
onPress={sendMessage} onPress={sendMessage}
style={style.sendButtonContainer} style={[style.sendButtonContainer, containerStyle]}
type={'opacity'} type={'opacity'}
disabled={disabled} disabled={disabled}
> >

View File

@@ -156,17 +156,14 @@ function Uploads({
{buildFilePreviews()} {buildFilePreviews()}
</ScrollView> </ScrollView>
</Animated.View> </Animated.View>
<Animated.View <Animated.View
style={[style.errorContainer, errorAnimatedStyle]} style={[style.errorContainer, errorAnimatedStyle]}
> >
{Boolean(uploadFileError) && {Boolean(uploadFileError) &&
<View style={style.errorTextContainer}> <View style={style.errorTextContainer}>
<Text style={style.warning}> <Text style={style.warning}>
{uploadFileError} {uploadFileError}
</Text> </Text>
</View> </View>
} }
</Animated.View> </Animated.View>

View File

@@ -2,13 +2,15 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'; import {StyleSheet, TouchableWithoutFeedback, useWindowDimensions, View} from 'react-native';
import Animated from 'react-native-reanimated'; import Animated from 'react-native-reanimated';
import {updateDraftFile} from '@actions/local/draft'; import {updateDraftFile} from '@actions/local/draft';
import FileIcon from '@components/files/file_icon'; import FileIcon from '@components/files/file_icon';
import ImageFile from '@components/files/image_file'; import ImageFile from '@components/files/image_file';
import VoiceRecordingFile from '@components/files/voice_recording_file';
import ProgressBar from '@components/progress_bar'; import ProgressBar from '@components/progress_bar';
import {VOICE_MESSAGE_CARD_RATIO} from '@constants/view';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import useDidUpdate from '@hooks/did_update'; import useDidUpdate from '@hooks/did_update';
@@ -63,8 +65,10 @@ export default function UploadItem({
const serverUrl = useServerUrl(); const serverUrl = useServerUrl();
const removeCallback = useRef<(() => void)|null>(null); const removeCallback = useRef<(() => void)|null>(null);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const dimensions = useWindowDimensions();
const loading = DraftUploadManager.isUploading(file.clientId!); const loading = DraftUploadManager.isUploading(file.clientId!);
const isVoiceMessage = file.is_voice_recording;
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
openGallery(file); openGallery(file);
@@ -115,6 +119,16 @@ export default function UploadItem({
/> />
); );
} }
if (isVoiceMessage) {
return (
<VoiceRecordingFile
file={file}
uploading={true}
/>
);
}
return ( return (
<FileIcon <FileIcon
backgroundColor={changeOpacity(theme.centerChannelColor, 0.08)} backgroundColor={changeOpacity(theme.centerChannelColor, 0.08)}
@@ -124,14 +138,35 @@ export default function UploadItem({
); );
}, [file]); }, [file]);
const voiceStyle = useMemo(() => {
return {
width: dimensions.width * VOICE_MESSAGE_CARD_RATIO,
};
}, [dimensions.width]);
return ( return (
<View <View
key={file.clientId} key={file.clientId}
style={style.preview} style={[
style.preview,
isVoiceMessage && voiceStyle,
]}
> >
<View style={style.previewContainer}> <View
<TouchableWithoutFeedback onPress={onGestureEvent}> style={[
<Animated.View style={[styles, style.filePreview]}> style.previewContainer,
]}
>
<TouchableWithoutFeedback
onPress={onGestureEvent}
disabled={file.is_voice_recording}
>
<Animated.View
style={[
styles,
style.filePreview,
]}
>
{filePreviewComponent} {filePreviewComponent}
</Animated.View> </Animated.View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import React from 'react';
import {View, Platform} from 'react-native'; import {View, Platform, StyleProp, ViewStyle} from 'react-native';
import {removeDraftFile} from '@actions/local/draft'; import {removeDraftFile} from '@actions/local/draft';
import CompassIcon from '@components/compass_icon'; import CompassIcon from '@components/compass_icon';
@@ -14,8 +14,9 @@ import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
type Props = { type Props = {
channelId: string; channelId: string;
rootId: string;
clientId: string; clientId: string;
rootId: string;
containerStyle?: StyleProp<ViewStyle>;
} }
const getStyleSheet = makeStyleSheetFromTheme((theme) => { const getStyleSheet = makeStyleSheetFromTheme((theme) => {
@@ -30,7 +31,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
}, },
removeButton: { removeButton: {
borderRadius: 12, borderRadius: 12,
alignSelf: 'center',
// alignSelf: 'center',
marginTop: Platform.select({ marginTop: Platform.select({
ios: 5.4, ios: 5.4,
android: 4.75, android: 4.75,
@@ -44,8 +46,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
export default function UploadRemove({ export default function UploadRemove({
channelId, channelId,
rootId, containerStyle,
clientId, clientId,
rootId,
}: Props) { }: Props) {
const theme = useTheme(); const theme = useTheme();
const style = getStyleSheet(theme); const style = getStyleSheet(theme);
@@ -58,7 +61,7 @@ export default function UploadRemove({
return ( return (
<TouchableWithFeedback <TouchableWithFeedback
style={style.tappableContainer} style={[style.tappableContainer, containerStyle]}
onPress={onPress} onPress={onPress}
type={'opacity'} type={'opacity'}
> >

View File

@@ -4,6 +4,7 @@
import React, {useCallback, useState} from 'react'; import React, {useCallback, useState} from 'react';
import {LayoutChangeEvent, StyleProp, View, ViewStyle} from 'react-native'; import {LayoutChangeEvent, StyleProp, View, ViewStyle} from 'react-native';
import {PostTypes} from '@app/constants/post';
import Files from '@components/files'; import Files from '@components/files';
import FormattedText from '@components/formatted_text'; import FormattedText from '@components/formatted_text';
import JumboEmoji from '@components/jumbo_emoji'; import JumboEmoji from '@components/jumbo_emoji';
@@ -38,6 +39,7 @@ type BodyProps = {
post: PostModel; post: PostModel;
searchPatterns?: SearchPattern[]; searchPatterns?: SearchPattern[];
showAddReaction?: boolean; showAddReaction?: boolean;
voiceMessageEnabled?: boolean;
theme: Theme; theme: Theme;
}; };
@@ -77,7 +79,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const Body = ({ const Body = ({
appsEnabled, hasFiles, hasReactions, highlight, highlightReplyBar, appsEnabled, hasFiles, hasReactions, highlight, highlightReplyBar,
isCRTEnabled, isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAddChannelMember, isCRTEnabled, isEphemeral, isFirstReply, isJumboEmoji, isLastReply, isPendingOrFailed, isPostAddChannelMember,
location, post, searchPatterns, showAddReaction, theme, location, post, searchPatterns, showAddReaction, voiceMessageEnabled, theme,
}: BodyProps) => { }: BodyProps) => {
const style = getStyleSheet(theme); const style = getStyleSheet(theme);
const isEdited = postEdited(post); const isEdited = postEdited(post);
@@ -159,36 +161,51 @@ const Body = ({
} }
if (!hasBeenDeleted) { if (!hasBeenDeleted) {
body = ( if (voiceMessageEnabled && post.type === PostTypes.VOICE_MESSAGE) {
<View style={style.messageBody}> body = (
{message} <View style={style.messageBody}>
{hasContent && {/* <VoiceMessagePost /> */}
<Content {hasReactions && showAddReaction &&
isReplyPost={isReplyPost} <Reactions
layoutWidth={layoutWidth} location={location}
location={location} post={post}
post={post} theme={theme}
theme={theme} />
/> }
} </View>
{hasFiles && );
<Files } else {
failed={isFailed} body = (
layoutWidth={layoutWidth} <View style={style.messageBody}>
location={location} {message}
post={post} {hasContent &&
isReplyPost={isReplyPost} <Content
/> isReplyPost={isReplyPost}
} layoutWidth={layoutWidth}
{hasReactions && showAddReaction && location={location}
<Reactions post={post}
location={location} theme={theme}
post={post} />
theme={theme} }
/> {hasFiles &&
} <Files
</View> failed={isFailed}
); layoutWidth={layoutWidth}
location={location}
post={post}
isReplyPost={isReplyPost}
/>
}
{hasReactions && showAddReaction &&
<Reactions
location={location}
post={post}
theme={theme}
/>
}
</View>
);
}
} }
return ( return (

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {observeVoiceMessagesEnabled} from '@queries/servers/system';
import Body from './body';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
voiceMessageEnabled: observeVoiceMessagesEnabled(database),
};
});
export default withDatabase(enhanced(Body));

View File

@@ -32,6 +32,7 @@ export const PostTypes: Record<string, string> = {
SYSTEM_AUTO_RESPONDER: 'system_auto_responder', SYSTEM_AUTO_RESPONDER: 'system_auto_responder',
CUSTOM_CALLS: 'custom_calls', CUSTOM_CALLS: 'custom_calls',
VOICE_MESSAGE: 'voice',
}; };
export const PostPriorityTypes: Record<string, string> = { export const PostPriorityTypes: Record<string, string> = {

View File

@@ -27,6 +27,9 @@ export const JOIN_CALL_BAR_HEIGHT = 38;
export const CURRENT_CALL_BAR_HEIGHT = 74; export const CURRENT_CALL_BAR_HEIGHT = 74;
export const QUICK_OPTIONS_HEIGHT = 270; export const QUICK_OPTIONS_HEIGHT = 270;
export const VOICE_MESSAGE_CARD_RATIO = 0.72;
export const MIC_SIZE = 40;
export const WAVEFORM_HEIGHT = 40;
export default { export default {
BOTTOM_TAB_HEIGHT, BOTTOM_TAB_HEIGHT,
@@ -50,5 +53,8 @@ export default {
HEADER_SEARCH_HEIGHT, HEADER_SEARCH_HEIGHT,
HEADER_SEARCH_BOTTOM_MARGIN, HEADER_SEARCH_BOTTOM_MARGIN,
QUICK_OPTIONS_HEIGHT, QUICK_OPTIONS_HEIGHT,
VOICE_MESSAGE_CARD_RATIO,
MIC_SIZE,
WAVEFORM_HEIGHT,
}; };

View File

@@ -423,6 +423,12 @@ export const observeAllowedThemesKeys = (database: Database) => {
); );
}; };
export const observeVoiceMessagesEnabled = (database: Database) => {
return observeConfig(database).pipe(
switchMap((c) => of$(c?.ExperimentalEnableVoiceMessages === 'true' && c?.FeatureFlagEnableVoiceMessages === 'true')),
);
};
export const getExpiredSession = async (database: Database) => { export const getExpiredSession = async (database: Database) => {
try { try {
const session = await database.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.SESSION_EXPIRATION); const session = await database.get<SystemModel>(SYSTEM).find(SYSTEM_IDENTIFIERS.SESSION_EXPIRATION);

View File

@@ -516,9 +516,9 @@ PODS:
- RNScreens (3.15.0): - RNScreens (3.15.0):
- React-Core - React-Core
- React-RCTImage - React-RCTImage
- RNSentry (4.2.2): - RNSentry (4.6.0):
- React-Core - React-Core
- Sentry (= 7.22.0) - Sentry (= 7.27.0)
- RNShare (7.7.0): - RNShare (7.7.0):
- React-Core - React-Core
- RNSVG (12.4.3): - RNSVG (12.4.3):
@@ -532,9 +532,9 @@ PODS:
- SDWebImageWebPCoder (0.8.4): - SDWebImageWebPCoder (0.8.4):
- libwebp (~> 1.0) - libwebp (~> 1.0)
- SDWebImage/Core (~> 5.10) - SDWebImage/Core (~> 5.10)
- Sentry (7.22.0): - Sentry (7.27.0):
- Sentry/Core (= 7.22.0) - Sentry/Core (= 7.27.0)
- Sentry/Core (7.22.0) - Sentry/Core (7.27.0)
- simdjson (1.0.0) - simdjson (1.0.0)
- SocketRocket (0.6.0) - SocketRocket (0.6.0)
- Starscream (4.0.4) - Starscream (4.0.4)
@@ -952,14 +952,14 @@ SPEC CHECKSUMS:
RNReanimated: 2cf7451318bb9cc430abeec8d67693f9cf4e039c RNReanimated: 2cf7451318bb9cc430abeec8d67693f9cf4e039c
RNRudderSdk: 14c176adb1557f3832cb385fcd14250f76a7e268 RNRudderSdk: 14c176adb1557f3832cb385fcd14250f76a7e268
RNScreens: 4a1af06327774490d97342c00aee0c2bafb497b7 RNScreens: 4a1af06327774490d97342c00aee0c2bafb497b7
RNSentry: f03937a2cf86c7029ba31fbbf5618c55d223cd62 RNSentry: 73f65d6ef3e5a313d6bb225e2c92c84be2e17921
RNShare: ab582e93876d9df333a531390c658c31b50a767d RNShare: ab582e93876d9df333a531390c658c31b50a767d
RNSVG: f3b60aeeaa81960e2e0536c3a9eef50b667ef3a9 RNSVG: f3b60aeeaa81960e2e0536c3a9eef50b667ef3a9
RNVectorIcons: fcc2f6cb32f5735b586e66d14103a74ce6ad61f8 RNVectorIcons: fcc2f6cb32f5735b586e66d14103a74ce6ad61f8
Rudder: 59634aec6dd26a4672f74f40fc45bc17f91af3f0 Rudder: 59634aec6dd26a4672f74f40fc45bc17f91af3f0
SDWebImage: 53179a2dba77246efa8a9b85f5c5b21f8f43e38f SDWebImage: 53179a2dba77246efa8a9b85f5c5b21f8f43e38f
SDWebImageWebPCoder: f93010f3f6c031e2f8fb3081ca4ee6966c539815 SDWebImageWebPCoder: f93010f3f6c031e2f8fb3081ca4ee6966c539815
Sentry: 30b51086ca9aac23337880152e95538f7e177f7f Sentry: 026b36fdc09531604db9279e55f047fe652e3f4a
simdjson: c96317b3a50dff3468a42f586ab7ed22c6ab2fd9 simdjson: c96317b3a50dff3468a42f586ab7ed22c6ab2fd9
SocketRocket: fccef3f9c5cedea1353a9ef6ada904fde10d6608 SocketRocket: fccef3f9c5cedea1353a9ef6ada904fde10d6608
Starscream: 5178aed56b316f13fa3bc55694e583d35dd414d9 Starscream: 5178aed56b316f13fa3bc55694e583d35dd414d9

View File

@@ -107,6 +107,7 @@ interface ClientConfig {
ExperimentalEnableClickToReply: string; ExperimentalEnableClickToReply: string;
ExperimentalEnableDefaultChannelLeaveJoinMessages: string; ExperimentalEnableDefaultChannelLeaveJoinMessages: string;
ExperimentalEnablePostMetadata: string; ExperimentalEnablePostMetadata: string;
ExperimentalEnableVoiceMessages: string;
ExperimentalGroupUnreadChannels: string; ExperimentalGroupUnreadChannels: string;
ExperimentalHideTownSquareinLHS: string; ExperimentalHideTownSquareinLHS: string;
ExperimentalNormalizeMarkdownLinks: string; ExperimentalNormalizeMarkdownLinks: string;
@@ -120,6 +121,7 @@ interface ClientConfig {
FeatureFlagCollapsedThreads?: string; FeatureFlagCollapsedThreads?: string;
FeatureFlagGraphQL?: string; FeatureFlagGraphQL?: string;
FeatureFlagPostPriority?: string; FeatureFlagPostPriority?: string;
FeatureFlagEnableVoiceMessages?: string;
GfycatApiKey: string; GfycatApiKey: string;
GfycatApiSecret: string; GfycatApiSecret: string;
GoogleDeveloperKey: string; GoogleDeveloperKey: string;

View File

@@ -22,6 +22,7 @@ type FileInfo = {
uri?: string; uri?: string;
user_id: string; user_id: string;
width: number; width: number;
is_voice_recording?: boolean;
}; };
type FilesState = { type FilesState = {