feat: New message composer (#5205)
This commit is contained in:
parent
8dcc939287
commit
7bf7111cfa
|
@ -23,13 +23,13 @@ const getStories = () => {
|
|||
require("../app/containers/BackgroundContainer/index.stories.tsx"),
|
||||
require("../app/containers/Button/Button.stories.tsx"),
|
||||
require("../app/containers/Chip/Chip.stories.tsx"),
|
||||
require("../app/containers/CollapsibleText/CollapsibleText.stories.tsx"),
|
||||
require("../app/containers/HeaderButton/HeaderButtons.stories.tsx"),
|
||||
require("../app/containers/List/List.stories.tsx"),
|
||||
require("../app/containers/LoginServices/LoginServices.stories.tsx"),
|
||||
require("../app/containers/markdown/Markdown.stories.tsx"),
|
||||
require("../app/containers/markdown/new/NewMarkdown.stories.tsx"),
|
||||
require("../app/containers/message/Components/CollapsibleQuote/CollapsibleQuote.stories.tsx"),
|
||||
require("../app/containers/CollapsibleText/CollapsibleText.stories.tsx"),
|
||||
require("../app/containers/message/Message.stories.tsx"),
|
||||
require("../app/containers/ReactionsList/ReactionsList.stories.tsx"),
|
||||
require("../app/containers/RoomHeader/RoomHeader.stories.tsx"),
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
export const RectButton = ({ children }) => children;
|
||||
export const State = () => 'View';
|
||||
export const LongPressGestureHandler = ({ children }) => children;
|
||||
export const BorderlessButton = ({ children }) => children;
|
||||
export const PanGestureHandler = ({ children }) => children;
|
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
openPicker: jest.fn().mockImplementation(() => Promise.resolve())
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
export class MMKVLoader {
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor() {
|
||||
console.log('MMKVLoader constructor mock');
|
||||
// console.log('MMKVLoader constructor mock');
|
||||
}
|
||||
|
||||
setProcessingMode = jest.fn().mockImplementation(() => ({
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
export default {
|
||||
NavigationActions: () => {}
|
||||
};
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -58,15 +58,13 @@ const App = memo(({ root, isMasterDetail }: { root: string; isMasterDetail: bool
|
|||
}}
|
||||
>
|
||||
<Stack.Navigator screenOptions={{ headerShown: false, animationEnabled: false }}>
|
||||
<>
|
||||
{root === RootEnum.ROOT_LOADING ? <Stack.Screen name='AuthLoading' component={AuthLoadingView} /> : null}
|
||||
{root === RootEnum.ROOT_OUTSIDE ? <Stack.Screen name='OutsideStack' component={OutsideStack} /> : null}
|
||||
{root === RootEnum.ROOT_INSIDE && isMasterDetail ? (
|
||||
<Stack.Screen name='MasterDetailStack' component={MasterDetailStack} />
|
||||
) : null}
|
||||
{root === RootEnum.ROOT_INSIDE && !isMasterDetail ? <Stack.Screen name='InsideStack' component={InsideStack} /> : null}
|
||||
{root === RootEnum.ROOT_SET_USERNAME ? <Stack.Screen name='SetUsernameStack' component={SetUsernameStack} /> : null}
|
||||
</>
|
||||
{root === RootEnum.ROOT_LOADING ? <Stack.Screen name='AuthLoading' component={AuthLoadingView} /> : null}
|
||||
{root === RootEnum.ROOT_OUTSIDE ? <Stack.Screen name='OutsideStack' component={OutsideStack} /> : null}
|
||||
{root === RootEnum.ROOT_INSIDE && isMasterDetail ? (
|
||||
<Stack.Screen name='MasterDetailStack' component={MasterDetailStack} />
|
||||
) : null}
|
||||
{root === RootEnum.ROOT_INSIDE && !isMasterDetail ? <Stack.Screen name='InsideStack' component={InsideStack} /> : null}
|
||||
{root === RootEnum.ROOT_SET_USERNAME ? <Stack.Screen name='SetUsernameStack' component={SetUsernameStack} /> : null}
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
|
|
|
@ -162,7 +162,7 @@ const AudioPlayer = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.audioContainer, { backgroundColor: colors.surfaceTint, borderColor: colors.strokeExtraLight }]}>
|
||||
<View style={[styles.audioContainer, { backgroundColor: colors.surfaceLight, borderColor: colors.strokeExtraLight }]}>
|
||||
<PlayButton disabled={disabled} audioState={audioState} onPress={onPress} />
|
||||
<Seek currentTime={currentTime} duration={duration} loaded={!disabled && isDownloaded} onChangeTime={setPosition} />
|
||||
{audioState === 'playing' || focused ? <PlaybackSpeed /> : null}
|
||||
|
|
|
@ -44,10 +44,10 @@ export const EmojiSearch = ({ onBlur, onChangeText, bottomSheet }: IEmojiSearchB
|
|||
textContentType='none'
|
||||
blurOnSubmit
|
||||
placeholder={I18n.t('Search_emoji')}
|
||||
placeholderTextColor={colors.auxiliaryText}
|
||||
placeholderTextColor={colors.fontAnnotation}
|
||||
underlineColorAndroid='transparent'
|
||||
onChangeText={handleTextChange}
|
||||
inputStyle={[styles.input, { backgroundColor: colors.textInputSecondaryBackground }]}
|
||||
inputStyle={[styles.input, { backgroundColor: colors.surfaceNeutral }]}
|
||||
containerStyle={styles.textInputContainer}
|
||||
value={searchText}
|
||||
onClearInput={() => handleTextChange('')}
|
||||
|
|
|
@ -5,17 +5,19 @@ import { useTheme } from '../../theme';
|
|||
import { CustomIcon } from '../CustomIcon';
|
||||
import styles from './styles';
|
||||
import { IFooterProps } from './interfaces';
|
||||
|
||||
const BUTTON_HIT_SLOP = { top: 15, right: 15, bottom: 15, left: 15 };
|
||||
import { isIOS } from '../../lib/methods/helpers';
|
||||
|
||||
const Footer = ({ onSearchPressed, onBackspacePressed }: IFooterProps): React.ReactElement => {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={[styles.footerContainer, { borderTopColor: colors.borderColor }]}>
|
||||
<View style={[styles.footerContainer, { borderTopColor: colors.strokeExtraLight }]}>
|
||||
<Pressable
|
||||
onPress={onSearchPressed}
|
||||
hitSlop={BUTTON_HIT_SLOP}
|
||||
style={({ pressed }) => [styles.footerButtonsContainer, { opacity: pressed ? 0.7 : 1 }]}
|
||||
android_ripple={{ color: colors.buttonBackgroundSecondaryPress }}
|
||||
style={({ pressed }) => [
|
||||
styles.footerButtonsContainer,
|
||||
{ backgroundColor: isIOS && pressed ? colors.buttonBackgroundSecondaryPress : 'transparent' }
|
||||
]}
|
||||
testID='emoji-picker-search'
|
||||
>
|
||||
<CustomIcon color={colors.auxiliaryTintColor} size={24} name='search' />
|
||||
|
@ -23,8 +25,11 @@ const Footer = ({ onSearchPressed, onBackspacePressed }: IFooterProps): React.Re
|
|||
|
||||
<Pressable
|
||||
onPress={onBackspacePressed}
|
||||
hitSlop={BUTTON_HIT_SLOP}
|
||||
style={({ pressed }) => [styles.footerButtonsContainer, { opacity: pressed ? 0.7 : 1 }]}
|
||||
android_ripple={{ color: colors.buttonBackgroundSecondaryPress }}
|
||||
style={({ pressed }) => [
|
||||
styles.footerButtonsContainer,
|
||||
{ backgroundColor: isIOS && pressed ? colors.buttonBackgroundSecondaryPress : 'transparent' }
|
||||
]}
|
||||
testID='emoji-picker-backspace'
|
||||
>
|
||||
<CustomIcon color={colors.auxiliaryTintColor} size={24} name='backspace' />
|
||||
|
|
|
@ -14,11 +14,11 @@ export const PressableEmoji = ({ emoji, onPress }: { emoji: IEmoji; onPress: (em
|
|||
key={typeof emoji === 'string' ? emoji : emoji.name}
|
||||
onPress={() => onPress(emoji)}
|
||||
testID={`emoji-${typeof emoji === 'string' ? emoji : emoji.name}`}
|
||||
android_ripple={{ color: colors.bannerBackground, borderless: true, radius: EMOJI_BUTTON_SIZE / 2 }}
|
||||
android_ripple={{ color: colors.buttonBackgroundSecondaryPress, borderless: true, radius: EMOJI_BUTTON_SIZE / 2 }}
|
||||
style={({ pressed }: { pressed: boolean }) => [
|
||||
styles.emojiButton,
|
||||
{
|
||||
backgroundColor: isIOS && pressed ? colors.bannerBackground : 'transparent'
|
||||
backgroundColor: isIOS && pressed ? colors.buttonBackgroundSecondaryPress : 'transparent'
|
||||
}
|
||||
]}
|
||||
>
|
||||
|
|
|
@ -17,20 +17,20 @@ const TabBar = ({ activeTab, tabs, goToPage }: ITabBarProps): React.ReactElement
|
|||
key={tab}
|
||||
onPress={() => goToPage?.(i)}
|
||||
testID={`emoji-picker-tab-${tab}`}
|
||||
android_ripple={{ color: colors.bannerBackground }}
|
||||
android_ripple={{ color: colors.buttonBackgroundSecondaryPress }}
|
||||
style={({ pressed }: { pressed: boolean }) => [
|
||||
styles.tab,
|
||||
{
|
||||
backgroundColor: isIOS && pressed ? colors.bannerBackground : 'transparent'
|
||||
backgroundColor: isIOS && pressed ? colors.buttonBackgroundSecondaryPress : 'transparent'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<CustomIcon name={tab} size={24} color={activeTab === i ? colors.tintColor : colors.auxiliaryTintColor} />
|
||||
<CustomIcon name={tab} size={24} color={activeTab === i ? colors.strokeHighlight : colors.fontSecondaryInfo} />
|
||||
<View
|
||||
style={
|
||||
activeTab === i
|
||||
? [styles.activeTabLine, { backgroundColor: colors.tintColor }]
|
||||
: [styles.tabLine, { backgroundColor: colors.borderColor }]
|
||||
? [styles.activeTabLine, { backgroundColor: colors.strokeHighlight }]
|
||||
: [styles.tabLine, { backgroundColor: colors.strokeExtraLight }]
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
|
|
|
@ -81,7 +81,7 @@ const EmojiPicker = ({
|
|||
keyboardShouldPersistTaps: 'always',
|
||||
keyboardDismissMode: 'none'
|
||||
}}
|
||||
style={{ backgroundColor: colors.messageboxBackground }}
|
||||
style={{ backgroundColor: colors.surfaceLight }}
|
||||
>
|
||||
{categories.tabs.map((tab: any, i) => renderCategory(tab.category, i, tab.tabLabel))}
|
||||
</ScrollableTabView>
|
||||
|
|
|
@ -24,10 +24,11 @@ export interface IMessageActionsProps {
|
|||
room: TSubscriptionModel;
|
||||
tmid?: string;
|
||||
user: Pick<ILoggedUser, 'id'>;
|
||||
editInit: (message: TAnyMessageModel) => void;
|
||||
reactionInit: (message: TAnyMessageModel) => void;
|
||||
editInit: (messageId: string) => void;
|
||||
reactionInit: (messageId: string) => void;
|
||||
onReactionPress: (shortname: IEmoji, messageId: string) => void;
|
||||
replyInit: (message: TAnyMessageModel, mention: boolean) => void;
|
||||
replyInit: (messageId: string) => void;
|
||||
quoteInit: (messageId: string) => void;
|
||||
jumpToMessage?: (messageUrl?: string, isFromReply?: boolean) => Promise<void>;
|
||||
isMasterDetail: boolean;
|
||||
isReadOnly: boolean;
|
||||
|
@ -63,6 +64,7 @@ const MessageActions = React.memo(
|
|||
reactionInit,
|
||||
onReactionPress,
|
||||
replyInit,
|
||||
quoteInit,
|
||||
jumpToMessage,
|
||||
isReadOnly,
|
||||
Message_AllowDeleting,
|
||||
|
@ -180,14 +182,14 @@ const MessageActions = React.memo(
|
|||
|
||||
const getPermalink = (message: TAnyMessageModel) => getPermalinkMessage(message);
|
||||
|
||||
const handleReply = (message: TAnyMessageModel) => {
|
||||
const handleReply = (messageId: string) => {
|
||||
logEvent(events.ROOM_MSG_ACTION_REPLY);
|
||||
replyInit(message, true);
|
||||
replyInit(messageId);
|
||||
};
|
||||
|
||||
const handleEdit = (message: TAnyMessageModel) => {
|
||||
const handleEdit = (messageId: string) => {
|
||||
logEvent(events.ROOM_MSG_ACTION_EDIT);
|
||||
editInit(message);
|
||||
editInit(messageId);
|
||||
};
|
||||
|
||||
const handleCreateDiscussion = (message: TAnyMessageModel) => {
|
||||
|
@ -263,9 +265,9 @@ const MessageActions = React.memo(
|
|||
}
|
||||
};
|
||||
|
||||
const handleQuote = (message: TAnyMessageModel) => {
|
||||
const handleQuote = (messageId: string) => {
|
||||
logEvent(events.ROOM_MSG_ACTION_QUOTE);
|
||||
replyInit(message, false);
|
||||
quoteInit(messageId);
|
||||
};
|
||||
|
||||
const handleReplyInDM = async (message: TAnyMessageModel) => {
|
||||
|
@ -278,7 +280,7 @@ const MessageActions = React.memo(
|
|||
name: getRoomTitle(room),
|
||||
t: room.t,
|
||||
roomUserId: getUidDirectMessage(room),
|
||||
replyInDM: message
|
||||
messageId: message.id
|
||||
};
|
||||
Navigation.replace('RoomView', params);
|
||||
}
|
||||
|
@ -311,7 +313,7 @@ const MessageActions = React.memo(
|
|||
if (emoji) {
|
||||
onReactionPress(emoji, message.id);
|
||||
} else {
|
||||
setTimeout(() => reactionInit(message), ACTION_SHEET_ANIMATION_DURATION);
|
||||
setTimeout(() => reactionInit(message.id), ACTION_SHEET_ANIMATION_DURATION);
|
||||
}
|
||||
hideActionSheet();
|
||||
};
|
||||
|
@ -391,7 +393,7 @@ const MessageActions = React.memo(
|
|||
options.push({
|
||||
title: I18n.t('Quote'),
|
||||
icon: 'quote',
|
||||
onPress: () => handleQuote(message)
|
||||
onPress: () => handleQuote(message.id)
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -400,7 +402,7 @@ const MessageActions = React.memo(
|
|||
options.push({
|
||||
title: I18n.t('Reply_in_Thread'),
|
||||
icon: 'threads',
|
||||
onPress: () => handleReply(message)
|
||||
onPress: () => handleReply(message.id)
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -459,7 +461,7 @@ const MessageActions = React.memo(
|
|||
options.push({
|
||||
title: I18n.t('Edit'),
|
||||
icon: 'edit',
|
||||
onPress: () => handleEdit(message),
|
||||
onPress: () => handleEdit(message.id),
|
||||
enabled: isEditAllowed
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
import FastImage from 'react-native-fast-image';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { TouchableOpacity } from 'react-native';
|
||||
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { CustomIcon } from '../../CustomIcon';
|
||||
import { useTheme } from '../../../theme';
|
||||
import ActivityIndicator from '../../ActivityIndicator';
|
||||
import MessageboxContext from '../Context';
|
||||
import styles from '../styles';
|
||||
|
||||
interface IMessageBoxCommandsPreviewItem {
|
||||
item: {
|
||||
type: string;
|
||||
id: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Item = ({ item }: IMessageBoxCommandsPreviewItem) => {
|
||||
const context = useContext(MessageboxContext);
|
||||
const { onPressCommandPreview } = context;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.commandPreview}
|
||||
onPress={() => onPressCommandPreview(item)}
|
||||
testID={`command-preview-item${item.id}`}
|
||||
>
|
||||
{item.type === 'image' ? (
|
||||
<FastImage
|
||||
style={styles.commandPreviewImage}
|
||||
source={{ uri: item.value }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
onLoadStart={() => setLoading(true)}
|
||||
onLoad={() => setLoading(false)}
|
||||
>
|
||||
{loading ? <ActivityIndicator /> : null}
|
||||
</FastImage>
|
||||
) : (
|
||||
<CustomIcon name='attach' size={36} color={themes[theme].actionTintColor} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default Item;
|
|
@ -1,48 +0,0 @@
|
|||
import { dequal } from 'dequal';
|
||||
import React from 'react';
|
||||
import { FlatList } from 'react-native';
|
||||
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { IPreviewItem } from '../../../definitions';
|
||||
import { useTheme } from '../../../theme';
|
||||
import styles from '../styles';
|
||||
import Item from './Item';
|
||||
|
||||
interface IMessageBoxCommandsPreview {
|
||||
commandPreview: IPreviewItem[];
|
||||
showCommandPreview: boolean;
|
||||
}
|
||||
|
||||
const CommandsPreview = React.memo(
|
||||
({ commandPreview, showCommandPreview }: IMessageBoxCommandsPreview) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!showCommandPreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
testID='commandbox-container'
|
||||
style={[styles.mentionList, { backgroundColor: themes[theme].messageboxBackground }]}
|
||||
data={commandPreview}
|
||||
renderItem={({ item }) => <Item item={item} />}
|
||||
keyExtractor={(item: any) => item.id}
|
||||
keyboardShouldPersistTaps='always'
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.showCommandPreview !== nextProps.showCommandPreview) {
|
||||
return false;
|
||||
}
|
||||
if (!dequal(prevProps.commandPreview, nextProps.commandPreview)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
export default CommandsPreview;
|
|
@ -1,4 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
const MessageboxContext = React.createContext<any>(null);
|
||||
export default MessageboxContext;
|
|
@ -1,20 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { CancelEditingButton, ToggleEmojiButton } from './buttons';
|
||||
|
||||
interface IMessageBoxLeftButtons {
|
||||
showEmojiKeyboard: boolean;
|
||||
openEmoji(): void;
|
||||
closeEmoji(): void;
|
||||
editing: boolean;
|
||||
editCancel(): void;
|
||||
}
|
||||
|
||||
const LeftButtons = React.memo(({ showEmojiKeyboard, editing, editCancel, openEmoji, closeEmoji }: IMessageBoxLeftButtons) => {
|
||||
if (editing) {
|
||||
return <CancelEditingButton onPress={editCancel} />;
|
||||
}
|
||||
return <ToggleEmojiButton show={showEmojiKeyboard} open={openEmoji} close={closeEmoji} />;
|
||||
});
|
||||
|
||||
export default LeftButtons;
|
|
@ -1,37 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Text, TouchableOpacity } from 'react-native';
|
||||
|
||||
import styles from '../styles';
|
||||
import I18n from '../../../i18n';
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface IMessageBoxFixedMentionItem {
|
||||
item: {
|
||||
username: string;
|
||||
};
|
||||
onPress: Function;
|
||||
}
|
||||
|
||||
const FixedMentionItem = ({ item, onPress }: IMessageBoxFixedMentionItem) => {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.mentionItem,
|
||||
{
|
||||
backgroundColor: themes[theme].auxiliaryBackground,
|
||||
borderTopColor: themes[theme].separatorColor
|
||||
}
|
||||
]}
|
||||
onPress={() => onPress(item)}
|
||||
>
|
||||
<Text style={[styles.fixedMentionAvatar, { color: themes[theme].titleText }]}>{item.username}</Text>
|
||||
<Text style={[styles.mentionText, { color: themes[theme].titleText }]}>
|
||||
{item.username === 'here' ? I18n.t('Notify_active_in_this_room') : I18n.t('Notify_all_in_this_room')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default FixedMentionItem;
|
|
@ -1,20 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
import { IEmoji } from '../../../definitions/IEmoji';
|
||||
import shortnameToUnicode from '../../../lib/methods/helpers/shortnameToUnicode';
|
||||
import CustomEmoji from '../../EmojiPicker/CustomEmoji';
|
||||
import styles from '../styles';
|
||||
|
||||
interface IMessageBoxMentionEmoji {
|
||||
item: IEmoji;
|
||||
}
|
||||
|
||||
const MentionEmoji = ({ item }: IMessageBoxMentionEmoji) => {
|
||||
if (typeof item === 'string') {
|
||||
return <Text style={styles.mentionItemEmoji}>{shortnameToUnicode(`:${item}:`)}</Text>;
|
||||
}
|
||||
return <CustomEmoji style={styles.mentionItemCustomEmoji} emoji={item} />;
|
||||
};
|
||||
|
||||
export default MentionEmoji;
|
|
@ -1,49 +0,0 @@
|
|||
import React, { useContext } from 'react';
|
||||
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { themes } from '../../../lib/constants';
|
||||
import I18n from '../../../i18n';
|
||||
import { CustomIcon } from '../../CustomIcon';
|
||||
import { useTheme } from '../../../theme';
|
||||
import sharedStyles from '../../../views/Styles';
|
||||
import MessageboxContext from '../Context';
|
||||
import styles from '../styles';
|
||||
import { MENTIONS_TRACKING_TYPE_CANNED } from '../constants';
|
||||
|
||||
interface IMentionHeaderList {
|
||||
trackingType: string;
|
||||
hasMentions: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const MentionHeaderList = ({ trackingType, hasMentions, loading }: IMentionHeaderList) => {
|
||||
const { theme } = useTheme();
|
||||
const context = useContext(MessageboxContext);
|
||||
const { onPressNoMatchCanned } = context;
|
||||
|
||||
if (trackingType === MENTIONS_TRACKING_TYPE_CANNED) {
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.wrapMentionHeaderListRow}>
|
||||
<ActivityIndicator style={styles.loadingPaddingHeader} size='small' />
|
||||
<Text style={[styles.mentionHeaderList, { color: themes[theme].auxiliaryText }]}>{I18n.t('Searching')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasMentions) {
|
||||
return (
|
||||
<TouchableOpacity style={[styles.wrapMentionHeaderListRow, styles.mentionNoMatchHeader]} onPress={onPressNoMatchCanned}>
|
||||
<Text style={[styles.mentionHeaderListNoMatchFound, { color: themes[theme].auxiliaryText }]}>
|
||||
{I18n.t('No_match_found')} <Text style={sharedStyles.textSemibold}>{I18n.t('Check_canned_responses')}</Text>
|
||||
</Text>
|
||||
<CustomIcon name='chevron-right' size={24} color={themes[theme].auxiliaryText} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default MentionHeaderList;
|
|
@ -1,107 +0,0 @@
|
|||
import React, { useContext } from 'react';
|
||||
import { Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { IEmoji } from '../../../definitions/IEmoji';
|
||||
import { useTheme } from '../../../theme';
|
||||
import Avatar from '../../Avatar';
|
||||
import { MENTIONS_TRACKING_TYPE_CANNED, MENTIONS_TRACKING_TYPE_COMMANDS, MENTIONS_TRACKING_TYPE_EMOJIS } from '../constants';
|
||||
import MessageboxContext from '../Context';
|
||||
import styles from '../styles';
|
||||
import FixedMentionItem from './FixedMentionItem';
|
||||
import MentionEmoji from './MentionEmoji';
|
||||
|
||||
interface IMessageBoxMentionItem {
|
||||
item: {
|
||||
name: string;
|
||||
command: string;
|
||||
username: string;
|
||||
t: string;
|
||||
id: string;
|
||||
shortcut: string;
|
||||
text: string;
|
||||
} & IEmoji;
|
||||
trackingType: string;
|
||||
}
|
||||
|
||||
const MentionItemContent = React.memo(({ trackingType, item }: IMessageBoxMentionItem) => {
|
||||
const { theme } = useTheme();
|
||||
switch (trackingType) {
|
||||
case MENTIONS_TRACKING_TYPE_EMOJIS:
|
||||
return (
|
||||
<>
|
||||
<MentionEmoji item={item} />
|
||||
<Text style={[styles.mentionText, { color: themes[theme].titleText }]}>:{item.name || item}:</Text>
|
||||
</>
|
||||
);
|
||||
case MENTIONS_TRACKING_TYPE_COMMANDS:
|
||||
return (
|
||||
<>
|
||||
<View style={[styles.slash, { backgroundColor: themes[theme].borderColor }]}>
|
||||
<Text style={{ color: themes[theme].tintColor }}>/</Text>
|
||||
</View>
|
||||
<Text style={[styles.mentionText, { color: themes[theme].titleText }]}>{item.id}</Text>
|
||||
</>
|
||||
);
|
||||
case MENTIONS_TRACKING_TYPE_CANNED:
|
||||
return (
|
||||
<>
|
||||
<Text style={[styles.cannedItem, { color: themes[theme].titleText }]}>!{item.shortcut}</Text>
|
||||
<Text numberOfLines={1} style={[styles.cannedMentionText, { color: themes[theme].auxiliaryTintColor }]}>
|
||||
{item.text}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<Avatar style={styles.avatar} text={item.username || item.name} size={30} type={item.t} />
|
||||
<Text style={[styles.mentionText, { color: themes[theme].titleText }]}>{item.username || item.name || item}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const MentionItem = ({ item, trackingType }: IMessageBoxMentionItem) => {
|
||||
const context = useContext(MessageboxContext);
|
||||
const { theme } = useTheme();
|
||||
const { onPressMention } = context;
|
||||
|
||||
const defineTestID = (type: string) => {
|
||||
switch (type) {
|
||||
case MENTIONS_TRACKING_TYPE_EMOJIS:
|
||||
return `mention-item-${item.name || item}`;
|
||||
case MENTIONS_TRACKING_TYPE_COMMANDS:
|
||||
return `mention-item-${item.command || item}`;
|
||||
case MENTIONS_TRACKING_TYPE_CANNED:
|
||||
return `mention-item-${item.shortcut || item}`;
|
||||
default:
|
||||
return `mention-item-${item.username || item.name || item}`;
|
||||
}
|
||||
};
|
||||
|
||||
const testID = defineTestID(trackingType);
|
||||
|
||||
if (item.username === 'all' || item.username === 'here') {
|
||||
return <FixedMentionItem item={item} onPress={onPressMention} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.mentionItem,
|
||||
{
|
||||
backgroundColor: themes[theme].auxiliaryBackground,
|
||||
borderTopColor: themes[theme].separatorColor
|
||||
}
|
||||
]}
|
||||
onPress={() => onPressMention(item)}
|
||||
testID={testID}
|
||||
>
|
||||
<MentionItemContent item={item} trackingType={trackingType} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export default MentionItem;
|
|
@ -1,55 +0,0 @@
|
|||
import React from 'react';
|
||||
import { FlatList, View } from 'react-native';
|
||||
import { dequal } from 'dequal';
|
||||
|
||||
import MentionHeaderList from './MentionHeaderList';
|
||||
import styles from '../styles';
|
||||
import MentionItem from './MentionItem';
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface IMessageBoxMentions {
|
||||
mentions: any[];
|
||||
trackingType: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const Mentions = React.memo(
|
||||
({ mentions, trackingType, loading }: IMessageBoxMentions) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!trackingType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID='messagebox-container'>
|
||||
<FlatList
|
||||
style={[styles.mentionList, { backgroundColor: themes[theme].auxiliaryBackground }]}
|
||||
ListHeaderComponent={() => (
|
||||
<MentionHeaderList trackingType={trackingType} hasMentions={mentions.length > 0} loading={loading} />
|
||||
)}
|
||||
data={mentions}
|
||||
extraData={mentions}
|
||||
renderItem={({ item }) => <MentionItem item={item} trackingType={trackingType} />}
|
||||
keyExtractor={item => item.rid || item.name || item.command || item.shortcut || item}
|
||||
keyboardShouldPersistTaps='always'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.loading !== nextProps.loading) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.trackingType !== nextProps.trackingType) {
|
||||
return false;
|
||||
}
|
||||
if (!dequal(prevProps.mentions, nextProps.mentions)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
export default Mentions;
|
|
@ -1,255 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
|
||||
import { BorderlessButton } from 'react-native-gesture-handler';
|
||||
import { getInfoAsync } from 'expo-file-system';
|
||||
import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake';
|
||||
|
||||
import styles from './styles';
|
||||
import I18n from '../../i18n';
|
||||
import { themes } from '../../lib/constants';
|
||||
import { CustomIcon } from '../CustomIcon';
|
||||
import { events, logEvent } from '../../lib/methods/helpers/log';
|
||||
import { TSupportedThemes } from '../../theme';
|
||||
|
||||
interface IMessageBoxRecordAudioProps {
|
||||
theme: TSupportedThemes;
|
||||
permissionToUpload: boolean;
|
||||
recordingCallback: Function;
|
||||
onFinish: Function;
|
||||
onStart: Function;
|
||||
}
|
||||
|
||||
const RECORDING_EXTENSION = '.aac';
|
||||
const RECORDING_SETTINGS = {
|
||||
android: {
|
||||
// Settings related to audio encoding.
|
||||
extension: RECORDING_EXTENSION,
|
||||
outputFormat: Audio.RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_AAC_ADTS,
|
||||
audioEncoder: Audio.RECORDING_OPTION_ANDROID_AUDIO_ENCODER_AAC,
|
||||
// Settings related to audio quality.
|
||||
sampleRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.android.sampleRate,
|
||||
numberOfChannels: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.android.numberOfChannels,
|
||||
bitRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.android.bitRate
|
||||
},
|
||||
ios: {
|
||||
// Settings related to audio encoding.
|
||||
extension: RECORDING_EXTENSION,
|
||||
audioQuality: Audio.RECORDING_OPTION_IOS_AUDIO_QUALITY_MEDIUM,
|
||||
outputFormat: Audio.RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4AAC,
|
||||
// Settings related to audio quality.
|
||||
sampleRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.ios.sampleRate,
|
||||
numberOfChannels: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.ios.numberOfChannels,
|
||||
bitRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.ios.bitRate
|
||||
},
|
||||
web: {}
|
||||
};
|
||||
|
||||
const RECORDING_MODE = {
|
||||
allowsRecordingIOS: true,
|
||||
playsInSilentModeIOS: true,
|
||||
staysActiveInBackground: true,
|
||||
shouldDuckAndroid: true,
|
||||
playThroughEarpieceAndroid: false,
|
||||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix
|
||||
};
|
||||
|
||||
const formatTime = function (time: number) {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = time % 60;
|
||||
const min = minutes < 10 ? `0${minutes}` : minutes;
|
||||
const sec = seconds < 10 ? `0${seconds}` : seconds;
|
||||
return `${min}:${sec}`;
|
||||
};
|
||||
|
||||
export default class RecordAudio extends React.PureComponent<IMessageBoxRecordAudioProps, any> {
|
||||
private isRecorderBusy: boolean;
|
||||
private recording!: Audio.Recording;
|
||||
private LastDuration: number;
|
||||
|
||||
constructor(props: IMessageBoxRecordAudioProps) {
|
||||
super(props);
|
||||
this.isRecorderBusy = false;
|
||||
this.LastDuration = 0;
|
||||
this.state = {
|
||||
isRecording: false,
|
||||
isRecorderActive: false,
|
||||
recordingDurationMillis: 0
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { recordingCallback } = this.props;
|
||||
const { isRecorderActive } = this.state;
|
||||
|
||||
recordingCallback(isRecorderActive);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.recording) {
|
||||
this.cancelRecordingAudio();
|
||||
}
|
||||
}
|
||||
|
||||
get duration() {
|
||||
const { recordingDurationMillis } = this.state;
|
||||
return formatTime(Math.floor(recordingDurationMillis / 1000));
|
||||
}
|
||||
|
||||
get GetLastDuration() {
|
||||
return formatTime(Math.floor(this.LastDuration / 1000));
|
||||
}
|
||||
|
||||
isRecordingPermissionGranted = async () => {
|
||||
try {
|
||||
const permission = await Audio.getPermissionsAsync();
|
||||
if (permission.status === 'granted') {
|
||||
return true;
|
||||
}
|
||||
await Audio.requestPermissionsAsync();
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
onRecordingStatusUpdate = (status: Audio.RecordingStatus) => {
|
||||
this.setState({
|
||||
isRecording: status.isRecording,
|
||||
recordingDurationMillis: status.durationMillis
|
||||
});
|
||||
this.LastDuration = status.durationMillis;
|
||||
};
|
||||
|
||||
startRecordingAudio = async () => {
|
||||
const { onStart } = this.props;
|
||||
onStart();
|
||||
|
||||
logEvent(events.ROOM_AUDIO_RECORD);
|
||||
if (!this.isRecorderBusy) {
|
||||
this.isRecorderBusy = true;
|
||||
this.LastDuration = 0;
|
||||
try {
|
||||
const canRecord = await this.isRecordingPermissionGranted();
|
||||
if (canRecord) {
|
||||
await Audio.setAudioModeAsync(RECORDING_MODE);
|
||||
|
||||
this.setState({ isRecorderActive: true });
|
||||
this.recording = new Audio.Recording();
|
||||
await this.recording.prepareToRecordAsync(RECORDING_SETTINGS);
|
||||
this.recording.setOnRecordingStatusUpdate(this.onRecordingStatusUpdate);
|
||||
|
||||
await this.recording.startAsync();
|
||||
activateKeepAwake();
|
||||
} else {
|
||||
await Audio.requestPermissionsAsync();
|
||||
}
|
||||
} catch (error) {
|
||||
logEvent(events.ROOM_AUDIO_RECORD_F);
|
||||
}
|
||||
this.isRecorderBusy = false;
|
||||
}
|
||||
};
|
||||
|
||||
finishRecordingAudio = async () => {
|
||||
logEvent(events.ROOM_AUDIO_FINISH);
|
||||
if (!this.isRecorderBusy) {
|
||||
const { onFinish } = this.props;
|
||||
|
||||
this.isRecorderBusy = true;
|
||||
try {
|
||||
await this.recording.stopAndUnloadAsync();
|
||||
|
||||
const fileURI = this.recording.getURI();
|
||||
const fileData = await getInfoAsync(fileURI as string);
|
||||
const fileInfo = {
|
||||
name: `${Date.now()}.aac`,
|
||||
mime: 'audio/aac',
|
||||
type: 'audio/aac',
|
||||
store: 'Uploads',
|
||||
path: fileURI,
|
||||
size: fileData.exists ? fileData.size : null
|
||||
};
|
||||
|
||||
onFinish(fileInfo);
|
||||
} catch (error) {
|
||||
logEvent(events.ROOM_AUDIO_FINISH_F);
|
||||
}
|
||||
this.setState({ isRecording: false, isRecorderActive: false, recordingDurationMillis: 0 });
|
||||
deactivateKeepAwake();
|
||||
this.isRecorderBusy = false;
|
||||
}
|
||||
};
|
||||
|
||||
cancelRecordingAudio = async () => {
|
||||
logEvent(events.ROOM_AUDIO_CANCEL);
|
||||
if (!this.isRecorderBusy) {
|
||||
this.isRecorderBusy = true;
|
||||
try {
|
||||
await this.recording.stopAndUnloadAsync();
|
||||
} catch (error) {
|
||||
logEvent(events.ROOM_AUDIO_CANCEL_F);
|
||||
}
|
||||
this.setState({ isRecording: false, isRecorderActive: false, recordingDurationMillis: 0 });
|
||||
deactivateKeepAwake();
|
||||
this.isRecorderBusy = false;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { theme, permissionToUpload } = this.props;
|
||||
const { isRecording, isRecorderActive } = this.state;
|
||||
if (!permissionToUpload) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecording && !isRecorderActive) {
|
||||
return (
|
||||
<BorderlessButton onPress={this.startRecordingAudio} style={styles.actionButton} testID='messagebox-send-audio'>
|
||||
<View accessible accessibilityLabel={I18n.t('Send_audio_message')} accessibilityRole='button'>
|
||||
<CustomIcon name='microphone' size={24} color={themes[theme].auxiliaryTintColor} />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecording && isRecorderActive) {
|
||||
return (
|
||||
<View style={styles.recordingContent}>
|
||||
<View style={styles.textArea}>
|
||||
<BorderlessButton onPress={this.cancelRecordingAudio} style={styles.actionButton}>
|
||||
<View accessible accessibilityLabel={I18n.t('Cancel_recording')} accessibilityRole='button'>
|
||||
<CustomIcon size={24} color={themes[theme].dangerColor} name='delete' />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
<Text style={[styles.recordingDurationText, { color: themes[theme].titleText }]}>{this.GetLastDuration}</Text>
|
||||
</View>
|
||||
<BorderlessButton onPress={this.finishRecordingAudio} style={styles.actionButton}>
|
||||
<View accessible accessibilityLabel={I18n.t('Finish_recording')} accessibilityRole='button'>
|
||||
<CustomIcon size={24} color={themes[theme].tintColor} name='send-filled' />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.recordingContent}>
|
||||
<View style={styles.textArea}>
|
||||
<BorderlessButton onPress={this.cancelRecordingAudio} style={styles.actionButton}>
|
||||
<View accessible accessibilityLabel={I18n.t('Cancel_recording')} accessibilityRole='button'>
|
||||
<CustomIcon size={24} color={themes[theme].dangerColor} name='delete' />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
<Text style={[styles.recordingDurationText, { color: themes[theme].titleText }]}>{this.duration}</Text>
|
||||
<CustomIcon size={24} color={themes[theme].dangerColor} name='record' />
|
||||
</View>
|
||||
<BorderlessButton onPress={this.finishRecordingAudio} style={styles.actionButton}>
|
||||
<View accessible accessibilityLabel={I18n.t('Finish_recording')} accessibilityRole='button'>
|
||||
<CustomIcon size={24} color={themes[theme].tintColor} name='send-filled' />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import moment from 'moment';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { MarkdownPreview } from '../markdown';
|
||||
import { CustomIcon } from '../CustomIcon';
|
||||
import sharedStyles from '../../views/Styles';
|
||||
import { themes } from '../../lib/constants';
|
||||
import { IMessage } from '../../definitions/IMessage';
|
||||
import { useTheme } from '../../theme';
|
||||
import { IApplicationState } from '../../definitions';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingTop: 10
|
||||
},
|
||||
messageContainer: {
|
||||
flex: 1,
|
||||
marginHorizontal: 10,
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 4
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
username: {
|
||||
fontSize: 16,
|
||||
...sharedStyles.textMedium,
|
||||
flexShrink: 1
|
||||
},
|
||||
time: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
marginLeft: 6,
|
||||
...sharedStyles.textRegular,
|
||||
fontWeight: '300'
|
||||
},
|
||||
close: {
|
||||
marginRight: 10
|
||||
}
|
||||
});
|
||||
|
||||
interface IMessageBoxReplyPreview {
|
||||
replying: boolean;
|
||||
message: IMessage;
|
||||
Message_TimeFormat: string;
|
||||
close(): void;
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
getCustomEmoji: Function;
|
||||
useRealName: boolean;
|
||||
}
|
||||
|
||||
const ReplyPreview = React.memo(
|
||||
({ message, Message_TimeFormat, replying, close, useRealName }: IMessageBoxReplyPreview) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!replying) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const time = moment(message.ts).format(Message_TimeFormat);
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: themes[theme].messageboxBackground }]}>
|
||||
<View style={[styles.messageContainer, { backgroundColor: themes[theme].chatComponentBackground }]}>
|
||||
<View style={styles.header}>
|
||||
<Text numberOfLines={1} style={[styles.username, { color: themes[theme].tintColor }]}>
|
||||
{useRealName ? message.u?.name : message.u?.username}
|
||||
</Text>
|
||||
<Text style={[styles.time, { color: themes[theme].auxiliaryText }]}>{time}</Text>
|
||||
</View>
|
||||
<MarkdownPreview msg={message.msg} />
|
||||
</View>
|
||||
<CustomIcon name='close' color={themes[theme].auxiliaryText} size={20} style={styles.close} onPress={close} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
(prevProps: IMessageBoxReplyPreview, nextProps: IMessageBoxReplyPreview) =>
|
||||
prevProps.replying === nextProps.replying && prevProps.message.id === nextProps.message.id
|
||||
);
|
||||
|
||||
const mapStateToProps = (state: IApplicationState) => ({
|
||||
Message_TimeFormat: state.settings.Message_TimeFormat as string,
|
||||
baseUrl: state.server.server,
|
||||
useRealName: state.settings.UI_Use_Real_Name as boolean
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(ReplyPreview);
|
|
@ -1,25 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { isIOS } from '../../lib/methods/helpers';
|
||||
import { ActionsButton, SendButton } from './buttons';
|
||||
import styles from './styles';
|
||||
|
||||
interface IMessageBoxRightButtons {
|
||||
showSend: boolean;
|
||||
submit(): void;
|
||||
showMessageBoxActions(): void;
|
||||
isActionsEnabled: boolean;
|
||||
}
|
||||
|
||||
const RightButtons = React.memo(({ showSend, submit, showMessageBoxActions, isActionsEnabled }: IMessageBoxRightButtons) => {
|
||||
if (showSend) {
|
||||
return <SendButton onPress={submit} />;
|
||||
}
|
||||
if (isActionsEnabled) {
|
||||
return <ActionsButton onPress={showMessageBoxActions} />;
|
||||
}
|
||||
return !isIOS ? <View style={styles.buttonsWhitespace} /> : null;
|
||||
});
|
||||
|
||||
export default RightButtons;
|
|
@ -1,13 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
interface IActionsButton {
|
||||
onPress(): void;
|
||||
}
|
||||
|
||||
const ActionsButton = ({ onPress }: IActionsButton) => (
|
||||
<BaseButton onPress={onPress} testID='messagebox-actions' accessibilityLabel='Message_actions' icon='add' />
|
||||
);
|
||||
|
||||
export default ActionsButton;
|
|
@ -1,34 +0,0 @@
|
|||
import { BorderlessButton } from 'react-native-gesture-handler';
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import styles from '../styles';
|
||||
import i18n from '../../../i18n';
|
||||
import { CustomIcon, TIconsName } from '../../CustomIcon';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { themes } from '../../../lib/constants';
|
||||
|
||||
interface IBaseButton {
|
||||
onPress(): void;
|
||||
testID: string;
|
||||
accessibilityLabel: string;
|
||||
icon: TIconsName;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const BaseButton = ({ accessibilityLabel, icon, color, ...props }: IBaseButton) => {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<BorderlessButton {...props} style={styles.actionButton}>
|
||||
<View
|
||||
accessible
|
||||
accessibilityLabel={accessibilityLabel ? i18n.t(accessibilityLabel) : accessibilityLabel}
|
||||
accessibilityRole='button'
|
||||
>
|
||||
<CustomIcon name={icon} size={24} color={color || themes[theme].auxiliaryTintColor} />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseButton;
|
|
@ -1,13 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
interface ICancelEditingButton {
|
||||
onPress(): void;
|
||||
}
|
||||
|
||||
const CancelEditingButton = ({ onPress }: ICancelEditingButton) => (
|
||||
<BaseButton onPress={onPress} testID='messagebox-cancel-editing' accessibilityLabel='Cancel_editing' icon='close' />
|
||||
);
|
||||
|
||||
export default CancelEditingButton;
|
|
@ -1,24 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { themes } from '../../../lib/constants';
|
||||
import { useTheme } from '../../../theme';
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
interface ISendButton {
|
||||
onPress(): void;
|
||||
}
|
||||
|
||||
const SendButton = ({ onPress }: ISendButton) => {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<BaseButton
|
||||
onPress={onPress}
|
||||
testID='messagebox-send-message'
|
||||
accessibilityLabel='Send_message'
|
||||
icon='send-filled'
|
||||
color={themes[theme].tintColor}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendButton;
|
|
@ -1,20 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
interface IToggleEmojiButton {
|
||||
show: boolean;
|
||||
open(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
const ToggleEmojiButton = ({ show, open, close }: IToggleEmojiButton) => {
|
||||
if (show) {
|
||||
return (
|
||||
<BaseButton onPress={close} testID='messagebox-close-emoji' accessibilityLabel='Close_emoji_selector' icon='keyboard' />
|
||||
);
|
||||
}
|
||||
return <BaseButton onPress={open} testID='messagebox-open-emoji' accessibilityLabel='Open_emoji_selector' icon='emoji' />;
|
||||
};
|
||||
|
||||
export default ToggleEmojiButton;
|
|
@ -1,6 +0,0 @@
|
|||
import CancelEditingButton from './CancelEditingButton';
|
||||
import ToggleEmojiButton from './ToggleEmojiButton';
|
||||
import SendButton from './SendButton';
|
||||
import ActionsButton from './ActionsButton';
|
||||
|
||||
export { CancelEditingButton, ToggleEmojiButton, SendButton, ActionsButton };
|
|
@ -1,9 +0,0 @@
|
|||
export const MENTIONS_TRACKING_TYPE_USERS = '@';
|
||||
export const MENTIONS_TRACKING_TYPE_EMOJIS = ':';
|
||||
export const MENTIONS_TRACKING_TYPE_COMMANDS = '/';
|
||||
export const MENTIONS_TRACKING_TYPE_ROOMS = '#';
|
||||
export const MENTIONS_TRACKING_TYPE_CANNED = '!';
|
||||
export const MENTIONS_COUNT_TO_DISPLAY = 4;
|
||||
export const MAX_EMOJIS_TO_DISPLAY = 20;
|
||||
|
||||
export const TIMEOUT_CLOSE_EMOJI = 300;
|
|
@ -1,4 +0,0 @@
|
|||
// Match query string from the message to replace it with the suggestion
|
||||
const getMentionRegexp = (): any => /[^@:#/!]*$/;
|
||||
|
||||
export default getMentionRegexp;
|
File diff suppressed because it is too large
Load Diff
|
@ -1,161 +0,0 @@
|
|||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { isIOS } from '../../lib/methods/helpers';
|
||||
import sharedStyles from '../../views/Styles';
|
||||
|
||||
const MENTION_HEIGHT = 50;
|
||||
const SCROLLVIEW_MENTION_HEIGHT = 4 * MENTION_HEIGHT;
|
||||
|
||||
export default StyleSheet.create({
|
||||
composer: {
|
||||
flexDirection: 'column',
|
||||
borderTopWidth: 1
|
||||
},
|
||||
textArea: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexGrow: 0
|
||||
},
|
||||
textBoxInput: {
|
||||
textAlignVertical: 'center',
|
||||
maxHeight: 240,
|
||||
flexGrow: 1,
|
||||
width: 1,
|
||||
// paddingVertical: 12, needs to be paddingTop/paddingBottom because of iOS/Android's TextInput differences on rendering
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
fontSize: 16,
|
||||
letterSpacing: 0,
|
||||
...sharedStyles.textRegular
|
||||
},
|
||||
actionButton: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 60,
|
||||
height: 48
|
||||
},
|
||||
wrapMentionHeaderList: {
|
||||
height: MENTION_HEIGHT,
|
||||
justifyContent: 'center'
|
||||
},
|
||||
wrapMentionHeaderListRow: {
|
||||
height: MENTION_HEIGHT,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12
|
||||
},
|
||||
loadingPaddingHeader: {
|
||||
paddingRight: 12
|
||||
},
|
||||
mentionHeaderList: {
|
||||
fontSize: 14,
|
||||
...sharedStyles.textMedium
|
||||
},
|
||||
mentionHeaderListNoMatchFound: {
|
||||
fontSize: 14,
|
||||
...sharedStyles.textRegular
|
||||
},
|
||||
mentionNoMatchHeader: {
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
mentionList: {
|
||||
maxHeight: MENTION_HEIGHT * 4
|
||||
},
|
||||
mentionItem: {
|
||||
height: MENTION_HEIGHT,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 5
|
||||
},
|
||||
mentionItemCustomEmoji: {
|
||||
margin: 8,
|
||||
width: 30,
|
||||
height: 30
|
||||
},
|
||||
mentionItemEmoji: {
|
||||
width: 46,
|
||||
height: 36,
|
||||
fontSize: isIOS ? 30 : 25,
|
||||
...sharedStyles.textAlignCenter
|
||||
},
|
||||
fixedMentionAvatar: {
|
||||
width: 46,
|
||||
fontSize: 14,
|
||||
...sharedStyles.textBold,
|
||||
...sharedStyles.textAlignCenter
|
||||
},
|
||||
mentionText: {
|
||||
fontSize: 14,
|
||||
...sharedStyles.textRegular
|
||||
},
|
||||
cannedMentionText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
paddingRight: 12,
|
||||
...sharedStyles.textRegular
|
||||
},
|
||||
cannedItem: {
|
||||
fontSize: 14,
|
||||
...sharedStyles.textBold,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 8
|
||||
},
|
||||
emojiKeyboardContainer: {
|
||||
flex: 1,
|
||||
borderTopWidth: 1
|
||||
},
|
||||
slash: {
|
||||
height: 30,
|
||||
width: 30,
|
||||
padding: 5,
|
||||
paddingHorizontal: 12,
|
||||
marginHorizontal: 10,
|
||||
borderRadius: 4
|
||||
},
|
||||
commandPreviewImage: {
|
||||
justifyContent: 'center',
|
||||
margin: 3,
|
||||
width: 120,
|
||||
height: 80,
|
||||
borderRadius: 4
|
||||
},
|
||||
commandPreview: {
|
||||
height: 100,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
avatar: {
|
||||
margin: 8
|
||||
},
|
||||
scrollViewMention: {
|
||||
maxHeight: SCROLLVIEW_MENTION_HEIGHT
|
||||
},
|
||||
recordingContent: {
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
recordingDurationText: {
|
||||
width: 60,
|
||||
fontSize: 16,
|
||||
...sharedStyles.textRegular
|
||||
},
|
||||
buttonsWhitespace: {
|
||||
width: 15
|
||||
},
|
||||
sendToChannelButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 18
|
||||
},
|
||||
sendToChannelText: {
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
...sharedStyles.textRegular
|
||||
}
|
||||
});
|
|
@ -0,0 +1,437 @@
|
|||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import { Provider } from 'react-redux';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
|
||||
import { MessageComposerContainer } from './MessageComposerContainer';
|
||||
import { setPermissions } from '../../actions/permissions';
|
||||
import { addSettings } from '../../actions/settings';
|
||||
import { selectServerRequest } from '../../actions/server';
|
||||
import { setUser } from '../../actions/login';
|
||||
import { mockedStore } from '../../reducers/mockedStore';
|
||||
import { IPermissionsState } from '../../reducers/permissions';
|
||||
import { IMessage } from '../../definitions';
|
||||
import { colors } from '../../lib/constants';
|
||||
import { IRoomContext, RoomContext } from '../../views/RoomView/context';
|
||||
|
||||
const initialStoreState = () => {
|
||||
const baseUrl = 'https://open.rocket.chat';
|
||||
mockedStore.dispatch(selectServerRequest(baseUrl, '6.4.0'));
|
||||
mockedStore.dispatch(setUser({ id: 'abc', username: 'rocket.cat', name: 'Rocket Cat', roles: ['user'] }));
|
||||
|
||||
const permissions: IPermissionsState = { 'mobile-upload-file': ['user'] };
|
||||
mockedStore.dispatch(setPermissions(permissions));
|
||||
mockedStore.dispatch(addSettings({ Message_AudioRecorderEnabled: true }));
|
||||
};
|
||||
initialStoreState();
|
||||
|
||||
const initialContext = {
|
||||
rid: '',
|
||||
tmid: undefined,
|
||||
sharing: false,
|
||||
action: null,
|
||||
selectedMessages: [],
|
||||
editCancel: jest.fn(),
|
||||
editRequest: jest.fn(),
|
||||
onSendMessage: jest.fn(),
|
||||
onRemoveQuoteMessage: jest.fn()
|
||||
};
|
||||
|
||||
const Stack = createStackNavigator();
|
||||
|
||||
// const Navigation = ({ children }: { children: any }) => (
|
||||
// <NavigationContainer>
|
||||
// <Stack.Navigator>
|
||||
// <Stack.Screen name='A' component={children} />
|
||||
// </Stack.Navigator>
|
||||
// </NavigationContainer>
|
||||
// );
|
||||
|
||||
// const Content = () => (
|
||||
// <MessageComposerContainer />
|
||||
// )
|
||||
|
||||
const Render = ({ context }: { context?: Partial<IRoomContext> }) => (
|
||||
<Provider store={mockedStore}>
|
||||
<RoomContext.Provider value={{ ...initialContext, ...context }}>
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen name='MessageComposer' component={MessageComposerContainer} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
</RoomContext.Provider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
describe.skip('MessageComposer', () => {
|
||||
test('renders correctly', () => {
|
||||
render(<Render />);
|
||||
expect(screen.getByTestId('message-composer-input')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-actions')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('renders correctly with audio recorder disabled', () => {
|
||||
mockedStore.dispatch(addSettings({ Message_AudioRecorderEnabled: false }));
|
||||
render(<Render />);
|
||||
expect(screen.queryByTestId('message-composer-send-audio')).toBeNull();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('renders correctly without audio upload permissions', () => {
|
||||
mockedStore.dispatch(setPermissions({}));
|
||||
render(<Render />);
|
||||
expect(screen.queryByTestId('message-composer-send-audio')).toBeNull();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('renders correctly with audio recorder disabled and without audio upload permissions', () => {
|
||||
mockedStore.dispatch(addSettings({ Message_AudioRecorderEnabled: false }));
|
||||
mockedStore.dispatch(setPermissions({}));
|
||||
render(<Render />);
|
||||
expect(screen.queryByTestId('message-composer-send-audio')).toBeNull();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('renders toolbar when focused', async () => {
|
||||
initialStoreState();
|
||||
render(<Render />);
|
||||
expect(screen.getByTestId('message-composer-actions')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen();
|
||||
expect(screen.queryByTestId('message-composer-open-emoji')).toBeNull();
|
||||
expect(screen.queryByTestId('message-composer-open-markdown')).toBeNull();
|
||||
expect(screen.queryByTestId('message-composer-mention')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
});
|
||||
expect(screen.getByTestId('message-composer-actions')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-open-emoji')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-open-markdown')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('message-composer-mention')).toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('send message', async () => {
|
||||
const onSendMessage = jest.fn();
|
||||
render(<Render context={{ onSendMessage }} />);
|
||||
expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen();
|
||||
await act(async () => {
|
||||
await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'test');
|
||||
expect(screen.queryByTestId('message-composer-send-audio')).toBeNull();
|
||||
expect(screen.getByTestId('message-composer-send')).toBeOnTheScreen();
|
||||
await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
});
|
||||
expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onSendMessage).toHaveBeenCalledWith('test', undefined);
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('tap actions from toolbar', async () => {
|
||||
render(<Render />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent.press(screen.getByTestId('message-composer-actions'));
|
||||
});
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('tap emoji', async () => {
|
||||
render(<Render />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
await fireEvent.press(screen.getByTestId('message-composer-open-emoji'));
|
||||
});
|
||||
expect(screen.getByTestId('message-composer-close-emoji')).toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// describe('Markdown', () => {
|
||||
// test('tap markdown', async () => {
|
||||
// render(<Render />);
|
||||
|
||||
// await act(async () => {
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
// });
|
||||
// expect(screen.getByTestId('message-composer-close-markdown')).toBeOnTheScreen();
|
||||
// expect(screen.getByTestId('message-composer-bold')).toBeOnTheScreen();
|
||||
// expect(screen.getByTestId('message-composer-italic')).toBeOnTheScreen();
|
||||
// expect(screen.getByTestId('message-composer-strike')).toBeOnTheScreen();
|
||||
// expect(screen.getByTestId('message-composer-code')).toBeOnTheScreen();
|
||||
// expect(screen.getByTestId('message-composer-code-block')).toBeOnTheScreen();
|
||||
// expect(screen.toJSON()).toMatchSnapshot();
|
||||
// });
|
||||
|
||||
// test('tap bold', async () => {
|
||||
// const onSendMessage = jest.fn();
|
||||
// render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
// await act(async () => {
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// // await waitFor(() => fireEvent.press(screen.getByTestId('message-composer-open-markdown')), { timeout: 1000 });
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-bold'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
// });
|
||||
// expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
// expect(onSendMessage).toHaveBeenCalledWith('**', undefined);
|
||||
// expect(screen.toJSON()).toMatchSnapshot();
|
||||
// });
|
||||
|
||||
// test('type test and tap bold', async () => {
|
||||
// const onSendMessage = jest.fn();
|
||||
// render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
// await act(async () => {
|
||||
// await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'test');
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
|
||||
// nativeEvent: { selection: { start: 0, end: 4 } }
|
||||
// });
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-bold'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
// });
|
||||
// expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
// expect(onSendMessage).toHaveBeenCalledWith('*test*', undefined);
|
||||
// expect(screen.toJSON()).toMatchSnapshot();
|
||||
// });
|
||||
|
||||
// test('tap italic', async () => {
|
||||
// const onSendMessage = jest.fn();
|
||||
// render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
// await act(async () => {
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-italic'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
// });
|
||||
// expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
// expect(onSendMessage).toHaveBeenCalledWith('__', undefined);
|
||||
// expect(screen.toJSON()).toMatchSnapshot();
|
||||
// });
|
||||
|
||||
// test('type test and tap italic', async () => {
|
||||
// const onSendMessage = jest.fn();
|
||||
// render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
// await act(async () => {
|
||||
// await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'test');
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
|
||||
// nativeEvent: { selection: { start: 0, end: 4 } }
|
||||
// });
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-italic'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
// });
|
||||
// expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
// expect(onSendMessage).toHaveBeenCalledWith('_test_', undefined);
|
||||
// expect(screen.toJSON()).toMatchSnapshot();
|
||||
// });
|
||||
|
||||
// test('tap strike', async () => {
|
||||
// const onSendMessage = jest.fn();
|
||||
// render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
// await act(async () => {
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-strike'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
// });
|
||||
// expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
// expect(onSendMessage).toHaveBeenCalledWith('~~', undefined);
|
||||
// expect(screen.toJSON()).toMatchSnapshot();
|
||||
// });
|
||||
|
||||
// test('type test and tap strike', async () => {
|
||||
// const onSendMessage = jest.fn();
|
||||
// render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
// await act(async () => {
|
||||
// await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'test');
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
|
||||
// nativeEvent: { selection: { start: 0, end: 4 } }
|
||||
// });
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-strike'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
// });
|
||||
// expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
// expect(onSendMessage).toHaveBeenCalledWith('~test~', undefined);
|
||||
// expect(screen.toJSON()).toMatchSnapshot();
|
||||
// });
|
||||
|
||||
// test('tap code', async () => {
|
||||
// const onSendMessage = jest.fn();
|
||||
// render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
// await act(async () => {
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-code'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
// });
|
||||
// expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
// expect(onSendMessage).toHaveBeenCalledWith('``', undefined);
|
||||
// expect(screen.toJSON()).toMatchSnapshot();
|
||||
// });
|
||||
|
||||
// test('type test and tap code', async () => {
|
||||
// const onSendMessage = jest.fn();
|
||||
// render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
// await act(async () => {
|
||||
// await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'test');
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
|
||||
// nativeEvent: { selection: { start: 0, end: 4 } }
|
||||
// });
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-code'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
// });
|
||||
// expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
// expect(onSendMessage).toHaveBeenCalledWith('`test`', undefined);
|
||||
// expect(screen.toJSON()).toMatchSnapshot();
|
||||
// });
|
||||
|
||||
// test('tap code-block', async () => {
|
||||
// const onSendMessage = jest.fn();
|
||||
// render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
// await act(async () => {
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-code-block'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
// });
|
||||
// expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
// expect(onSendMessage).toHaveBeenCalledWith('``````', undefined);
|
||||
// expect(screen.toJSON()).toMatchSnapshot();
|
||||
// });
|
||||
|
||||
// test('type test and tap code-block', async () => {
|
||||
// const onSendMessage = jest.fn();
|
||||
// render(<Render context={{ onSendMessage }} />);
|
||||
|
||||
// await act(async () => {
|
||||
// await fireEvent.changeText(screen.getByTestId('message-composer-input'), 'test');
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// await fireEvent(screen.getByTestId('message-composer-input'), 'selectionChange', {
|
||||
// nativeEvent: { selection: { start: 0, end: 4 } }
|
||||
// });
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-open-markdown'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-code-block'));
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
// });
|
||||
// expect(onSendMessage).toHaveBeenCalledTimes(1);
|
||||
// expect(onSendMessage).toHaveBeenCalledWith('```test```', undefined);
|
||||
// expect(screen.toJSON()).toMatchSnapshot();
|
||||
// });
|
||||
// });
|
||||
|
||||
test('tap mention', async () => {
|
||||
render(<Render />);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent(screen.getByTestId('message-composer-input'), 'focus');
|
||||
// await fireEvent.press(screen.getByTestId('message-composer-mention'));
|
||||
});
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('edit message', () => {
|
||||
const onSendMessage = jest.fn();
|
||||
const editCancel = jest.fn();
|
||||
const editRequest = jest.fn();
|
||||
const id = 'messageId';
|
||||
beforeEach(() => {
|
||||
render(<Render context={{ rid: 'rid', selectedMessages: [id], action: 'edit', onSendMessage, editCancel, editRequest }} />);
|
||||
});
|
||||
test('init', async () => {
|
||||
await screen.findByTestId('message-composer');
|
||||
expect(screen.getByTestId('message-composer')).toHaveStyle({ backgroundColor: colors.light.statusBackgroundWarning2 });
|
||||
expect(screen.getByTestId('message-composer-actions')).toBeOnTheScreen();
|
||||
expect(screen.queryByTestId('message-composer-send-audio')).toBeNull();
|
||||
expect(screen.getByTestId('message-composer-cancel-edit')).toBeOnTheScreen();
|
||||
});
|
||||
test('cancel', async () => {
|
||||
await screen.findByTestId('message-composer');
|
||||
expect(screen.getByTestId('message-composer')).toHaveStyle({ backgroundColor: colors.light.statusBackgroundWarning2 });
|
||||
fireEvent.press(screen.getByTestId('message-composer-cancel-edit'));
|
||||
expect(editCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('send', async () => {
|
||||
await screen.findByTestId('message-composer');
|
||||
expect(screen.getByTestId('message-composer')).toHaveStyle({ backgroundColor: colors.light.statusBackgroundWarning2 });
|
||||
fireEvent.press(screen.getByTestId('message-composer-send'));
|
||||
expect(editRequest).toHaveBeenCalledTimes(1);
|
||||
expect(editRequest).toHaveBeenCalledWith({ id, msg: `Message ${id}`, rid: 'rid' });
|
||||
});
|
||||
});
|
||||
|
||||
const messageIds = ['abc', 'def'];
|
||||
jest.mock('./hooks/useMessage', () => ({
|
||||
useMessage: (messageId: string) => {
|
||||
if (!messageIds.includes(messageId)) {
|
||||
return null;
|
||||
}
|
||||
const message = {
|
||||
id: messageId,
|
||||
msg: 'quote this',
|
||||
u: {
|
||||
username: 'rocket.cat'
|
||||
}
|
||||
} as IMessage;
|
||||
return message;
|
||||
}
|
||||
}));
|
||||
|
||||
jest.mock('../../lib/store/auxStore', () => ({
|
||||
store: {
|
||||
getState: () => mockedStore.getState()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('Quote', () => {
|
||||
test('Adding/removing quotes', () => {
|
||||
const onRemoveQuoteMessage = jest.fn();
|
||||
|
||||
// Render without quotes
|
||||
const { rerender } = render(<Render context={{ selectedMessages: [], onRemoveQuoteMessage }} />);
|
||||
expect(screen.queryByTestId('composer-quote-abc')).toBeNull();
|
||||
expect(screen.queryByTestId('composer-quote-def')).toBeNull();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
|
||||
// Add a quote
|
||||
rerender(<Render context={{ action: 'quote', selectedMessages: ['abc'], onRemoveQuoteMessage }} />);
|
||||
expect(screen.getByTestId('composer-quote-abc')).toBeOnTheScreen();
|
||||
expect(screen.queryByTestId('composer-quote-def')).toBeNull();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
|
||||
// Add another quote
|
||||
rerender(<Render context={{ action: 'quote', selectedMessages: ['abc', 'def'], onRemoveQuoteMessage }} />);
|
||||
expect(screen.getByTestId('composer-quote-abc')).toBeOnTheScreen();
|
||||
expect(screen.getByTestId('composer-quote-def')).toBeOnTheScreen();
|
||||
expect(screen.toJSON()).toMatchSnapshot();
|
||||
|
||||
// Remove a quote
|
||||
fireEvent.press(screen.getByTestId('composer-quote-remove-def'));
|
||||
expect(onRemoveQuoteMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onRemoveQuoteMessage).toHaveBeenCalledWith('def');
|
||||
});
|
||||
|
||||
// TODO: need to create proper mocks for getMessageById and getPermalinkMessage
|
||||
// test('Send message with a quote', async () => {});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,235 @@
|
|||
import React, { ReactElement, useRef, useImperativeHandle, useCallback } from 'react';
|
||||
import { View, StyleSheet, NativeModules } from 'react-native';
|
||||
import { KeyboardAccessoryView } from 'react-native-ui-lib/keyboard';
|
||||
import { useBackHandler } from '@react-native-community/hooks';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
|
||||
import { useRoomContext } from '../../views/RoomView/context';
|
||||
import { Autocomplete, Toolbar, EmojiSearchbar, ComposerInput, Left, Right, Quotes, SendThreadToChannel } from './components';
|
||||
import { MIN_HEIGHT, TIMEOUT_CLOSE_EMOJI_KEYBOARD } from './constants';
|
||||
import {
|
||||
MessageInnerContext,
|
||||
useAlsoSendThreadToChannel,
|
||||
useMessageComposerApi,
|
||||
useRecordingAudio,
|
||||
useShowEmojiKeyboard,
|
||||
useShowEmojiSearchbar
|
||||
} from './context';
|
||||
import { IComposerInput, ITrackingView } from './interfaces';
|
||||
import { isIOS } from '../../lib/methods/helpers';
|
||||
import shortnameToUnicode from '../../lib/methods/helpers/shortnameToUnicode';
|
||||
import { useTheme } from '../../theme';
|
||||
import { EventTypes } from '../EmojiPicker/interfaces';
|
||||
import { IEmoji } from '../../definitions';
|
||||
import database from '../../lib/database';
|
||||
import { sanitizeLikeString } from '../../lib/database/utils';
|
||||
import { generateTriggerId } from '../../lib/methods';
|
||||
import { Services } from '../../lib/services';
|
||||
import log from '../../lib/methods/helpers/log';
|
||||
import { prepareQuoteMessage } from './helpers';
|
||||
import { RecordAudio } from './components/RecordAudio';
|
||||
import { useKeyboardListener } from './hooks';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
minHeight: MIN_HEIGHT
|
||||
},
|
||||
input: {
|
||||
flexDirection: 'row'
|
||||
}
|
||||
});
|
||||
|
||||
require('./components/EmojiKeyboard');
|
||||
|
||||
export const MessageComposer = ({
|
||||
forwardedRef,
|
||||
children
|
||||
}: {
|
||||
forwardedRef: any;
|
||||
children?: ReactElement;
|
||||
}): ReactElement | null => {
|
||||
const composerInputRef = useRef(null);
|
||||
const composerInputComponentRef = useRef<IComposerInput>({
|
||||
getTextAndClear: () => '',
|
||||
getText: () => '',
|
||||
getSelection: () => ({ start: 0, end: 0 }),
|
||||
setInput: () => {},
|
||||
onAutocompleteItemSelected: () => {}
|
||||
});
|
||||
const trackingViewRef = useRef<ITrackingView>({ resetTracking: () => {}, getNativeProps: () => ({ trackingViewHeight: 0 }) });
|
||||
const { colors, theme } = useTheme();
|
||||
const { rid, tmid, action, selectedMessages, sharing, editRequest, onSendMessage } = useRoomContext();
|
||||
const showEmojiKeyboard = useShowEmojiKeyboard();
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
const alsoSendThreadToChannel = useAlsoSendThreadToChannel();
|
||||
const { openSearchEmojiKeyboard, closeEmojiKeyboard, closeSearchEmojiKeyboard } = useMessageComposerApi();
|
||||
const recordingAudio = useRecordingAudio();
|
||||
useKeyboardListener(trackingViewRef);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
trackingViewRef.current?.resetTracking();
|
||||
}, [recordingAudio])
|
||||
);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
closeEmojiKeyboardAndAction,
|
||||
getText: composerInputComponentRef.current?.getText,
|
||||
setInput: composerInputComponentRef.current?.setInput
|
||||
}));
|
||||
|
||||
useBackHandler(() => {
|
||||
if (showEmojiSearchbar) {
|
||||
closeSearchEmojiKeyboard();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const closeEmojiKeyboardAndAction = (action?: Function, params?: any) => {
|
||||
if (showEmojiKeyboard) {
|
||||
closeEmojiKeyboard();
|
||||
}
|
||||
setTimeout(() => action && action(params), showEmojiKeyboard && isIOS ? TIMEOUT_CLOSE_EMOJI_KEYBOARD : undefined);
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!rid) return;
|
||||
|
||||
if (sharing) {
|
||||
onSendMessage?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const textFromInput = composerInputComponentRef.current.getTextAndClear();
|
||||
|
||||
if (action === 'edit') {
|
||||
return editRequest?.({ id: selectedMessages[0], msg: textFromInput, rid });
|
||||
}
|
||||
|
||||
if (action === 'quote') {
|
||||
const quoteMessage = await prepareQuoteMessage(textFromInput, selectedMessages);
|
||||
onSendMessage?.(quoteMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Slash command
|
||||
if (textFromInput[0] === '/') {
|
||||
const db = database.active;
|
||||
const commandsCollection = db.get('slash_commands');
|
||||
const command = textFromInput.replace(/ .*/, '').slice(1);
|
||||
const likeString = sanitizeLikeString(command);
|
||||
const slashCommand = await commandsCollection.query(Q.where('id', Q.like(`${likeString}%`))).fetch();
|
||||
if (slashCommand.length > 0) {
|
||||
try {
|
||||
const messageWithoutCommand = textFromInput.replace(/([^\s]+)/, '').trim();
|
||||
const [{ appId }] = slashCommand;
|
||||
const triggerId = generateTriggerId(appId);
|
||||
await Services.runSlashCommand(command, rid, messageWithoutCommand, triggerId, tmid);
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Text message
|
||||
onSendMessage?.(textFromInput, alsoSendThreadToChannel);
|
||||
};
|
||||
|
||||
const onKeyboardItemSelected = (_keyboardId: string, params: { eventType: EventTypes; emoji: IEmoji }) => {
|
||||
const { eventType, emoji } = params;
|
||||
const text = composerInputComponentRef.current.getText();
|
||||
let newText = '';
|
||||
// if input has an active cursor
|
||||
const { start, end } = composerInputComponentRef.current.getSelection();
|
||||
const cursor = Math.max(start, end);
|
||||
let newCursor;
|
||||
|
||||
switch (eventType) {
|
||||
case EventTypes.BACKSPACE_PRESSED:
|
||||
const emojiRegex = /\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]/;
|
||||
let charsToRemove = 1;
|
||||
const lastEmoji = text.substr(cursor > 0 ? cursor - 2 : text.length - 2, cursor > 0 ? cursor : text.length);
|
||||
// Check if last character is an emoji
|
||||
if (emojiRegex.test(lastEmoji)) charsToRemove = 2;
|
||||
newText =
|
||||
text.substr(0, (cursor > 0 ? cursor : text.length) - charsToRemove) + text.substr(cursor > 0 ? cursor : text.length);
|
||||
newCursor = cursor - charsToRemove;
|
||||
composerInputComponentRef.current.setInput(newText, { start: newCursor, end: newCursor });
|
||||
break;
|
||||
case EventTypes.EMOJI_PRESSED:
|
||||
let emojiText = '';
|
||||
if (typeof emoji === 'string') {
|
||||
emojiText = shortnameToUnicode(`:${emoji}:`);
|
||||
} else {
|
||||
emojiText = `:${emoji.name}:`;
|
||||
}
|
||||
newText = `${text.substr(0, cursor)}${emojiText}${text.substr(cursor)}`;
|
||||
newCursor = cursor + emojiText.length;
|
||||
composerInputComponentRef.current.setInput(newText, { start: newCursor, end: newCursor });
|
||||
break;
|
||||
case EventTypes.SEARCH_PRESSED:
|
||||
openSearchEmojiKeyboard();
|
||||
break;
|
||||
default:
|
||||
// Do nothing
|
||||
}
|
||||
};
|
||||
|
||||
const onEmojiSelected = (emoji: IEmoji) => {
|
||||
onKeyboardItemSelected('EmojiKeyboard', { eventType: EventTypes.EMOJI_PRESSED, emoji });
|
||||
};
|
||||
|
||||
const onKeyboardResigned = () => {
|
||||
if (!showEmojiSearchbar) {
|
||||
closeEmojiKeyboard();
|
||||
}
|
||||
};
|
||||
|
||||
const backgroundColor = action === 'edit' ? colors.statusBackgroundWarning2 : colors.surfaceLight;
|
||||
|
||||
const renderContent = () => {
|
||||
console.count('[MessageComposer] renderContent');
|
||||
if (recordingAudio) {
|
||||
return <RecordAudio />;
|
||||
}
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor, borderTopColor: colors.strokeLight }]} testID='message-composer'>
|
||||
<View style={styles.input}>
|
||||
<Left />
|
||||
<ComposerInput ref={composerInputComponentRef} inputRef={composerInputRef} />
|
||||
<Right />
|
||||
</View>
|
||||
<Quotes />
|
||||
<Toolbar />
|
||||
<EmojiSearchbar />
|
||||
<SendThreadToChannel />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageInnerContext.Provider value={{ sendMessage: handleSendMessage, onEmojiSelected, closeEmojiKeyboardAndAction }}>
|
||||
<KeyboardAccessoryView
|
||||
ref={(ref: ITrackingView) => (trackingViewRef.current = ref)}
|
||||
renderContent={renderContent}
|
||||
kbInputRef={composerInputRef}
|
||||
kbComponent={showEmojiKeyboard ? 'EmojiKeyboard' : null}
|
||||
kbInitialProps={{ theme }}
|
||||
onKeyboardResigned={onKeyboardResigned}
|
||||
onItemSelected={onKeyboardItemSelected}
|
||||
trackInteractive
|
||||
requiresSameParentToManageScrollView
|
||||
addBottomView
|
||||
bottomViewColor={backgroundColor}
|
||||
iOSScrollBehavior={NativeModules.KeyboardTrackingViewTempManager?.KeyboardTrackingScrollBehaviorFixedOffset}
|
||||
/>
|
||||
<Autocomplete onPress={item => composerInputComponentRef.current.onAutocompleteItemSelected(item)} />
|
||||
</MessageInnerContext.Provider>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
import React, { ReactElement, forwardRef } from 'react';
|
||||
|
||||
import { MessageComposerProvider } from './context';
|
||||
import { IMessageComposerContainerProps, IMessageComposerRef } from './interfaces';
|
||||
import { MessageComposer } from './MessageComposer';
|
||||
|
||||
export const MessageComposerContainer = forwardRef<IMessageComposerRef, IMessageComposerContainerProps>(
|
||||
({ children }, ref): ReactElement => (
|
||||
<MessageComposerProvider>
|
||||
<MessageComposer forwardedRef={ref}>{children}</MessageComposer>
|
||||
</MessageComposerProvider>
|
||||
)
|
||||
);
|
|
@ -0,0 +1,69 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
import { View, FlatList } from 'react-native';
|
||||
|
||||
import { useAutocompleteParams, useKeyboardHeight, useTrackingViewHeight } from '../../context';
|
||||
import { AutocompleteItem } from './AutocompleteItem';
|
||||
import { useAutocomplete } from '../../hooks';
|
||||
import { IAutocompleteItemProps } from '../../interfaces';
|
||||
import { AutocompletePreview } from './AutocompletePreview';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
import { useStyle, getBottom } from './styles';
|
||||
|
||||
export const Autocomplete = ({ onPress }: { onPress: IAutocompleteItemProps['onPress'] }): ReactElement | null => {
|
||||
const { rid } = useRoomContext();
|
||||
const trackingViewHeight = useTrackingViewHeight();
|
||||
const keyboardHeight = useKeyboardHeight();
|
||||
const { text, type, params } = useAutocompleteParams();
|
||||
const items = useAutocomplete({
|
||||
rid,
|
||||
text,
|
||||
type,
|
||||
commandParams: params
|
||||
});
|
||||
const [styles, colors] = useStyle();
|
||||
|
||||
if (items.length === 0 || !type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type !== '/preview') {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.root,
|
||||
{
|
||||
bottom: getBottom(trackingViewHeight, keyboardHeight)
|
||||
}
|
||||
]}
|
||||
>
|
||||
<FlatList
|
||||
contentContainerStyle={styles.listContentContainer}
|
||||
data={items}
|
||||
renderItem={({ item }) => <AutocompleteItem item={item} onPress={onPress} />}
|
||||
keyboardShouldPersistTaps='always'
|
||||
testID='autocomplete'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === '/preview') {
|
||||
return (
|
||||
<View
|
||||
style={[styles.root, { backgroundColor: colors.surfaceLight, bottom: getBottom(trackingViewHeight, keyboardHeight) }]}
|
||||
>
|
||||
<FlatList
|
||||
contentContainerStyle={styles.listContentContainer}
|
||||
style={styles.list}
|
||||
horizontal
|
||||
data={items}
|
||||
renderItem={({ item }) => <AutocompletePreview item={item} onPress={onPress} />}
|
||||
keyboardShouldPersistTaps='always'
|
||||
testID='autocomplete'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import sharedStyles from '../../../../views/Styles';
|
||||
import { IAutocompleteCannedResponse } from '../../interfaces';
|
||||
import I18n from '../../../../i18n';
|
||||
import { CustomIcon } from '../../../CustomIcon';
|
||||
import { NO_CANNED_RESPONSES } from '../../constants';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
export const AutocompleteCannedResponse = ({ item }: { item: IAutocompleteCannedResponse }) => {
|
||||
const [styles, colors] = useStyle();
|
||||
if (item.id === NO_CANNED_RESPONSES) {
|
||||
return (
|
||||
<View style={styles.canned}>
|
||||
<View style={styles.cannedTitle}>
|
||||
<Text style={styles.cannedTitleText}>
|
||||
{I18n.t('No_match_found')} <Text style={sharedStyles.textSemibold}>{I18n.t('Check_canned_responses')}</Text>
|
||||
</Text>
|
||||
<CustomIcon name='chevron-right' size={24} color={colors.fontHint} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.canned}>
|
||||
<View style={styles.cannedTitle}>
|
||||
<Text style={styles.cannedTitleText} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
{item.subtitle ? (
|
||||
<View style={styles.cannedSubtitle}>
|
||||
<Text style={styles.cannedSubtitleText}>{item.subtitle}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { IAutocompleteEmoji } from '../../interfaces';
|
||||
import { Emoji } from '../../../EmojiPicker/Emoji';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
export const AutocompleteEmoji = ({ item }: { item: IAutocompleteEmoji }) => {
|
||||
const [styles] = useStyle();
|
||||
return (
|
||||
<>
|
||||
<Emoji emoji={item.emoji} />
|
||||
<View style={styles.emoji}>
|
||||
<View style={styles.emojiTitle}>
|
||||
<Text style={styles.emojiText} numberOfLines={1}>
|
||||
{typeof item.emoji === 'string' ? `:${item.emoji}:` : `:${item.emoji.name}:`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { RectButton } from 'react-native-gesture-handler';
|
||||
|
||||
import { IAutocompleteItemProps, TAutocompleteItem } from '../../interfaces';
|
||||
import { AutocompleteUserRoom } from './AutocompleteUserRoom';
|
||||
import { AutocompleteEmoji } from './AutocompleteEmoji';
|
||||
import { AutocompleteSlashCommand } from './AutocompleteSlashCommand';
|
||||
import { AutocompleteCannedResponse } from './AutocompleteCannedResponse';
|
||||
import { AutocompleteItemLoading } from './AutocompleteItemLoading';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
const getTestIDSuffix = (item: TAutocompleteItem) => {
|
||||
if ('title' in item) {
|
||||
return item.title;
|
||||
}
|
||||
if ('emoji' in item) {
|
||||
return item.emoji;
|
||||
}
|
||||
return item.id;
|
||||
};
|
||||
|
||||
export const AutocompleteItem = ({ item, onPress }: IAutocompleteItemProps) => {
|
||||
const [styles, colors] = useStyle();
|
||||
return (
|
||||
<RectButton
|
||||
onPress={() => onPress(item)}
|
||||
underlayColor={colors.buttonBackgroundPrimaryPress}
|
||||
style={{ backgroundColor: colors.surfaceLight }}
|
||||
rippleColor={colors.buttonBackgroundPrimaryPress}
|
||||
testID={`autocomplete-item-${getTestIDSuffix(item)}`}
|
||||
>
|
||||
<View style={styles.item}>
|
||||
{item.type === '@' || item.type === '#' ? <AutocompleteUserRoom item={item} /> : null}
|
||||
{item.type === ':' ? <AutocompleteEmoji item={item} /> : null}
|
||||
{item.type === '/' ? <AutocompleteSlashCommand item={item} /> : null}
|
||||
{item.type === '!' ? <AutocompleteCannedResponse item={item} /> : null}
|
||||
{item.type === 'loading' ? <AutocompleteItemLoading /> : null}
|
||||
</View>
|
||||
</RectButton>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { useTheme } from '../../../../theme';
|
||||
|
||||
export const AutocompleteItemLoading = ({ preview = false }: { preview?: boolean }): React.ReactElement => {
|
||||
const { colors } = useTheme();
|
||||
if (preview) {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SkeletonPlaceholder borderRadius={4} backgroundColor={colors.surfaceNeutral}>
|
||||
<SkeletonPlaceholder.Item height={80} width={80} />
|
||||
</SkeletonPlaceholder>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SkeletonPlaceholder borderRadius={4} backgroundColor={colors.surfaceNeutral}>
|
||||
<SkeletonPlaceholder.Item height={20} />
|
||||
</SkeletonPlaceholder>
|
||||
</View>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import { RectButton } from 'react-native-gesture-handler';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
|
||||
import { IAutocompleteItemProps } from '../../interfaces';
|
||||
import { CustomIcon } from '../../../CustomIcon';
|
||||
import { AutocompleteItemLoading } from './AutocompleteItemLoading';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
export const AutocompletePreview = ({ item, onPress }: IAutocompleteItemProps) => {
|
||||
const [styles, colors] = useStyle();
|
||||
|
||||
let content;
|
||||
if (item.type === 'loading') {
|
||||
content = <AutocompleteItemLoading preview />;
|
||||
}
|
||||
if (item.type === '/preview') {
|
||||
content =
|
||||
item.preview.type === 'image' ? (
|
||||
<FastImage style={styles.previewImage} source={{ uri: item.preview.value }} resizeMode={FastImage.resizeMode.cover} />
|
||||
) : (
|
||||
<CustomIcon name='attach' size={36} color={colors.fontInfo} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RectButton
|
||||
onPress={() => onPress(item)}
|
||||
underlayColor={colors.buttonBackgroundPrimaryPress}
|
||||
style={styles.previewItem}
|
||||
rippleColor={colors.buttonBackgroundPrimaryPress}
|
||||
>
|
||||
{content}
|
||||
</RectButton>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { IAutocompleteSlashCommand } from '../../interfaces';
|
||||
import I18n from '../../../../i18n';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
export const AutocompleteSlashCommand = ({ item }: { item: IAutocompleteSlashCommand }) => {
|
||||
const [styles] = useStyle();
|
||||
return (
|
||||
<View style={styles.slashItem}>
|
||||
<View style={styles.slashTitle}>
|
||||
<Text style={styles.slashTitleText} numberOfLines={1}>
|
||||
/{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
{item.subtitle ? (
|
||||
<View style={styles.slashSubtitle}>
|
||||
<Text style={styles.slashSubtitleText}>{I18n.isTranslated(item.subtitle) ? I18n.t(item.subtitle) : item.subtitle}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
|
||||
import { IAutocompleteUserRoom } from '../../interfaces';
|
||||
import Avatar from '../../../Avatar';
|
||||
import RoomTypeIcon from '../../../RoomTypeIcon';
|
||||
import { fetchIsAllOrHere } from '../../helpers';
|
||||
import I18n from '../../../../i18n';
|
||||
import { useStyle } from './styles';
|
||||
|
||||
export const AutocompleteUserRoom = ({ item }: { item: IAutocompleteUserRoom }) => {
|
||||
const [styles] = useStyle();
|
||||
const isAllOrHere = fetchIsAllOrHere(item);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isAllOrHere ? <Avatar rid={item.id} text={item.subtitle} size={36} type={item.t} /> : null}
|
||||
<View style={[styles.userRoom, { paddingLeft: isAllOrHere ? 0 : 12 }]}>
|
||||
<View style={styles.userRoomHeader}>
|
||||
{!isAllOrHere ? (
|
||||
<RoomTypeIcon userId={item.id} type={item.t} status={item.status} size={16} teamMain={item.teamMain} />
|
||||
) : null}
|
||||
<View style={{ paddingLeft: isAllOrHere ? 0 : 2 }}>
|
||||
<Text style={styles.userRoomTitleText} numberOfLines={1}>
|
||||
{isAllOrHere ? `@${item.title}` : item.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{item.type === '#' ? null : (
|
||||
<View style={styles.userRoomSubtitle}>
|
||||
<Text style={styles.userRoomSubtitleText}>{item.subtitle}</Text>
|
||||
{item.outside ? <Text style={styles.userRoomOutsideText}>{I18n.t('Not_in_channel')}</Text> : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './Autocomplete';
|
|
@ -0,0 +1,61 @@
|
|||
import sharedStyles from '../../../../views/Styles';
|
||||
import { useTheme } from '../../../../theme';
|
||||
|
||||
const MAX_HEIGHT = 216;
|
||||
export const getBottom = (trackingViewHeight: number, keyboardHeight: number): number => trackingViewHeight + keyboardHeight + 50;
|
||||
|
||||
export const useStyle = () => {
|
||||
const { colors } = useTheme();
|
||||
const styles = {
|
||||
root: {
|
||||
maxHeight: MAX_HEIGHT,
|
||||
left: 8,
|
||||
right: 8,
|
||||
backgroundColor: colors.surfaceNeutral,
|
||||
position: 'absolute',
|
||||
borderRadius: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2
|
||||
},
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 2,
|
||||
elevation: 4
|
||||
},
|
||||
listContentContainer: {
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
list: { margin: 8 },
|
||||
item: {
|
||||
minHeight: 48,
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
alignItems: 'center'
|
||||
},
|
||||
slashItem: { flex: 1, justifyContent: 'center' },
|
||||
slashTitle: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
||||
slashTitleText: { ...sharedStyles.textBold, fontSize: 14, color: colors.fontDefault },
|
||||
slashSubtitle: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingTop: 2 },
|
||||
slashSubtitleText: { ...sharedStyles.textRegular, fontSize: 14, color: colors.fontSecondaryInfo, flex: 1 },
|
||||
previewItem: { backgroundColor: colors.surfaceLight, paddingRight: 4 },
|
||||
previewImage: { height: 80, minWidth: 80, borderRadius: 4 },
|
||||
emoji: { flex: 1, justifyContent: 'center', paddingLeft: 12 },
|
||||
emojiTitle: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
||||
emojiText: { ...sharedStyles.textBold, fontSize: 14, color: colors.fontDefault },
|
||||
canned: { flex: 1, justifyContent: 'center' },
|
||||
cannedTitle: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
||||
cannedTitleText: { ...sharedStyles.textRegular, flex: 1, fontSize: 14, color: colors.fontHint },
|
||||
cannedSubtitle: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingTop: 2 },
|
||||
cannedSubtitleText: { ...sharedStyles.textRegular, fontSize: 14, color: colors.fontSecondaryInfo, flex: 1 },
|
||||
userRoom: { flex: 1, justifyContent: 'center' },
|
||||
userRoomHeader: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
||||
userRoomTitleText: { ...sharedStyles.textBold, fontSize: 14, color: colors.fontDefault },
|
||||
userRoomSubtitle: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingTop: 2 },
|
||||
userRoomSubtitleText: { ...sharedStyles.textRegular, fontSize: 14, color: colors.fontSecondaryInfo, flex: 1 },
|
||||
userRoomOutsideText: { ...sharedStyles.textRegular, fontSize: 12, color: colors.fontSecondaryInfo }
|
||||
} as const;
|
||||
return [styles, colors] as const;
|
||||
};
|
|
@ -0,0 +1,81 @@
|
|||
import React, { useContext } from 'react';
|
||||
|
||||
import { getSubscriptionByRoomId } from '../../../../lib/database/services/Subscription';
|
||||
import { BaseButton } from './BaseButton';
|
||||
import { TActionSheetOptionsItem, useActionSheet } from '../../../ActionSheet';
|
||||
import { MessageInnerContext } from '../../context';
|
||||
import I18n from '../../../../i18n';
|
||||
import Navigation from '../../../../lib/navigation/appNavigation';
|
||||
import { useAppSelector, usePermissions } from '../../../../lib/hooks';
|
||||
import { useCanUploadFile, useChooseMedia } from '../../hooks';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
|
||||
export const ActionsButton = () => {
|
||||
const { rid, tmid, t } = useRoomContext();
|
||||
const { closeEmojiKeyboardAndAction } = useContext(MessageInnerContext);
|
||||
const permissionToUpload = useCanUploadFile(rid);
|
||||
const [permissionToViewCannedResponses] = usePermissions(['view-canned-responses'], rid);
|
||||
const { takePhoto, takeVideo, chooseFromLibrary, chooseFile } = useChooseMedia({
|
||||
rid,
|
||||
tmid,
|
||||
permissionToUpload
|
||||
});
|
||||
const { showActionSheet } = useActionSheet();
|
||||
const isMasterDetail = useAppSelector(state => state.app.isMasterDetail);
|
||||
|
||||
const createDiscussion = async () => {
|
||||
if (!rid) return;
|
||||
const subscription = await getSubscriptionByRoomId(rid);
|
||||
const params = { channel: subscription, showCloseModal: true };
|
||||
if (isMasterDetail) {
|
||||
Navigation.navigate('ModalStackNavigator', { screen: 'CreateDiscussionView', params });
|
||||
} else {
|
||||
Navigation.navigate('NewMessageStackNavigator', { screen: 'CreateDiscussionView', params });
|
||||
}
|
||||
};
|
||||
|
||||
const onPress = () => {
|
||||
const options: TActionSheetOptionsItem[] = [];
|
||||
if (t === 'l' && permissionToViewCannedResponses) {
|
||||
options.push({
|
||||
title: I18n.t('Canned_Responses'),
|
||||
icon: 'canned-response',
|
||||
onPress: () => Navigation.navigate('CannedResponsesListView', { rid })
|
||||
});
|
||||
}
|
||||
if (permissionToUpload) {
|
||||
options.push(
|
||||
{
|
||||
title: I18n.t('Take_a_photo'),
|
||||
icon: 'camera-photo',
|
||||
onPress: () => takePhoto()
|
||||
},
|
||||
{
|
||||
title: I18n.t('Take_a_video'),
|
||||
icon: 'camera',
|
||||
onPress: () => takeVideo()
|
||||
},
|
||||
{
|
||||
title: I18n.t('Choose_from_library'),
|
||||
icon: 'image',
|
||||
onPress: () => chooseFromLibrary()
|
||||
},
|
||||
{
|
||||
title: I18n.t('Choose_file'),
|
||||
icon: 'attach',
|
||||
onPress: () => chooseFile()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
options.push({
|
||||
title: I18n.t('Create_Discussion'),
|
||||
icon: 'discussions',
|
||||
onPress: () => createDiscussion()
|
||||
});
|
||||
|
||||
closeEmojiKeyboardAndAction(showActionSheet, { options });
|
||||
};
|
||||
|
||||
return <BaseButton onPress={onPress} testID='message-composer-actions' accessibilityLabel='Message_actions' icon='add' />;
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
import { BorderlessButton } from 'react-native-gesture-handler';
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
|
||||
import I18n from '../../../../i18n';
|
||||
import { CustomIcon, TIconsName } from '../../../CustomIcon';
|
||||
import { useTheme } from '../../../../theme';
|
||||
|
||||
export interface IBaseButton {
|
||||
testID: string;
|
||||
accessibilityLabel: string;
|
||||
icon: TIconsName;
|
||||
color?: string;
|
||||
onPress(): void;
|
||||
}
|
||||
|
||||
export const hitSlop = {
|
||||
top: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
left: 16
|
||||
};
|
||||
|
||||
export const BaseButton = ({ accessibilityLabel, icon, color, testID, onPress }: IBaseButton) => {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<BorderlessButton style={styles.button} onPress={() => onPress()} testID={testID} hitSlop={hitSlop}>
|
||||
<View accessible accessibilityLabel={I18n.t(accessibilityLabel)} accessibilityRole='button'>
|
||||
<CustomIcon name={icon} size={24} color={color || colors.fontSecondaryInfo} />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24
|
||||
}
|
||||
});
|
|
@ -0,0 +1,51 @@
|
|||
import React, { useContext } from 'react';
|
||||
import { Audio } from 'expo-av';
|
||||
|
||||
import { BaseButton } from './BaseButton';
|
||||
import { MessageInnerContext, useMessageComposerApi, useMicOrSend } from '../../context';
|
||||
import { useTheme } from '../../../../theme';
|
||||
import { useAppSelector } from '../../../../lib/hooks';
|
||||
import { useCanUploadFile } from '../../hooks';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
|
||||
export const MicOrSendButton = () => {
|
||||
const { rid, sharing } = useRoomContext();
|
||||
const micOrSend = useMicOrSend();
|
||||
const { sendMessage } = useContext(MessageInnerContext);
|
||||
const permissionToUpload = useCanUploadFile(rid);
|
||||
const { Message_AudioRecorderEnabled } = useAppSelector(state => state.settings);
|
||||
const { colors } = useTheme();
|
||||
const { setRecordingAudio } = useMessageComposerApi();
|
||||
|
||||
const startRecording = async () => {
|
||||
const permission = await Audio.requestPermissionsAsync();
|
||||
if (permission.granted) {
|
||||
setRecordingAudio(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (micOrSend === 'send' || sharing) {
|
||||
return (
|
||||
<BaseButton
|
||||
onPress={sendMessage}
|
||||
testID='message-composer-send'
|
||||
accessibilityLabel='Send_message'
|
||||
icon='send-filled'
|
||||
color={colors.strokeHighlight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (Message_AudioRecorderEnabled && permissionToUpload) {
|
||||
return (
|
||||
<BaseButton
|
||||
onPress={startRecording}
|
||||
testID='message-composer-send-audio'
|
||||
accessibilityLabel='Send_audio_message'
|
||||
icon='microphone'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
export * from './ActionsButton';
|
||||
export * from './BaseButton';
|
||||
export * from './MicOrSendButton';
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
|
||||
import { BaseButton } from './Buttons';
|
||||
import { useRoomContext } from '../../../views/RoomView/context';
|
||||
import { Gap } from './Gap';
|
||||
|
||||
export const CancelEdit = () => {
|
||||
const { action, editCancel } = useRoomContext();
|
||||
|
||||
if (action !== 'edit') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<BaseButton
|
||||
onPress={() => editCancel?.()}
|
||||
testID='message-composer-cancel-edit'
|
||||
accessibilityLabel='Cancel_editing'
|
||||
icon='close'
|
||||
/>
|
||||
<Gap />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,365 @@
|
|||
import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle } from 'react';
|
||||
import { TextInput, StyleSheet, TextInputProps, InteractionManager } from 'react-native';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/native';
|
||||
|
||||
import I18n from '../../../i18n';
|
||||
import { IAutocompleteItemProps, IComposerInput, IComposerInputProps, IInputSelection, TSetInput } from '../interfaces';
|
||||
import { useAutocompleteParams, useFocused, useMessageComposerApi } from '../context';
|
||||
import { loadDraftMessage, saveDraftMessage, fetchIsAllOrHere, getMentionRegexp } from '../helpers';
|
||||
import { useSubscription } from '../hooks';
|
||||
import sharedStyles from '../../../views/Styles';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { userTyping } from '../../../actions/room';
|
||||
import { getRoomTitle } from '../../../lib/methods/helpers';
|
||||
import { MAX_HEIGHT, MIN_HEIGHT, NO_CANNED_RESPONSES, MARKDOWN_STYLES } from '../constants';
|
||||
import database from '../../../lib/database';
|
||||
import Navigation from '../../../lib/navigation/appNavigation';
|
||||
import { emitter } from '../emitter';
|
||||
import { useRoomContext } from '../../../views/RoomView/context';
|
||||
import { getMessageById } from '../../../lib/database/services/Message';
|
||||
import { generateTriggerId } from '../../../lib/methods';
|
||||
import { Services } from '../../../lib/services';
|
||||
import log from '../../../lib/methods/helpers/log';
|
||||
import { useAppSelector, usePrevious } from '../../../lib/hooks';
|
||||
import { ChatsStackParamList } from '../../../stacks/types';
|
||||
|
||||
const defaultSelection: IInputSelection = { start: 0, end: 0 };
|
||||
|
||||
export const ComposerInput = memo(
|
||||
forwardRef<IComposerInput, IComposerInputProps>(({ inputRef }, ref) => {
|
||||
const { colors, theme } = useTheme();
|
||||
const { rid, tmid, sharing, action, selectedMessages } = useRoomContext();
|
||||
const focused = useFocused();
|
||||
const { setFocused, setTrackingViewHeight, setMicOrSend, setAutocompleteParams } = useMessageComposerApi();
|
||||
const autocompleteType = useAutocompleteParams()?.type;
|
||||
const textRef = React.useRef('');
|
||||
const selectionRef = React.useRef<IInputSelection>(defaultSelection);
|
||||
const dispatch = useDispatch();
|
||||
const subscription = useSubscription(rid);
|
||||
const isMasterDetail = useAppSelector(state => state.app.isMasterDetail);
|
||||
let placeholder = tmid ? I18n.t('Add_thread_reply') : '';
|
||||
if (subscription && !tmid) {
|
||||
placeholder = I18n.t('Message_roomname', { roomName: (subscription.t === 'd' ? '@' : '#') + getRoomTitle(subscription) });
|
||||
}
|
||||
const route = useRoute<RouteProp<ChatsStackParamList, 'RoomView'>>();
|
||||
const usedCannedResponse = route.params?.usedCannedResponse;
|
||||
const prevAction = usePrevious(action);
|
||||
|
||||
// Draft/Canned Responses
|
||||
useEffect(() => {
|
||||
const setDraftMessage = async () => {
|
||||
const draftMessage = await loadDraftMessage({ rid, tmid });
|
||||
if (draftMessage) {
|
||||
setInput(draftMessage);
|
||||
}
|
||||
};
|
||||
|
||||
if (sharing) return;
|
||||
|
||||
if (usedCannedResponse) {
|
||||
setInput(usedCannedResponse);
|
||||
} else if (action !== 'edit') {
|
||||
setDraftMessage();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (action !== 'edit') {
|
||||
saveDraftMessage({ rid, tmid, draftMessage: textRef.current });
|
||||
}
|
||||
};
|
||||
}, [action, rid, tmid, usedCannedResponse]);
|
||||
|
||||
// Edit/quote
|
||||
useEffect(() => {
|
||||
const fetchMessageAndSetInput = async () => {
|
||||
const message = await getMessageById(selectedMessages[0]);
|
||||
if (message) {
|
||||
setInput(message?.msg || '');
|
||||
}
|
||||
};
|
||||
|
||||
if (sharing) return;
|
||||
|
||||
if (prevAction === 'edit' && action !== 'edit') {
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
if (action === 'edit' && selectedMessages[0]) {
|
||||
focus();
|
||||
fetchMessageAndSetInput();
|
||||
return;
|
||||
}
|
||||
if (action === 'quote' && selectedMessages.length === 1) {
|
||||
focus();
|
||||
}
|
||||
}, [action, selectedMessages]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
emitter.on('addMarkdown', ({ style }) => {
|
||||
const { start, end } = selectionRef.current;
|
||||
const text = textRef.current;
|
||||
const markdown = MARKDOWN_STYLES[style];
|
||||
const newText = `${text.substr(0, start)}${markdown}${text.substr(start, end - start)}${markdown}${text.substr(end)}`;
|
||||
setInput(newText, {
|
||||
start: start + markdown.length,
|
||||
end: start === end ? start + markdown.length : end + markdown.length
|
||||
});
|
||||
});
|
||||
emitter.on('toolbarMention', () => {
|
||||
if (autocompleteType) {
|
||||
return;
|
||||
}
|
||||
const { start, end } = selectionRef.current;
|
||||
const text = textRef.current;
|
||||
const newText = `${text.substr(0, start)}@${text.substr(start, end - start)}${text.substr(end)}`;
|
||||
setInput(newText, { start: start + 1, end: start === end ? start + 1 : end + 1 });
|
||||
setAutocompleteParams({ text: '', type: '@' });
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
emitter.off('addMarkdown');
|
||||
emitter.off('toolbarMention');
|
||||
task?.cancel();
|
||||
};
|
||||
}, [rid, tmid, autocompleteType])
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getTextAndClear: () => {
|
||||
const text = textRef.current;
|
||||
setInput('');
|
||||
return text;
|
||||
},
|
||||
getText: () => textRef.current,
|
||||
getSelection: () => selectionRef.current,
|
||||
setInput,
|
||||
onAutocompleteItemSelected
|
||||
}));
|
||||
|
||||
const setInput: TSetInput = (text, selection) => {
|
||||
textRef.current = text;
|
||||
if (inputRef.current) {
|
||||
inputRef.current.setNativeProps({ text });
|
||||
}
|
||||
if (selection) {
|
||||
// setSelection won't trigger onSelectionChange, so we need it to be ran after new text is set
|
||||
setTimeout(() => {
|
||||
inputRef.current?.setSelection?.(selection.start, selection.end);
|
||||
selectionRef.current = selection;
|
||||
}, 50);
|
||||
}
|
||||
setMicOrSend(text.length === 0 ? 'mic' : 'send');
|
||||
};
|
||||
|
||||
const focus = () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeText: TextInputProps['onChangeText'] = text => {
|
||||
textRef.current = text;
|
||||
debouncedOnChangeText(text);
|
||||
setInput(text);
|
||||
};
|
||||
|
||||
const onSelectionChange: TextInputProps['onSelectionChange'] = e => {
|
||||
selectionRef.current = e.nativeEvent.selection;
|
||||
};
|
||||
|
||||
const onFocus: TextInputProps['onFocus'] = () => {
|
||||
setFocused(true);
|
||||
};
|
||||
|
||||
const onBlur: TextInputProps['onBlur'] = () => {
|
||||
setFocused(false);
|
||||
stopAutocomplete();
|
||||
};
|
||||
|
||||
const handleLayout: TextInputProps['onLayout'] = e => {
|
||||
setTrackingViewHeight(e.nativeEvent.layout.height);
|
||||
};
|
||||
|
||||
const onAutocompleteItemSelected: IAutocompleteItemProps['onPress'] = async item => {
|
||||
if (item.type === 'loading') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If it's slash command preview, we need to execute the command
|
||||
if (item.type === '/preview') {
|
||||
try {
|
||||
if (!rid) return;
|
||||
const db = database.active;
|
||||
const commandsCollection = db.get('slash_commands');
|
||||
const commandRecord = await commandsCollection.find(item.text);
|
||||
const { appId } = commandRecord;
|
||||
const triggerId = generateTriggerId(appId);
|
||||
Services.executeCommandPreview(item.text, item.params, rid, item.preview, triggerId, tmid);
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
stopAutocomplete();
|
||||
setInput('', { start: 0, end: 0 });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's canned response, but there's no canned responses, we open the canned responses view
|
||||
if (item.type === '!' && item.id === NO_CANNED_RESPONSES) {
|
||||
const params = { rid };
|
||||
if (isMasterDetail) {
|
||||
Navigation.navigate('ModalStackNavigator', { screen: 'CannedResponsesListView', params });
|
||||
} else {
|
||||
Navigation.navigate('CannedResponsesListView', params);
|
||||
}
|
||||
stopAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const text = textRef.current;
|
||||
const { start, end } = selectionRef.current;
|
||||
const cursor = Math.max(start, end);
|
||||
const regexp = getMentionRegexp();
|
||||
let result = text.substr(0, cursor).replace(regexp, '');
|
||||
// Remove the ! after select the canned response
|
||||
if (item.type === '!') {
|
||||
const lastIndexOfExclamation = text.lastIndexOf('!', cursor);
|
||||
result = text.substr(0, lastIndexOfExclamation).replace(regexp, '');
|
||||
}
|
||||
let mention = '';
|
||||
switch (item.type) {
|
||||
case '@':
|
||||
mention = fetchIsAllOrHere(item) ? item.title : item.subtitle || item.title;
|
||||
break;
|
||||
case '#':
|
||||
mention = item.subtitle ? item.subtitle : '';
|
||||
break;
|
||||
case ':':
|
||||
mention = `${typeof item.emoji === 'string' ? item.emoji : item.emoji.name}:`;
|
||||
break;
|
||||
case '/':
|
||||
mention = item.title;
|
||||
break;
|
||||
case '!':
|
||||
mention = item.subtitle ? item.subtitle : '';
|
||||
break;
|
||||
default:
|
||||
mention = '';
|
||||
}
|
||||
const newText = `${result}${mention} ${text.slice(cursor)}`;
|
||||
|
||||
const newCursor = result.length + mention.length + 1;
|
||||
setInput(newText, { start: newCursor, end: newCursor });
|
||||
focus();
|
||||
requestAnimationFrame(() => {
|
||||
stopAutocomplete();
|
||||
});
|
||||
};
|
||||
|
||||
const stopAutocomplete = () => {
|
||||
setAutocompleteParams({ text: '', type: null, params: '' });
|
||||
};
|
||||
|
||||
const debouncedOnChangeText = useDebouncedCallback(async (text: string) => {
|
||||
const isTextEmpty = text.length === 0;
|
||||
handleTyping(!isTextEmpty);
|
||||
if (isTextEmpty || !focused) {
|
||||
stopAutocomplete();
|
||||
return;
|
||||
}
|
||||
const { start, end } = selectionRef.current;
|
||||
const cursor = Math.max(start, end);
|
||||
const whiteSpaceOrBreakLineRegex = /[\s\n]+/;
|
||||
const txt =
|
||||
cursor < text.length ? text.substr(0, cursor).split(whiteSpaceOrBreakLineRegex) : text.split(whiteSpaceOrBreakLineRegex);
|
||||
const lastWord = txt[txt.length - 1];
|
||||
const autocompleteText = lastWord.substring(1);
|
||||
|
||||
if (!lastWord) {
|
||||
stopAutocomplete();
|
||||
return;
|
||||
}
|
||||
if (!sharing && text.match(/^\//)) {
|
||||
const commandParameter = text.match(/^\/([a-z0-9._-]+) (.+)/im);
|
||||
if (commandParameter) {
|
||||
const db = database.active;
|
||||
const [, command, params] = commandParameter;
|
||||
const commandsCollection = db.get('slash_commands');
|
||||
try {
|
||||
const commandRecord = await commandsCollection.find(command);
|
||||
if (commandRecord.providesPreview) {
|
||||
setAutocompleteParams({ params, text: command, type: '/preview' });
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
setAutocompleteParams({ text: autocompleteText, type: '/' });
|
||||
return;
|
||||
}
|
||||
if (lastWord.match(/^#/)) {
|
||||
setAutocompleteParams({ text: autocompleteText, type: '#' });
|
||||
return;
|
||||
}
|
||||
if (lastWord.match(/^@/)) {
|
||||
setAutocompleteParams({ text: autocompleteText, type: '@' });
|
||||
return;
|
||||
}
|
||||
if (lastWord.match(/^:/)) {
|
||||
setAutocompleteParams({ text: autocompleteText, type: ':' });
|
||||
return;
|
||||
}
|
||||
if (lastWord.match(/^!/) && subscription?.t === 'l') {
|
||||
setAutocompleteParams({ text: autocompleteText, type: '!' });
|
||||
return;
|
||||
}
|
||||
|
||||
stopAutocomplete();
|
||||
}, 300);
|
||||
|
||||
const handleTyping = (isTyping: boolean) => {
|
||||
if (sharing || !rid) return;
|
||||
dispatch(userTyping(rid, isTyping));
|
||||
};
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
onLayout={handleLayout}
|
||||
style={[styles.textInput, { color: colors.fontDefault }]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.fontAnnotation}
|
||||
ref={component => (inputRef.current = component)}
|
||||
blurOnSubmit={false}
|
||||
onChangeText={onChangeText}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
underlineColorAndroid='transparent'
|
||||
defaultValue=''
|
||||
multiline
|
||||
keyboardAppearance={theme === 'light' ? 'light' : 'dark'}
|
||||
testID={`message-composer-input${tmid ? '-thread' : ''}`}
|
||||
/>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
textInput: {
|
||||
flex: 1,
|
||||
minHeight: MIN_HEIGHT,
|
||||
maxHeight: MAX_HEIGHT,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
fontSize: 16,
|
||||
textAlignVertical: 'center',
|
||||
...sharedStyles.textRegular,
|
||||
lineHeight: 22
|
||||
}
|
||||
});
|
|
@ -3,13 +3,12 @@ import { View } from 'react-native';
|
|||
import { KeyboardRegistry } from 'react-native-ui-lib/keyboard';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import store from '../../lib/store';
|
||||
import EmojiPicker from '../EmojiPicker';
|
||||
import styles from './styles';
|
||||
import { ThemeContext, TSupportedThemes } from '../../theme';
|
||||
import { EventTypes } from '../EmojiPicker/interfaces';
|
||||
import { IEmoji } from '../../definitions';
|
||||
import { colors } from '../../lib/constants';
|
||||
import store from '../../../lib/store';
|
||||
import EmojiPicker from '../../EmojiPicker';
|
||||
import { ThemeContext, TSupportedThemes } from '../../../theme';
|
||||
import { EventTypes } from '../../EmojiPicker/interfaces';
|
||||
import { IEmoji } from '../../../definitions';
|
||||
import { colors } from '../../../lib/constants';
|
||||
|
||||
const EmojiKeyboard = ({ theme }: { theme: TSupportedThemes }) => {
|
||||
const onItemClicked = (eventType: EventTypes, emoji?: IEmoji) => {
|
||||
|
@ -24,10 +23,7 @@ const EmojiKeyboard = ({ theme }: { theme: TSupportedThemes }) => {
|
|||
colors: colors[theme]
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={[styles.emojiKeyboardContainer, { borderTopColor: colors[theme].borderColor }]}
|
||||
testID='messagebox-keyboard-emoji'
|
||||
>
|
||||
<View style={{ flex: 1 }} testID='message-composer-keyboard-emoji'>
|
||||
<EmojiPicker onItemClicked={onItemClicked} isEmojiKeyboard={true} />
|
||||
</View>
|
||||
</ThemeContext.Provider>
|
|
@ -1,30 +1,87 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { View, Text, Pressable, FlatList, StyleSheet } from 'react-native';
|
||||
|
||||
import { useTheme } from '../../theme';
|
||||
import I18n from '../../i18n';
|
||||
import { CustomIcon } from '../CustomIcon';
|
||||
import { IEmoji } from '../../definitions';
|
||||
import { useFrequentlyUsedEmoji } from '../../lib/hooks';
|
||||
import { addFrequentlyUsed, searchEmojis } from '../../lib/methods';
|
||||
import { useDebounce } from '../../lib/methods/helpers';
|
||||
import sharedStyles from '../../views/Styles';
|
||||
import { PressableEmoji } from '../EmojiPicker/PressableEmoji';
|
||||
import { EmojiSearch } from '../EmojiPicker/EmojiSearch';
|
||||
import { EMOJI_BUTTON_SIZE } from '../EmojiPicker/styles';
|
||||
import { events, logEvent } from '../../lib/methods/helpers/log';
|
||||
import { MessageInnerContext, useMessageComposerApi, useShowEmojiSearchbar } from '../context';
|
||||
import { useTheme } from '../../../theme';
|
||||
import I18n from '../../../i18n';
|
||||
import { CustomIcon } from '../../CustomIcon';
|
||||
import { IEmoji } from '../../../definitions';
|
||||
import { useFrequentlyUsedEmoji } from '../../../lib/hooks';
|
||||
import { addFrequentlyUsed, searchEmojis } from '../../../lib/methods';
|
||||
import { useDebounce } from '../../../lib/methods/helpers';
|
||||
import sharedStyles from '../../../views/Styles';
|
||||
import { PressableEmoji } from '../../EmojiPicker/PressableEmoji';
|
||||
import { EmojiSearch } from '../../EmojiPicker/EmojiSearch';
|
||||
import { EMOJI_BUTTON_SIZE } from '../../EmojiPicker/styles';
|
||||
|
||||
const BUTTON_HIT_SLOP = { top: 4, right: 4, bottom: 4, left: 4 };
|
||||
|
||||
export const EmojiSearchbar = (): React.ReactElement | null => {
|
||||
const { colors } = useTheme();
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
const { openEmojiKeyboard, closeEmojiKeyboard } = useMessageComposerApi();
|
||||
const { onEmojiSelected } = useContext(MessageInnerContext);
|
||||
const { frequentlyUsed } = useFrequentlyUsedEmoji(true);
|
||||
const [emojis, setEmojis] = useState<IEmoji[]>([]);
|
||||
|
||||
const handleTextChange = useDebounce(async (text: string) => {
|
||||
setSearchText(text);
|
||||
const result = await searchEmojis(text);
|
||||
setEmojis(result);
|
||||
}, 300);
|
||||
|
||||
const handleEmojiSelected = (emoji: IEmoji) => {
|
||||
onEmojiSelected(emoji);
|
||||
addFrequentlyUsed(emoji);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: IEmoji }) => <PressableEmoji emoji={item} onPress={handleEmojiSelected} />;
|
||||
|
||||
if (!showEmojiSearchbar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Use RNGH
|
||||
return (
|
||||
<View style={{ backgroundColor: colors.surfaceLight }}>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={searchText ? emojis : frequentlyUsed}
|
||||
renderItem={renderItem}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
ListEmptyComponent={() => (
|
||||
<View style={styles.emptyContainer} testID='no-results-found'>
|
||||
<Text style={[styles.emptyText, { color: colors.fontHint }]}>{I18n.t('No_results_found')}</Text>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={item => (typeof item === 'string' ? item : item.name)}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
keyboardShouldPersistTaps='always'
|
||||
/>
|
||||
<View style={styles.searchContainer}>
|
||||
<Pressable
|
||||
style={({ pressed }: { pressed: boolean }) => [styles.backButton, { opacity: pressed ? 0.7 : 1 }]}
|
||||
onPress={openEmojiKeyboard}
|
||||
hitSlop={BUTTON_HIT_SLOP}
|
||||
testID='openback-emoji-keyboard'
|
||||
>
|
||||
<CustomIcon name='chevron-left' size={24} color={colors.fontHint} />
|
||||
</Pressable>
|
||||
<View style={styles.inputContainer}>
|
||||
<EmojiSearch onBlur={closeEmojiKeyboard} onChangeText={handleTextChange} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContainer: {
|
||||
height: EMOJI_BUTTON_SIZE,
|
||||
margin: 8,
|
||||
flexGrow: 1
|
||||
},
|
||||
container: {
|
||||
borderTopWidth: 1
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
|
@ -52,65 +109,3 @@ const styles = StyleSheet.create({
|
|||
flex: 1
|
||||
}
|
||||
});
|
||||
|
||||
interface IEmojiSearchBarProps {
|
||||
openEmoji: () => void;
|
||||
closeEmoji: () => void;
|
||||
onEmojiSelected: (emoji: IEmoji) => void;
|
||||
}
|
||||
|
||||
const EmojiSearchBar = ({ openEmoji, closeEmoji, onEmojiSelected }: IEmojiSearchBarProps): React.ReactElement => {
|
||||
const { colors } = useTheme();
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const { frequentlyUsed } = useFrequentlyUsedEmoji(true);
|
||||
const [emojis, setEmojis] = useState<IEmoji[]>([]);
|
||||
|
||||
const handleTextChange = useDebounce(async (text: string) => {
|
||||
logEvent(events.MB_SB_EMOJI_SEARCH);
|
||||
setSearchText(text);
|
||||
const result = await searchEmojis(text);
|
||||
setEmojis(result);
|
||||
}, 300);
|
||||
|
||||
const handleEmojiSelected = (emoji: IEmoji) => {
|
||||
logEvent(events.MB_SB_EMOJI_SELECTED);
|
||||
onEmojiSelected(emoji);
|
||||
addFrequentlyUsed(emoji);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: IEmoji }) => <PressableEmoji emoji={item} onPress={handleEmojiSelected} />;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { borderTopColor: colors.borderColor, backgroundColor: colors.messageboxBackground }]}>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={searchText ? emojis : frequentlyUsed}
|
||||
renderItem={renderItem}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
ListEmptyComponent={() => (
|
||||
<View style={styles.emptyContainer} testID='no-results-found'>
|
||||
<Text style={[styles.emptyText, { color: colors.auxiliaryText }]}>{I18n.t('No_results_found')}</Text>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={item => (typeof item === 'string' ? item : item.name)}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
keyboardShouldPersistTaps='always'
|
||||
/>
|
||||
<View style={styles.searchContainer}>
|
||||
<Pressable
|
||||
style={({ pressed }: { pressed: boolean }) => [styles.backButton, { opacity: pressed ? 0.7 : 1 }]}
|
||||
onPress={openEmoji}
|
||||
hitSlop={BUTTON_HIT_SLOP}
|
||||
testID='openback-emoji-keyboard'
|
||||
>
|
||||
<CustomIcon name='chevron-left' size={24} color={colors.auxiliaryTintColor} />
|
||||
</Pressable>
|
||||
<View style={styles.inputContainer}>
|
||||
<EmojiSearch onBlur={closeEmoji} onChangeText={handleTextChange} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiSearchBar;
|
|
@ -0,0 +1,4 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
export const Gap = () => <View style={{ width: 12 }} />;
|
|
@ -0,0 +1,95 @@
|
|||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import moment from 'moment';
|
||||
|
||||
import { useTheme } from '../../../../theme';
|
||||
import sharedStyles from '../../../../views/Styles';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
import { BaseButton } from '../Buttons';
|
||||
import { useMessage } from '../../hooks';
|
||||
import { useAppSelector } from '../../../../lib/hooks';
|
||||
import { MarkdownPreview } from '../../../markdown';
|
||||
|
||||
export const Quote = ({ messageId }: { messageId: string }) => {
|
||||
const [styles, colors] = useStyle();
|
||||
const message = useMessage(messageId);
|
||||
const useRealName = useAppSelector(({ settings }) => settings.UI_Use_Real_Name);
|
||||
const { onRemoveQuoteMessage } = useRoomContext();
|
||||
|
||||
let username = '';
|
||||
let msg = '';
|
||||
let time = '';
|
||||
|
||||
if (message) {
|
||||
username = useRealName ? message.u?.name || message.u?.username || '' : message.u?.username || '';
|
||||
msg = message.msg || '';
|
||||
time = message.ts ? moment(message.ts).format('LT') : '';
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.root} testID={`composer-quote-${message.id}`}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.title}>
|
||||
<Text style={styles.username} numberOfLines={1}>
|
||||
{username}
|
||||
</Text>
|
||||
<Text style={styles.time}>{time}</Text>
|
||||
</View>
|
||||
<BaseButton
|
||||
icon='close'
|
||||
color={colors.fontDefault}
|
||||
onPress={() => onRemoveQuoteMessage?.(message.id)}
|
||||
accessibilityLabel='Remove_quote_message'
|
||||
testID={`composer-quote-remove-${message.id}`}
|
||||
/>
|
||||
</View>
|
||||
<MarkdownPreview style={[styles.message]} numberOfLines={1} msg={msg} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
function useStyle() {
|
||||
const { colors } = useTheme();
|
||||
const style = {
|
||||
root: {
|
||||
backgroundColor: colors.surfaceTint,
|
||||
height: 64,
|
||||
width: 320,
|
||||
borderColor: colors.strokeExtraLight,
|
||||
borderLeftColor: colors.strokeMedium,
|
||||
borderWidth: 1,
|
||||
borderTopRightRadius: 4,
|
||||
borderBottomRightRadius: 4,
|
||||
paddingLeft: 16,
|
||||
padding: 8,
|
||||
marginRight: 8
|
||||
},
|
||||
header: { flexDirection: 'row', alignItems: 'center' },
|
||||
title: { flexDirection: 'row', flex: 1, alignItems: 'center' },
|
||||
username: {
|
||||
...sharedStyles.textBold,
|
||||
color: colors.fontTitlesLabels,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
flexShrink: 1,
|
||||
paddingRight: 4
|
||||
},
|
||||
time: {
|
||||
...sharedStyles.textRegular,
|
||||
color: colors.fontAnnotation,
|
||||
fontSize: 12,
|
||||
lineHeight: 16
|
||||
},
|
||||
message: {
|
||||
...sharedStyles.textRegular,
|
||||
color: colors.fontDefault,
|
||||
fontSize: 14,
|
||||
lineHeight: 20
|
||||
}
|
||||
} as const;
|
||||
return [style, colors] as const;
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
import { FlatList } from 'react-native';
|
||||
|
||||
import { Quote } from './Quote';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
|
||||
export const Quotes = (): React.ReactElement | null => {
|
||||
const { selectedMessages, action } = useRoomContext();
|
||||
|
||||
if (action !== 'quote') {
|
||||
return null;
|
||||
}
|
||||
return <FlatList data={selectedMessages} renderItem={({ item }) => <Quote messageId={item} />} horizontal />;
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './Quotes';
|
|
@ -0,0 +1,7 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { BaseButton, IBaseButton } from '../Buttons';
|
||||
|
||||
export const CancelButton = ({ onPress }: { onPress: IBaseButton['onPress'] }): ReactElement => (
|
||||
<BaseButton onPress={onPress} testID='message-composer-delete-audio' accessibilityLabel='Cancel' icon='delete' />
|
||||
);
|
|
@ -0,0 +1,43 @@
|
|||
import React, { forwardRef, useImperativeHandle } from 'react';
|
||||
import { FontVariant, Text } from 'react-native';
|
||||
import { Audio } from 'expo-av';
|
||||
|
||||
import sharedStyles from '../../../../views/Styles';
|
||||
import { useTheme } from '../../../../theme';
|
||||
import { formatTime } from './utils';
|
||||
|
||||
export interface IDurationRef {
|
||||
onRecordingStatusUpdate: (status: Audio.RecordingStatus) => void;
|
||||
}
|
||||
|
||||
export const Duration = forwardRef<IDurationRef>((_, ref) => {
|
||||
const [styles] = useStyle();
|
||||
const [duration, setDuration] = React.useState('00:00');
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onRecordingStatusUpdate
|
||||
}));
|
||||
|
||||
const onRecordingStatusUpdate = (status: Audio.RecordingStatus) => {
|
||||
if (!status.isRecording) {
|
||||
return;
|
||||
}
|
||||
setDuration(formatTime(Math.floor(status.durationMillis / 1000)));
|
||||
};
|
||||
|
||||
return <Text style={styles.text}>{duration}</Text>;
|
||||
});
|
||||
|
||||
function useStyle() {
|
||||
const { colors } = useTheme();
|
||||
const styles = {
|
||||
text: {
|
||||
marginLeft: 12,
|
||||
fontSize: 16,
|
||||
...sharedStyles.textRegular,
|
||||
color: colors.fontDefault,
|
||||
fontVariant: ['tabular-nums'] as FontVariant[]
|
||||
}
|
||||
} as const;
|
||||
return [styles, colors] as const;
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
import { View, Text } from 'react-native';
|
||||
import React, { ReactElement, useEffect, useRef } from 'react';
|
||||
import { Audio } from 'expo-av';
|
||||
import { getInfoAsync } from 'expo-file-system';
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
import { shallowEqual } from 'react-redux';
|
||||
|
||||
import { useTheme } from '../../../../theme';
|
||||
import { BaseButton } from '../Buttons';
|
||||
import { CustomIcon } from '../../../CustomIcon';
|
||||
import sharedStyles from '../../../../views/Styles';
|
||||
import { ReviewButton } from './ReviewButton';
|
||||
import { useMessageComposerApi } from '../../context';
|
||||
import { sendFileMessage } from '../../../../lib/methods';
|
||||
import { IUpload } from '../../../../definitions';
|
||||
import log from '../../../../lib/methods/helpers/log';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
import { useAppSelector } from '../../../../lib/hooks';
|
||||
import { useCanUploadFile } from '../../hooks';
|
||||
import { Duration, IDurationRef } from './Duration';
|
||||
import { RECORDING_EXTENSION, RECORDING_MODE, RECORDING_SETTINGS } from './constants';
|
||||
import AudioPlayer from '../../../AudioPlayer';
|
||||
import { CancelButton } from './CancelButton';
|
||||
import i18n from '../../../../i18n';
|
||||
|
||||
export const RecordAudio = (): ReactElement | null => {
|
||||
const [styles, colors] = useStyle();
|
||||
const recordingRef = useRef<Audio.Recording>();
|
||||
const durationRef = useRef<IDurationRef>({} as IDurationRef);
|
||||
const [status, setStatus] = React.useState<'recording' | 'reviewing'>('recording');
|
||||
const { setRecordingAudio } = useMessageComposerApi();
|
||||
const { rid, tmid } = useRoomContext();
|
||||
const server = useAppSelector(state => state.server.server);
|
||||
const user = useAppSelector(state => ({ id: state.login.user.id, token: state.login.user.token }), shallowEqual);
|
||||
const permissionToUpload = useCanUploadFile(rid);
|
||||
useKeepAwake();
|
||||
|
||||
useEffect(() => {
|
||||
const record = async () => {
|
||||
try {
|
||||
await Audio.setAudioModeAsync(RECORDING_MODE);
|
||||
recordingRef.current = new Audio.Recording();
|
||||
await recordingRef.current.prepareToRecordAsync(RECORDING_SETTINGS);
|
||||
recordingRef.current.setOnRecordingStatusUpdate(durationRef.current.onRecordingStatusUpdate);
|
||||
await recordingRef.current.startAsync();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
record();
|
||||
|
||||
return () => {
|
||||
try {
|
||||
recordingRef.current?.stopAndUnloadAsync();
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cancelRecording = async () => {
|
||||
try {
|
||||
await recordingRef.current?.stopAndUnloadAsync();
|
||||
} catch {
|
||||
// Do nothing
|
||||
} finally {
|
||||
setRecordingAudio(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goReview = async () => {
|
||||
try {
|
||||
await recordingRef.current?.stopAndUnloadAsync();
|
||||
setStatus('reviewing');
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
};
|
||||
|
||||
const sendAudio = async () => {
|
||||
try {
|
||||
if (!rid) return;
|
||||
setRecordingAudio(false);
|
||||
const fileURI = recordingRef.current?.getURI();
|
||||
const fileData = await getInfoAsync(fileURI as string);
|
||||
const fileInfo = {
|
||||
name: `${Date.now()}${RECORDING_EXTENSION}`,
|
||||
mime: 'audio/aac',
|
||||
type: 'audio/aac',
|
||||
store: 'Uploads',
|
||||
path: fileURI,
|
||||
size: fileData.exists ? fileData.size : null
|
||||
} as IUpload;
|
||||
|
||||
if (fileInfo) {
|
||||
if (permissionToUpload) {
|
||||
await sendFileMessage(rid, fileInfo, tmid, server, user);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!rid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'reviewing') {
|
||||
return (
|
||||
<View style={styles.review}>
|
||||
<View style={styles.audioPlayer}>
|
||||
<AudioPlayer fileUri={recordingRef.current?.getURI() ?? ''} rid={rid} downloadState='downloaded' />
|
||||
</View>
|
||||
<View style={styles.buttons}>
|
||||
<CancelButton onPress={cancelRecording} />
|
||||
<View style={{ flex: 1 }} />
|
||||
<BaseButton
|
||||
onPress={sendAudio}
|
||||
testID='message-composer-send'
|
||||
accessibilityLabel='Send_message'
|
||||
icon='send-filled'
|
||||
color={colors.buttonBackgroundPrimaryDefault}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.recording}>
|
||||
<View style={styles.duration}>
|
||||
<CustomIcon name='microphone' size={24} color={colors.fontDanger} />
|
||||
<Duration ref={durationRef} />
|
||||
</View>
|
||||
<View style={styles.buttons}>
|
||||
<CancelButton onPress={cancelRecording} />
|
||||
<View style={styles.recordingNote}>
|
||||
<Text style={styles.recordingNoteText}>{i18n.t('Recording_audio_in_progress')}</Text>
|
||||
</View>
|
||||
<ReviewButton onPress={goReview} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
function useStyle() {
|
||||
const { colors } = useTheme();
|
||||
const style = {
|
||||
review: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
backgroundColor: colors.surfaceLight,
|
||||
borderTopColor: colors.strokeLight
|
||||
},
|
||||
recording: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
backgroundColor: colors.surfaceLight,
|
||||
borderTopColor: colors.strokeLight
|
||||
},
|
||||
duration: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
audioPlayer: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 8
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
recordingNote: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
recordingNoteText: {
|
||||
fontSize: 14,
|
||||
...sharedStyles.textRegular,
|
||||
color: colors.fontSecondaryInfo
|
||||
}
|
||||
} as const;
|
||||
return [style, colors] as const;
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { StyleSheet, View } from 'react-native';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { BorderlessButton } from 'react-native-gesture-handler';
|
||||
|
||||
import { useTheme } from '../../../../theme';
|
||||
import { CustomIcon } from '../../../CustomIcon';
|
||||
import { hitSlop } from '../Buttons';
|
||||
|
||||
export const ReviewButton = ({ onPress }: { onPress: Function }): ReactElement => {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<BorderlessButton
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
backgroundColor: colors.buttonBackgroundPrimaryDefault
|
||||
}
|
||||
]}
|
||||
onPress={() => onPress()}
|
||||
hitSlop={hitSlop}
|
||||
>
|
||||
<View accessible accessibilityLabel={'Cancel_recording'} accessibilityRole='button'>
|
||||
<CustomIcon name={'arrow-right'} size={24} color={colors.fontWhite} />
|
||||
</View>
|
||||
</BorderlessButton>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16
|
||||
}
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
import { Audio, AudioMode, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
|
||||
import { RecordingOptions } from 'expo-av/build/Audio';
|
||||
|
||||
export const RECORDING_EXTENSION = '.aac';
|
||||
export const RECORDING_SETTINGS: RecordingOptions = {
|
||||
android: {
|
||||
// Settings related to audio encoding.
|
||||
extension: RECORDING_EXTENSION,
|
||||
outputFormat: Audio.RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_AAC_ADTS,
|
||||
audioEncoder: Audio.RECORDING_OPTION_ANDROID_AUDIO_ENCODER_AAC,
|
||||
// Settings related to audio quality.
|
||||
sampleRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.android.sampleRate,
|
||||
numberOfChannels: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.android.numberOfChannels,
|
||||
bitRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.android.bitRate
|
||||
},
|
||||
ios: {
|
||||
// Settings related to audio encoding.
|
||||
extension: RECORDING_EXTENSION,
|
||||
audioQuality: Audio.RECORDING_OPTION_IOS_AUDIO_QUALITY_MEDIUM,
|
||||
outputFormat: Audio.RECORDING_OPTION_IOS_OUTPUT_FORMAT_MPEG4AAC,
|
||||
// Settings related to audio quality.
|
||||
sampleRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.ios.sampleRate,
|
||||
numberOfChannels: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.ios.numberOfChannels,
|
||||
bitRate: Audio.RECORDING_OPTIONS_PRESET_LOW_QUALITY.ios.bitRate
|
||||
},
|
||||
web: {},
|
||||
keepAudioActiveHint: true
|
||||
};
|
||||
|
||||
export const RECORDING_MODE: AudioMode = {
|
||||
allowsRecordingIOS: true,
|
||||
playsInSilentModeIOS: true,
|
||||
staysActiveInBackground: true,
|
||||
shouldDuckAndroid: true,
|
||||
playThroughEarpieceAndroid: false,
|
||||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './RecordAudio';
|
|
@ -0,0 +1,7 @@
|
|||
export const formatTime = function (time: number) {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = time % 60;
|
||||
const min = minutes < 10 ? `0${minutes}` : minutes;
|
||||
const sec = seconds < 10 ? `0${seconds}` : seconds;
|
||||
return `${min}:${sec}`;
|
||||
};
|
|
@ -0,0 +1,98 @@
|
|||
import { TouchableWithoutFeedback } from 'react-native-gesture-handler';
|
||||
import { StyleSheet, Text } from 'react-native';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
|
||||
import { useRoomContext } from '../../../views/RoomView/context';
|
||||
import { useAlsoSendThreadToChannel, useMessageComposerApi, useShowEmojiSearchbar } from '../context';
|
||||
import { CustomIcon } from '../../CustomIcon';
|
||||
import { useTheme } from '../../../theme';
|
||||
import sharedStyles from '../../../views/Styles';
|
||||
import I18n from '../../../i18n';
|
||||
import { useAppSelector } from '../../../lib/hooks';
|
||||
import database from '../../../lib/database';
|
||||
import { compareServerVersion } from '../../../lib/methods/helpers';
|
||||
|
||||
export const SendThreadToChannel = (): React.ReactElement | null => {
|
||||
const alsoSendThreadToChannel = useAlsoSendThreadToChannel();
|
||||
const { setAlsoSendThreadToChannel } = useMessageComposerApi();
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
const { tmid } = useRoomContext();
|
||||
const { colors } = useTheme();
|
||||
const subscription = useRef<Subscription>();
|
||||
const alsoSendThreadToChannelUserPref = useAppSelector(state => state.login.user.alsoSendThreadToChannel);
|
||||
const serverVersion = useAppSelector(state => state.server.version);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tmid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (compareServerVersion(serverVersion, 'lowerThan', '5.0.0')) {
|
||||
setAlsoSendThreadToChannel(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (alsoSendThreadToChannelUserPref === 'always') {
|
||||
setAlsoSendThreadToChannel(true);
|
||||
return;
|
||||
}
|
||||
if (alsoSendThreadToChannelUserPref === 'never') {
|
||||
setAlsoSendThreadToChannel(false);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* "default" sends a to channel only in the first message of the thread.
|
||||
* We check if the thread exists by observing/subscribing to the query with tmid.
|
||||
* If it doesn't exist, it means that this is the first message of the thread. So it's true.
|
||||
* Otherwise, it's false.
|
||||
* */
|
||||
if (alsoSendThreadToChannelUserPref === 'default') {
|
||||
const db = database.active;
|
||||
const observable = db.get('threads').query(Q.where('tmid', tmid)).observe();
|
||||
|
||||
subscription.current = observable.subscribe(result => {
|
||||
setAlsoSendThreadToChannel(!result.length);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscription.current?.unsubscribe();
|
||||
};
|
||||
}, [tmid, alsoSendThreadToChannelUserPref, serverVersion, setAlsoSendThreadToChannel]);
|
||||
|
||||
if (!tmid || showEmojiSearchbar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback
|
||||
style={styles.container}
|
||||
onPress={() => setAlsoSendThreadToChannel(!alsoSendThreadToChannel)}
|
||||
testID='message-composer-send-to-channel'
|
||||
>
|
||||
<CustomIcon
|
||||
testID={alsoSendThreadToChannel ? 'send-to-channel-checked' : 'send-to-channel-unchecked'}
|
||||
name={alsoSendThreadToChannel ? 'checkbox-checked' : 'checkbox-unchecked'}
|
||||
size={24}
|
||||
color={alsoSendThreadToChannel ? colors.buttonBackgroundPrimaryDefault : colors.strokeDark}
|
||||
/>
|
||||
<Text style={[styles.text, { color: colors.fontSecondaryInfo }]}>{I18n.t('Message_composer_Send_to_channel')}</Text>
|
||||
</TouchableWithoutFeedback>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12
|
||||
},
|
||||
text: {
|
||||
fontSize: 14,
|
||||
marginLeft: 8,
|
||||
...sharedStyles.textRegular
|
||||
}
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
export const Container = ({ children }: { children: (ReactElement | null)[] }): ReactElement => (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 12
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
|
@ -0,0 +1,43 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { ActionsButton, BaseButton } from '..';
|
||||
import { useMessageComposerApi } from '../../context';
|
||||
import { Gap } from '../Gap';
|
||||
import { emitter } from '../../emitter';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
|
||||
export const Default = (): ReactElement | null => {
|
||||
const { sharing } = useRoomContext();
|
||||
const { openEmojiKeyboard, setMarkdownToolbar } = useMessageComposerApi();
|
||||
|
||||
return (
|
||||
<>
|
||||
{sharing ? null : (
|
||||
<>
|
||||
<ActionsButton />
|
||||
<Gap />
|
||||
</>
|
||||
)}
|
||||
<BaseButton
|
||||
onPress={openEmojiKeyboard}
|
||||
testID='message-composer-open-emoji'
|
||||
accessibilityLabel='Open_emoji_selector'
|
||||
icon='emoji'
|
||||
/>
|
||||
<Gap />
|
||||
<BaseButton
|
||||
onPress={() => setMarkdownToolbar(true)}
|
||||
testID='message-composer-open-markdown'
|
||||
accessibilityLabel='Open_markdown_tools'
|
||||
icon='text-format'
|
||||
/>
|
||||
<Gap />
|
||||
<BaseButton
|
||||
onPress={() => emitter.emit('toolbarMention')}
|
||||
testID='message-composer-mention'
|
||||
accessibilityLabel='Open_mention_autocomplete'
|
||||
icon='mention'
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { MicOrSendButton, ActionsButton, BaseButton } from '..';
|
||||
import { useMessageComposerApi } from '../../context';
|
||||
import { Container } from './Container';
|
||||
import { EmptySpace } from './EmptySpace';
|
||||
import { Gap } from '../Gap';
|
||||
|
||||
export const EmojiKeyboard = (): ReactElement => {
|
||||
const { closeEmojiKeyboard } = useMessageComposerApi();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ActionsButton />
|
||||
<Gap />
|
||||
<BaseButton
|
||||
onPress={closeEmojiKeyboard}
|
||||
testID='message-composer-close-emoji'
|
||||
accessibilityLabel='Close_emoji_selector'
|
||||
icon='keyboard'
|
||||
/>
|
||||
<EmptySpace />
|
||||
<MicOrSendButton />
|
||||
</Container>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
export const EmptySpace = () => <View style={{ flex: 1 }} />;
|
|
@ -0,0 +1,44 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { BaseButton } from '..';
|
||||
import { useMessageComposerApi } from '../../context';
|
||||
import { Gap } from '../Gap';
|
||||
import { TMarkdownStyle } from '../../interfaces';
|
||||
import { emitter } from '../../emitter';
|
||||
|
||||
export const Markdown = (): ReactElement => {
|
||||
const { setMarkdownToolbar } = useMessageComposerApi();
|
||||
|
||||
const onPress = (style: TMarkdownStyle) => emitter.emit('addMarkdown', { style });
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseButton
|
||||
onPress={() => setMarkdownToolbar(false)}
|
||||
testID='message-composer-close-markdown'
|
||||
accessibilityLabel='Close'
|
||||
icon='close'
|
||||
/>
|
||||
<Gap />
|
||||
<BaseButton onPress={() => onPress('bold')} testID='message-composer-bold' accessibilityLabel='Bold' icon='bold' />
|
||||
<Gap />
|
||||
<BaseButton onPress={() => onPress('italic')} testID='message-composer-italic' accessibilityLabel='Italic' icon='italic' />
|
||||
<Gap />
|
||||
<BaseButton
|
||||
onPress={() => onPress('strike')}
|
||||
testID='message-composer-strike'
|
||||
accessibilityLabel='Strikethrough'
|
||||
icon='strike'
|
||||
/>
|
||||
<Gap />
|
||||
<BaseButton onPress={() => onPress('code')} testID='message-composer-code' accessibilityLabel='Inline_code' icon='code' />
|
||||
<Gap />
|
||||
<BaseButton
|
||||
onPress={() => onPress('code-block')}
|
||||
testID='message-composer-code-block'
|
||||
accessibilityLabel='Code_block'
|
||||
icon='code-block'
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
|
||||
import { useFocused, useShowEmojiKeyboard, useShowEmojiSearchbar, useShowMarkdownToolbar } from '../../context';
|
||||
import { Markdown } from './Markdown';
|
||||
import { Default } from './Default';
|
||||
import { EmojiKeyboard } from './EmojiKeyboard';
|
||||
import { Container } from './Container';
|
||||
import { MicOrSendButton } from '../Buttons';
|
||||
import { EmptySpace } from './EmptySpace';
|
||||
import { CancelEdit } from '../CancelEdit';
|
||||
|
||||
export const Toolbar = (): ReactElement | null => {
|
||||
const focused = useFocused();
|
||||
const showEmojiKeyboard = useShowEmojiKeyboard();
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
const showMarkdownToolbar = useShowMarkdownToolbar();
|
||||
|
||||
if (showEmojiSearchbar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (showEmojiKeyboard) {
|
||||
return <EmojiKeyboard />;
|
||||
}
|
||||
|
||||
if (!focused) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{showMarkdownToolbar ? <Markdown /> : <Default />}
|
||||
<EmptySpace />
|
||||
<CancelEdit />
|
||||
<MicOrSendButton />
|
||||
</Container>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './Toolbar';
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { useFocused, useShowEmojiKeyboard, useShowEmojiSearchbar } from '../../context';
|
||||
import { ActionsButton } from '../Buttons';
|
||||
import { MIN_HEIGHT } from '../../constants';
|
||||
import { useRoomContext } from '../../../../views/RoomView/context';
|
||||
|
||||
export const Left = () => {
|
||||
const { sharing } = useRoomContext();
|
||||
const focused = useFocused();
|
||||
const showEmojiKeyboard = useShowEmojiKeyboard();
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
if (focused || showEmojiKeyboard || showEmojiSearchbar || sharing) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={{ height: MIN_HEIGHT, paddingRight: 12, justifyContent: 'center' }}>
|
||||
<ActionsButton />
|
||||
</View>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { useFocused, useShowEmojiKeyboard, useShowEmojiSearchbar } from '../../context';
|
||||
import { MicOrSendButton } from '../Buttons';
|
||||
import { MIN_HEIGHT } from '../../constants';
|
||||
import { CancelEdit } from '../CancelEdit';
|
||||
|
||||
export const Right = () => {
|
||||
const focused = useFocused();
|
||||
const showEmojiKeyboard = useShowEmojiKeyboard();
|
||||
const showEmojiSearchbar = useShowEmojiSearchbar();
|
||||
if (focused || showEmojiKeyboard || showEmojiSearchbar) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={{ height: MIN_HEIGHT, paddingLeft: 12, alignItems: 'center', flexDirection: 'row' }}>
|
||||
<CancelEdit />
|
||||
<MicOrSendButton />
|
||||
</View>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export * from './Left';
|
||||
export * from './Right';
|
|
@ -0,0 +1,8 @@
|
|||
export * from './Autocomplete';
|
||||
export * from './Buttons';
|
||||
export * from './ComposerInput';
|
||||
export * from './EmojiSearchbar';
|
||||
export * from './Toolbar';
|
||||
export * from './Unfocused';
|
||||
export * from './Quotes';
|
||||
export * from './SendThreadToChannel';
|
|
@ -0,0 +1,35 @@
|
|||
import { Options } from 'react-native-image-crop-picker';
|
||||
|
||||
import { TMarkdownStyle } from './interfaces';
|
||||
|
||||
export const IMAGE_PICKER_CONFIG = {
|
||||
cropping: true,
|
||||
avoidEmptySpaceAroundImage: false,
|
||||
freeStyleCropEnabled: true,
|
||||
forceJpg: true
|
||||
};
|
||||
|
||||
export const LIBRARY_PICKER_CONFIG: Options = {
|
||||
multiple: true,
|
||||
compressVideoPreset: 'Passthrough',
|
||||
mediaType: 'any'
|
||||
};
|
||||
|
||||
export const VIDEO_PICKER_CONFIG: Options = {
|
||||
mediaType: 'video'
|
||||
};
|
||||
|
||||
export const TIMEOUT_CLOSE_EMOJI_KEYBOARD = 300;
|
||||
|
||||
export const MIN_HEIGHT = 48;
|
||||
export const MAX_HEIGHT = 200;
|
||||
|
||||
export const NO_CANNED_RESPONSES = 'no-canned-responses';
|
||||
|
||||
export const MARKDOWN_STYLES: Record<TMarkdownStyle, string> = {
|
||||
bold: '*',
|
||||
italic: '_',
|
||||
strike: '~',
|
||||
code: '`',
|
||||
'code-block': '```'
|
||||
};
|
|
@ -0,0 +1,195 @@
|
|||
import React, { createContext, ReactElement, useContext, useMemo, useReducer } from 'react';
|
||||
|
||||
import { IEmoji } from '../../definitions';
|
||||
import { IAutocompleteBase, TMicOrSend } from './interfaces';
|
||||
import { animateNextTransition } from '../../lib/methods/helpers';
|
||||
|
||||
type TMessageComposerContextApi = {
|
||||
setKeyboardHeight: (height: number) => void;
|
||||
setTrackingViewHeight: (height: number) => void;
|
||||
openEmojiKeyboard(): void;
|
||||
closeEmojiKeyboard(): void;
|
||||
openSearchEmojiKeyboard(): void;
|
||||
closeSearchEmojiKeyboard(): void;
|
||||
setFocused(focused: boolean): void;
|
||||
setMicOrSend(micOrSend: TMicOrSend): void;
|
||||
setMarkdownToolbar(showMarkdownToolbar: boolean): void;
|
||||
setAlsoSendThreadToChannel(alsoSendThreadToChannel: boolean): void;
|
||||
setRecordingAudio(recordingAudio: boolean): void;
|
||||
setAutocompleteParams(params: IAutocompleteBase): void;
|
||||
};
|
||||
|
||||
const FocusedContext = createContext<State['focused']>({} as State['focused']);
|
||||
const MicOrSendContext = createContext<State['micOrSend']>({} as State['micOrSend']);
|
||||
const ShowMarkdownToolbarContext = createContext<State['showMarkdownToolbar']>({} as State['showMarkdownToolbar']);
|
||||
const ShowEmojiKeyboardContext = createContext<State['showEmojiKeyboard']>({} as State['showEmojiKeyboard']);
|
||||
const ShowEmojiSearchbarContext = createContext<State['showEmojiSearchbar']>({} as State['showEmojiSearchbar']);
|
||||
const KeyboardHeightContext = createContext<State['keyboardHeight']>({} as State['keyboardHeight']);
|
||||
const TrackingViewHeightContext = createContext<State['trackingViewHeight']>({} as State['trackingViewHeight']);
|
||||
const AlsoSendThreadToChannelContext = createContext<State['alsoSendThreadToChannel']>({} as State['alsoSendThreadToChannel']);
|
||||
const RecordingAudioContext = createContext<State['recordingAudio']>({} as State['recordingAudio']);
|
||||
const AutocompleteParamsContext = createContext<State['autocompleteParams']>({} as State['autocompleteParams']);
|
||||
const MessageComposerContextApi = createContext<TMessageComposerContextApi>({} as TMessageComposerContextApi);
|
||||
|
||||
export const useMessageComposerApi = (): TMessageComposerContextApi => useContext(MessageComposerContextApi);
|
||||
export const useFocused = (): State['focused'] => useContext(FocusedContext);
|
||||
export const useMicOrSend = (): State['micOrSend'] => useContext(MicOrSendContext);
|
||||
export const useShowMarkdownToolbar = (): State['showMarkdownToolbar'] => useContext(ShowMarkdownToolbarContext);
|
||||
export const useShowEmojiKeyboard = (): State['showEmojiKeyboard'] => useContext(ShowEmojiKeyboardContext);
|
||||
export const useShowEmojiSearchbar = (): State['showEmojiSearchbar'] => useContext(ShowEmojiSearchbarContext);
|
||||
export const useKeyboardHeight = (): State['keyboardHeight'] => useContext(KeyboardHeightContext);
|
||||
export const useTrackingViewHeight = (): State['trackingViewHeight'] => useContext(TrackingViewHeightContext);
|
||||
export const useAlsoSendThreadToChannel = (): State['alsoSendThreadToChannel'] => useContext(AlsoSendThreadToChannelContext);
|
||||
export const useRecordingAudio = (): State['recordingAudio'] => useContext(RecordingAudioContext);
|
||||
export const useAutocompleteParams = (): State['autocompleteParams'] => useContext(AutocompleteParamsContext);
|
||||
|
||||
// TODO: rename
|
||||
type TMessageInnerContext = {
|
||||
sendMessage(): void;
|
||||
onEmojiSelected(emoji: IEmoji): void;
|
||||
// TODO: action should be required
|
||||
closeEmojiKeyboardAndAction(action?: Function, params?: any): void;
|
||||
};
|
||||
|
||||
// TODO: rename
|
||||
export const MessageInnerContext = createContext<TMessageInnerContext>({
|
||||
sendMessage: () => {},
|
||||
onEmojiSelected: () => {},
|
||||
closeEmojiKeyboardAndAction: () => {}
|
||||
});
|
||||
|
||||
type State = {
|
||||
showEmojiKeyboard: boolean;
|
||||
showEmojiSearchbar: boolean;
|
||||
focused: boolean;
|
||||
trackingViewHeight: number;
|
||||
keyboardHeight: number;
|
||||
micOrSend: TMicOrSend;
|
||||
showMarkdownToolbar: boolean;
|
||||
alsoSendThreadToChannel: boolean;
|
||||
recordingAudio: boolean;
|
||||
autocompleteParams: IAutocompleteBase;
|
||||
};
|
||||
|
||||
type Actions =
|
||||
| { type: 'updateEmojiKeyboard'; showEmojiKeyboard: boolean }
|
||||
| { type: 'updateEmojiSearchbar'; showEmojiSearchbar: boolean }
|
||||
| { type: 'updateFocused'; focused: boolean }
|
||||
| { type: 'updateTrackingViewHeight'; trackingViewHeight: number }
|
||||
| { type: 'updateKeyboardHeight'; keyboardHeight: number }
|
||||
| { type: 'openEmojiKeyboard' }
|
||||
| { type: 'closeEmojiKeyboard' }
|
||||
| { type: 'openSearchEmojiKeyboard' }
|
||||
| { type: 'closeSearchEmojiKeyboard' }
|
||||
| { type: 'setMicOrSend'; micOrSend: TMicOrSend }
|
||||
| { type: 'setMarkdownToolbar'; showMarkdownToolbar: boolean }
|
||||
| { type: 'setAlsoSendThreadToChannel'; alsoSendThreadToChannel: boolean }
|
||||
| { type: 'setRecordingAudio'; recordingAudio: boolean }
|
||||
| { type: 'setAutocompleteParams'; params: IAutocompleteBase };
|
||||
|
||||
const reducer = (state: State, action: Actions): State => {
|
||||
switch (action.type) {
|
||||
case 'updateEmojiKeyboard':
|
||||
return { ...state, showEmojiKeyboard: action.showEmojiKeyboard };
|
||||
case 'updateEmojiSearchbar':
|
||||
return { ...state, showEmojiSearchbar: action.showEmojiSearchbar };
|
||||
case 'updateFocused':
|
||||
animateNextTransition();
|
||||
return { ...state, focused: action.focused };
|
||||
case 'updateTrackingViewHeight':
|
||||
return { ...state, trackingViewHeight: action.trackingViewHeight };
|
||||
case 'updateKeyboardHeight':
|
||||
return { ...state, keyboardHeight: action.keyboardHeight };
|
||||
case 'openEmojiKeyboard':
|
||||
return { ...state, showEmojiKeyboard: true, showEmojiSearchbar: false };
|
||||
case 'openSearchEmojiKeyboard':
|
||||
return { ...state, showEmojiKeyboard: false, showEmojiSearchbar: true };
|
||||
case 'closeEmojiKeyboard':
|
||||
return { ...state, showEmojiKeyboard: false, showEmojiSearchbar: false };
|
||||
case 'closeSearchEmojiKeyboard':
|
||||
return { ...state, showEmojiSearchbar: false };
|
||||
case 'setMicOrSend':
|
||||
return { ...state, micOrSend: action.micOrSend };
|
||||
case 'setMarkdownToolbar':
|
||||
animateNextTransition();
|
||||
return { ...state, showMarkdownToolbar: action.showMarkdownToolbar };
|
||||
case 'setAlsoSendThreadToChannel':
|
||||
return { ...state, alsoSendThreadToChannel: action.alsoSendThreadToChannel };
|
||||
case 'setRecordingAudio':
|
||||
animateNextTransition();
|
||||
return { ...state, recordingAudio: action.recordingAudio };
|
||||
case 'setAutocompleteParams':
|
||||
return { ...state, autocompleteParams: action.params };
|
||||
}
|
||||
};
|
||||
|
||||
export const MessageComposerProvider = ({ children }: { children: ReactElement }): ReactElement => {
|
||||
const [state, dispatch] = useReducer(reducer, { keyboardHeight: 0, autocompleteParams: { text: '', type: null } } as State);
|
||||
|
||||
const api = useMemo(() => {
|
||||
const setFocused = (focused: boolean) => dispatch({ type: 'updateFocused', focused });
|
||||
|
||||
const setKeyboardHeight = (keyboardHeight: number) => dispatch({ type: 'updateKeyboardHeight', keyboardHeight });
|
||||
|
||||
const setTrackingViewHeight = (trackingViewHeight: number) =>
|
||||
dispatch({ type: 'updateTrackingViewHeight', trackingViewHeight });
|
||||
|
||||
const openEmojiKeyboard = () => dispatch({ type: 'openEmojiKeyboard' });
|
||||
|
||||
const closeEmojiKeyboard = () => dispatch({ type: 'closeEmojiKeyboard' });
|
||||
|
||||
const openSearchEmojiKeyboard = () => dispatch({ type: 'openSearchEmojiKeyboard' });
|
||||
|
||||
const closeSearchEmojiKeyboard = () => dispatch({ type: 'closeSearchEmojiKeyboard' });
|
||||
|
||||
const setMicOrSend = (micOrSend: TMicOrSend) => dispatch({ type: 'setMicOrSend', micOrSend });
|
||||
|
||||
const setMarkdownToolbar = (showMarkdownToolbar: boolean) => dispatch({ type: 'setMarkdownToolbar', showMarkdownToolbar });
|
||||
|
||||
const setAlsoSendThreadToChannel = (alsoSendThreadToChannel: boolean) =>
|
||||
dispatch({ type: 'setAlsoSendThreadToChannel', alsoSendThreadToChannel });
|
||||
|
||||
const setRecordingAudio = (recordingAudio: boolean) => dispatch({ type: 'setRecordingAudio', recordingAudio });
|
||||
|
||||
const setAutocompleteParams = (params: IAutocompleteBase) => dispatch({ type: 'setAutocompleteParams', params });
|
||||
|
||||
return {
|
||||
setFocused,
|
||||
setKeyboardHeight,
|
||||
setTrackingViewHeight,
|
||||
openEmojiKeyboard,
|
||||
closeEmojiKeyboard,
|
||||
openSearchEmojiKeyboard,
|
||||
closeSearchEmojiKeyboard,
|
||||
setMicOrSend,
|
||||
setMarkdownToolbar,
|
||||
setAlsoSendThreadToChannel,
|
||||
setRecordingAudio,
|
||||
setAutocompleteParams
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MessageComposerContextApi.Provider value={api}>
|
||||
<ShowEmojiKeyboardContext.Provider value={state.showEmojiKeyboard}>
|
||||
<ShowEmojiSearchbarContext.Provider value={state.showEmojiSearchbar}>
|
||||
<FocusedContext.Provider value={state.focused}>
|
||||
<KeyboardHeightContext.Provider value={state.keyboardHeight}>
|
||||
<TrackingViewHeightContext.Provider value={state.trackingViewHeight}>
|
||||
<ShowMarkdownToolbarContext.Provider value={state.showMarkdownToolbar}>
|
||||
<AlsoSendThreadToChannelContext.Provider value={state.alsoSendThreadToChannel}>
|
||||
<RecordingAudioContext.Provider value={state.recordingAudio}>
|
||||
<AutocompleteParamsContext.Provider value={state.autocompleteParams}>
|
||||
<MicOrSendContext.Provider value={state.micOrSend}>{children}</MicOrSendContext.Provider>
|
||||
</AutocompleteParamsContext.Provider>
|
||||
</RecordingAudioContext.Provider>
|
||||
</AlsoSendThreadToChannelContext.Provider>
|
||||
</ShowMarkdownToolbarContext.Provider>
|
||||
</TrackingViewHeightContext.Provider>
|
||||
</KeyboardHeightContext.Provider>
|
||||
</FocusedContext.Provider>
|
||||
</ShowEmojiSearchbarContext.Provider>
|
||||
</ShowEmojiKeyboardContext.Provider>
|
||||
</MessageComposerContextApi.Provider>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,14 @@
|
|||
import mitt from 'mitt';
|
||||
|
||||
import { TMarkdownStyle } from './interfaces';
|
||||
|
||||
type Events = {
|
||||
toolbarMention: undefined;
|
||||
addMarkdown: {
|
||||
style: TMarkdownStyle;
|
||||
};
|
||||
};
|
||||
|
||||
export const emitter = mitt<Events>();
|
||||
|
||||
emitter.on('*', (type, e) => console.log(type, e));
|
|
@ -0,0 +1,50 @@
|
|||
import log from '../../../lib/methods/helpers/log';
|
||||
import database from '../../../lib/database';
|
||||
import { getSubscriptionByRoomId } from '../../../lib/database/services/Subscription';
|
||||
import { getThreadById } from '../../../lib/database/services/Thread';
|
||||
|
||||
export const loadDraftMessage = async ({ rid, tmid }: { rid?: string; tmid?: string }): Promise<string> => {
|
||||
if (tmid) {
|
||||
const thread = await getThreadById(tmid);
|
||||
if (thread && thread.draftMessage) {
|
||||
return thread.draftMessage;
|
||||
}
|
||||
} else if (rid) {
|
||||
const subscription = await getSubscriptionByRoomId(rid);
|
||||
if (subscription && subscription.draftMessage) {
|
||||
return subscription.draftMessage;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export const saveDraftMessage = async ({
|
||||
rid,
|
||||
tmid,
|
||||
draftMessage
|
||||
}: {
|
||||
rid?: string;
|
||||
tmid?: string;
|
||||
draftMessage: string;
|
||||
}): Promise<void> => {
|
||||
let obj;
|
||||
if (tmid) {
|
||||
obj = await getThreadById(tmid);
|
||||
} else if (rid) {
|
||||
obj = await getSubscriptionByRoomId(rid);
|
||||
}
|
||||
if (obj && obj.draftMessage !== draftMessage) {
|
||||
try {
|
||||
const db = database.active;
|
||||
const object = obj;
|
||||
await db.write(async () => {
|
||||
await object.update(r => {
|
||||
r.draftMessage = draftMessage;
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
import { TAutocompleteItem } from '../interfaces';
|
||||
|
||||
export const fetchIsAllOrHere = (item: TAutocompleteItem) => item.id === 'all' || item.id === 'here';
|
|
@ -1,6 +1,6 @@
|
|||
import { ImageOrVideo } from 'react-native-image-crop-picker';
|
||||
|
||||
import { isIOS } from '../../lib/methods/helpers';
|
||||
import { isIOS } from '../../../lib/methods/helpers';
|
||||
|
||||
const regex = new RegExp(/\.[^/.]+$/); // Check from last '.' of the string
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import getMentionRegexp from './getMentionRegexp';
|
||||
import { getMentionRegexp } from './getMentionRegexp';
|
||||
|
||||
const regexp = getMentionRegexp();
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
// Match query string from the message to replace it with the suggestion
|
||||
export const getMentionRegexp = (): any => /[^@:#/!]*$/;
|
|
@ -0,0 +1,5 @@
|
|||
export * from './draftMessage';
|
||||
export * from './fetchIsAllOrHere';
|
||||
export * from './forceJpgExtension';
|
||||
export * from './getMentionRegexp';
|
||||
export * from './prepareQuoteMessage';
|
|
@ -0,0 +1,24 @@
|
|||
import { getPermalinkMessage } from '../../../lib/methods';
|
||||
import { getMessageById } from '../../../lib/database/services/Message';
|
||||
import { store } from '../../../lib/store/auxStore';
|
||||
import { compareServerVersion } from '../../../lib/methods/helpers';
|
||||
|
||||
export const prepareQuoteMessage = async (textFromInput: string, selectedMessages: string[]): Promise<string> => {
|
||||
let quoteText = '';
|
||||
const { version: serverVersion } = store.getState().server;
|
||||
const connectionString = compareServerVersion(serverVersion, 'lowerThan', '5.0.0') ? ' ' : '\n';
|
||||
|
||||
if (selectedMessages.length > 0) {
|
||||
for (let i = 0; i < selectedMessages.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const message = await getMessageById(selectedMessages[i]);
|
||||
if (message) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const permalink = await getPermalinkMessage(message);
|
||||
quoteText += `[ ](${permalink}) ${connectionString}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
quoteText = `${quoteText}${textFromInput}`;
|
||||
return quoteText;
|
||||
};
|
|
@ -0,0 +1,6 @@
|
|||
export * from './useAutocomplete';
|
||||
export * from './useCanUploadFile';
|
||||
export * from './useChooseMedia';
|
||||
export * from './useKeyboardListener';
|
||||
export * from './useSubscription';
|
||||
export * from './useMessage';
|
|
@ -0,0 +1,179 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
|
||||
import { IAutocompleteEmoji, IAutocompleteUserRoom, TAutocompleteItem, TAutocompleteType } from '../interfaces';
|
||||
import { search } from '../../../lib/methods';
|
||||
import { sanitizeLikeString } from '../../../lib/database/utils';
|
||||
import database from '../../../lib/database';
|
||||
import { emojis } from '../../../lib/constants';
|
||||
import { ICustomEmoji } from '../../../definitions';
|
||||
import { Services } from '../../../lib/services';
|
||||
import log from '../../../lib/methods/helpers/log';
|
||||
import I18n from '../../../i18n';
|
||||
import { NO_CANNED_RESPONSES } from '../constants';
|
||||
|
||||
const MENTIONS_COUNT_TO_DISPLAY = 4;
|
||||
|
||||
const getCustomEmojis = async (keyword: string): Promise<ICustomEmoji[]> => {
|
||||
const likeString = sanitizeLikeString(keyword);
|
||||
const whereClause = [];
|
||||
if (likeString) {
|
||||
whereClause.push(Q.where('name', Q.like(`${likeString}%`)));
|
||||
}
|
||||
const db = database.active;
|
||||
const customEmojisCollection = db.get('custom_emojis');
|
||||
const customEmojis = await (await customEmojisCollection.query(...whereClause).fetch())
|
||||
.slice(0, MENTIONS_COUNT_TO_DISPLAY)
|
||||
.map(emoji => ({
|
||||
name: emoji.name,
|
||||
extension: emoji.extension
|
||||
}));
|
||||
return customEmojis;
|
||||
};
|
||||
|
||||
export const useAutocomplete = ({
|
||||
text,
|
||||
type,
|
||||
rid,
|
||||
commandParams
|
||||
}: {
|
||||
rid?: string;
|
||||
type: TAutocompleteType;
|
||||
text: string;
|
||||
commandParams?: string;
|
||||
}): TAutocompleteItem[] => {
|
||||
const [items, setItems] = useState<TAutocompleteItem[]>([]);
|
||||
useEffect(() => {
|
||||
const getAutocomplete = async () => {
|
||||
try {
|
||||
if (!rid || !type) {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove existing loading skeleton from items
|
||||
const loadingIndex = items.findIndex(item => item.id === 'loading');
|
||||
if (loadingIndex !== -1) {
|
||||
items.splice(loadingIndex, 1);
|
||||
}
|
||||
|
||||
// add loading skeleton
|
||||
items.unshift({ id: 'loading', type: 'loading' });
|
||||
setItems(items);
|
||||
|
||||
if (type === '@' || type === '#') {
|
||||
const res = await search({ text, filterRooms: type === '#', filterUsers: type === '@', rid });
|
||||
const parsedRes: IAutocompleteUserRoom[] = res
|
||||
// TODO: need to refactor search to have a more predictable return type
|
||||
.map((item: any) => ({
|
||||
id: type === '@' ? item._id : item.rid,
|
||||
title: item.fname || item.name || item.username,
|
||||
subtitle: item.username || item.name,
|
||||
outside: item.outside,
|
||||
t: item.t ?? 'd',
|
||||
status: item.status,
|
||||
teamMain: item.teamMain,
|
||||
type
|
||||
})) as IAutocompleteUserRoom[];
|
||||
if (type === '@') {
|
||||
if ('all'.includes(text.toLocaleLowerCase())) {
|
||||
parsedRes.push({
|
||||
id: 'all',
|
||||
title: 'all',
|
||||
subtitle: I18n.t('Notify_all_in_this_room'),
|
||||
type,
|
||||
t: 'd'
|
||||
});
|
||||
}
|
||||
if ('here'.includes(text.toLocaleLowerCase())) {
|
||||
parsedRes.push({
|
||||
id: 'here',
|
||||
title: 'here',
|
||||
subtitle: I18n.t('Notify_active_in_this_room'),
|
||||
type,
|
||||
t: 'd'
|
||||
});
|
||||
}
|
||||
}
|
||||
setItems(parsedRes);
|
||||
}
|
||||
if (type === ':') {
|
||||
const customEmojis = await getCustomEmojis(text);
|
||||
const filteredStandardEmojis = emojis.filter(emoji => emoji.indexOf(text) !== -1).slice(0, MENTIONS_COUNT_TO_DISPLAY);
|
||||
let mergedEmojis: IAutocompleteEmoji[] = customEmojis.map(emoji => ({
|
||||
id: emoji.name,
|
||||
emoji,
|
||||
type
|
||||
}));
|
||||
mergedEmojis = mergedEmojis.concat(
|
||||
filteredStandardEmojis.map(emoji => ({
|
||||
id: emoji,
|
||||
emoji,
|
||||
type
|
||||
}))
|
||||
);
|
||||
setItems(mergedEmojis);
|
||||
}
|
||||
if (type === '/') {
|
||||
const db = database.active;
|
||||
const commandsCollection = db.get('slash_commands');
|
||||
const likeString = sanitizeLikeString(text);
|
||||
const commands = await (
|
||||
await commandsCollection.query(Q.where('id', Q.like(`${likeString}%`))).fetch()
|
||||
).map(command => ({
|
||||
id: command.id,
|
||||
title: command.id,
|
||||
subtitle: command.description,
|
||||
type
|
||||
}));
|
||||
setItems(commands);
|
||||
}
|
||||
if (type === '/preview') {
|
||||
if (!commandParams) {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
const response = await Services.getCommandPreview(text, rid, commandParams);
|
||||
if (response.success) {
|
||||
const previewItems = (response.preview?.items || []).map(item => ({
|
||||
id: item.id,
|
||||
preview: item,
|
||||
type,
|
||||
text,
|
||||
params: commandParams
|
||||
}));
|
||||
setItems(previewItems);
|
||||
}
|
||||
}
|
||||
if (type === '!') {
|
||||
const res = await Services.getListCannedResponse({ text });
|
||||
if (res.success) {
|
||||
if (res.cannedResponses.length === 0) {
|
||||
setItems([
|
||||
{
|
||||
id: NO_CANNED_RESPONSES,
|
||||
title: NO_CANNED_RESPONSES,
|
||||
type
|
||||
}
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const cannedResponses = res.cannedResponses.map(cannedResponse => ({
|
||||
id: cannedResponse._id,
|
||||
title: cannedResponse.shortcut,
|
||||
subtitle: cannedResponse.text,
|
||||
type
|
||||
}));
|
||||
setItems(cannedResponses);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(e);
|
||||
setItems([]);
|
||||
}
|
||||
};
|
||||
getAutocomplete();
|
||||
}, [text, type, rid, commandParams]);
|
||||
return items;
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
import { shallowEqual } from 'react-redux';
|
||||
|
||||
import { getPermissionsSelector, useAppSelector, usePermissions } from '../../../lib/hooks';
|
||||
|
||||
export const useCanUploadFile = (rid?: string): boolean => {
|
||||
const [uploadPermissionRedux] = useAppSelector(state => getPermissionsSelector(state, ['mobile-upload-file']), shallowEqual);
|
||||
const [permissionToUpload] = usePermissions(['mobile-upload-file'], rid);
|
||||
|
||||
// Servers older than 4.2
|
||||
if (!uploadPermissionRedux) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return permissionToUpload;
|
||||
};
|
|
@ -0,0 +1,147 @@
|
|||
import { Alert } from 'react-native';
|
||||
import DocumentPicker from 'react-native-document-picker';
|
||||
import ImagePicker, { ImageOrVideo } from 'react-native-image-crop-picker';
|
||||
|
||||
import { IMAGE_PICKER_CONFIG, LIBRARY_PICKER_CONFIG, VIDEO_PICKER_CONFIG } from '../constants';
|
||||
import { forceJpgExtension } from '../helpers';
|
||||
import I18n from '../../../i18n';
|
||||
import { canUploadFile } from '../../../lib/methods/helpers';
|
||||
import log from '../../../lib/methods/helpers/log';
|
||||
import { getSubscriptionByRoomId } from '../../../lib/database/services/Subscription';
|
||||
import { getThreadById } from '../../../lib/database/services/Thread';
|
||||
import Navigation from '../../../lib/navigation/appNavigation';
|
||||
import { useAppSelector } from '../../../lib/hooks';
|
||||
import { useRoomContext } from '../../../views/RoomView/context';
|
||||
|
||||
export const useChooseMedia = ({
|
||||
rid,
|
||||
tmid,
|
||||
permissionToUpload
|
||||
}: {
|
||||
rid?: string;
|
||||
tmid?: string;
|
||||
permissionToUpload: boolean;
|
||||
}) => {
|
||||
const { FileUpload_MediaTypeWhiteList, FileUpload_MaxFileSize } = useAppSelector(state => state.settings);
|
||||
const { action, selectedMessages } = useRoomContext();
|
||||
const allowList = FileUpload_MediaTypeWhiteList as string;
|
||||
const maxFileSize = FileUpload_MaxFileSize as number;
|
||||
const libPickerLabels = {
|
||||
cropperChooseText: I18n.t('Choose'),
|
||||
cropperCancelText: I18n.t('Cancel'),
|
||||
loadingLabelText: I18n.t('Processing')
|
||||
};
|
||||
|
||||
const takePhoto = async () => {
|
||||
try {
|
||||
let image = await ImagePicker.openCamera({ ...IMAGE_PICKER_CONFIG, ...libPickerLabels });
|
||||
image = forceJpgExtension(image);
|
||||
const file = image as any; // FIXME: unify those types to remove the need for any
|
||||
const canUploadResult = canUploadFile({
|
||||
file,
|
||||
allowList,
|
||||
maxFileSize,
|
||||
permissionToUploadFile: permissionToUpload
|
||||
});
|
||||
if (canUploadResult.success) {
|
||||
return openShareView([image]);
|
||||
}
|
||||
|
||||
handleError(canUploadResult.error);
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
};
|
||||
|
||||
const takeVideo = async () => {
|
||||
try {
|
||||
const video = await ImagePicker.openCamera({ ...VIDEO_PICKER_CONFIG, ...libPickerLabels });
|
||||
const file = video as any; // FIXME: unify those types to remove the need for any
|
||||
const canUploadResult = canUploadFile({
|
||||
file,
|
||||
allowList,
|
||||
maxFileSize,
|
||||
permissionToUploadFile: permissionToUpload
|
||||
});
|
||||
if (canUploadResult.success) {
|
||||
return openShareView([video]);
|
||||
}
|
||||
|
||||
handleError(canUploadResult.error);
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseFromLibrary = async () => {
|
||||
try {
|
||||
// The type can be video or photo, however the lib understands that it is just one of them.
|
||||
let attachments = (await ImagePicker.openPicker({
|
||||
...LIBRARY_PICKER_CONFIG,
|
||||
...libPickerLabels
|
||||
})) as unknown as ImageOrVideo[]; // FIXME: type this
|
||||
attachments = attachments.map(att => forceJpgExtension(att));
|
||||
openShareView(attachments);
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseFile = async () => {
|
||||
try {
|
||||
const res = await DocumentPicker.pickSingle({
|
||||
type: [DocumentPicker.types.allFiles]
|
||||
});
|
||||
const file = {
|
||||
filename: res.name,
|
||||
size: res.size,
|
||||
mime: res.type,
|
||||
path: res.uri
|
||||
} as any;
|
||||
const canUploadResult = canUploadFile({
|
||||
file,
|
||||
allowList,
|
||||
maxFileSize,
|
||||
permissionToUploadFile: permissionToUpload
|
||||
});
|
||||
if (canUploadResult.success) {
|
||||
return openShareView([file]);
|
||||
}
|
||||
handleError(canUploadResult.error);
|
||||
} catch (e: any) {
|
||||
if (!DocumentPicker.isCancel(e)) {
|
||||
log(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openShareView = async (attachments: any) => {
|
||||
if (!rid) return;
|
||||
const room = await getSubscriptionByRoomId(rid);
|
||||
let thread;
|
||||
if (tmid) {
|
||||
thread = await getThreadById(tmid);
|
||||
}
|
||||
if (room) {
|
||||
// FIXME: use useNavigation
|
||||
Navigation.navigate('ShareView', {
|
||||
room,
|
||||
thread,
|
||||
attachments,
|
||||
action,
|
||||
selectedMessages
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error?: string) => {
|
||||
Alert.alert(I18n.t('Error_uploading'), error && I18n.isTranslated(error) ? I18n.t(error) : error);
|
||||
};
|
||||
|
||||
return {
|
||||
takePhoto,
|
||||
takeVideo,
|
||||
chooseFromLibrary,
|
||||
chooseFile
|
||||
};
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue