diff --git a/app/containers/ActionSheet/ActionSheet.tsx b/app/containers/ActionSheet/ActionSheet.tsx index fb00d5267..e114b8e1c 100644 --- a/app/containers/ActionSheet/ActionSheet.tsx +++ b/app/containers/ActionSheet/ActionSheet.tsx @@ -140,6 +140,8 @@ const ActionSheet = React.memo( style={{ ...styles.container, ...bottomSheet }} backgroundStyle={{ backgroundColor: colors.focusedBackground }} onChange={index => index === -1 && onClose()} + // We need this to allow horizontal swipe gestures inside bottom sheet like in reaction picker + enableContentPanningGesture={data?.enableContentPanningGesture ?? true} {...androidTablet}> diff --git a/app/containers/ActionSheet/Provider.tsx b/app/containers/ActionSheet/Provider.tsx index b7caf6416..4b21726bc 100644 --- a/app/containers/ActionSheet/Provider.tsx +++ b/app/containers/ActionSheet/Provider.tsx @@ -22,6 +22,7 @@ export type TActionSheetOptions = { children?: React.ReactElement | null; snaps?: (string | number)[]; onClose?: () => void; + enableContentPanningGesture?: boolean; }; export interface IActionSheetProvider { showActionSheet: (item: TActionSheetOptions) => void; diff --git a/app/containers/ReactionsList.tsx b/app/containers/ReactionsList.tsx new file mode 100644 index 000000000..10a1b3c80 --- /dev/null +++ b/app/containers/ReactionsList.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { StyleSheet, Text, Pressable, View, ScrollView } from 'react-native'; +import ScrollableTabView from 'react-native-scrollable-tab-view'; +import { FlatList } from 'react-native-gesture-handler'; + +import Emoji from './message/Emoji'; +import { useTheme } from '../theme'; +import { TGetCustomEmoji } from '../definitions/IEmoji'; +import { IReaction } from '../definitions'; +import Avatar from './Avatar'; +import sharedStyles from '../views/Styles'; + +const MIN_TAB_WIDTH = 70; + +const styles = StyleSheet.create({ + reactionsListContainer: { height: '100%', width: '100%' }, + tabBarItem: { + paddingHorizontal: 10, + paddingBottom: 10, + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'row' + }, + reactionCount: { marginLeft: 5 }, + emojiName: { margin: 10 }, + userItemContainer: { marginHorizontal: 10, marginVertical: 5, flexDirection: 'row' }, + usernameContainer: { marginHorizontal: 10, justifyContent: 'center' }, + usernameText: { fontSize: 17, ...sharedStyles.textMedium }, + standardEmojiStyle: { fontSize: 20, color: '#fff' }, + customEmojiStyle: { width: 25, height: 25 } +}); + +interface IReactionsListBase { + baseUrl: string; + getCustomEmoji: TGetCustomEmoji; +} + +interface IReactionsListProps extends IReactionsListBase { + reactions?: IReaction[]; + width: number; +} + +interface ITabBarItem extends IReactionsListBase { + tab: IReaction; + index: number; + goToPage?: (index: number) => void; +} +interface IReactionsTabBar extends IReactionsListBase { + activeTab?: number; + tabs?: IReaction[]; + goToPage?: (index: number) => void; + width: number; +} + +const TabBarItem = ({ tab, index, goToPage, baseUrl, getCustomEmoji }: ITabBarItem) => { + const { colors } = useTheme(); + return ( + { + goToPage?.(index); + }} + style={({ pressed }: { pressed: boolean }) => ({ + opacity: pressed ? 0.7 : 1 + })}> + + + {tab.usernames.length} + + + ); +}; + +const ReactionsTabBar = ({ tabs, activeTab, goToPage, baseUrl, getCustomEmoji, width }: IReactionsTabBar) => { + const tabWidth = tabs && Math.max(width / tabs.length, MIN_TAB_WIDTH); + const { colors } = useTheme(); + return ( + + + {tabs?.map((tab, index) => { + const isActiveTab = activeTab === index; + return ( + + + + ); + })} + + + ); +}; + +const UsersList = ({ tabLabel }: { tabLabel: IReaction }) => { + const { colors } = useTheme(); + const { emoji, usernames } = tabLabel; + return ( + ( + + {emoji} + + )} + renderItem={({ item }) => ( + + + + {item} + + + )} + keyExtractor={item => item} + /> + ); +}; + +const ReactionsList = ({ reactions, baseUrl, getCustomEmoji, width }: IReactionsListProps): React.ReactElement => { + // sorting reactions in descending order on the basic of number of users reacted + const sortedReactions = reactions?.sort((reaction1, reaction2) => reaction2.usernames.length - reaction1.usernames.length); + + return ( + + }> + {sortedReactions?.map(reaction => ( + + ))} + + + ); +}; + +export default ReactionsList; diff --git a/app/containers/ReactionsModal.tsx b/app/containers/ReactionsModal.tsx deleted file mode 100644 index b74707f04..000000000 --- a/app/containers/ReactionsModal.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React from 'react'; -import { FlatList, StyleSheet, Text, View } from 'react-native'; -import Modal from 'react-native-modal'; -import Touchable from 'react-native-platform-touchable'; - -import Emoji from './message/Emoji'; -import I18n from '../i18n'; -import { CustomIcon } from './CustomIcon'; -import sharedStyles from '../views/Styles'; -import { themes } from '../lib/constants'; -import { TSupportedThemes, useTheme, withTheme } from '../theme'; -import { TGetCustomEmoji } from '../definitions/IEmoji'; -import { TMessageModel, ILoggedUser } from '../definitions'; -import SafeAreaView from './SafeAreaView'; - -const styles = StyleSheet.create({ - safeArea: { - backgroundColor: 'transparent' - }, - titleContainer: { - alignItems: 'center', - paddingVertical: 10 - }, - title: { - fontSize: 16, - ...sharedStyles.textSemibold, - ...sharedStyles.textAlignCenter - }, - reactCount: { - fontSize: 13, - ...sharedStyles.textRegular - }, - peopleReacted: { - fontSize: 14, - ...sharedStyles.textMedium - }, - peopleItemContainer: { - flex: 1, - flexDirection: 'column', - justifyContent: 'center' - }, - emojiContainer: { - width: 50, - height: 50, - alignItems: 'center', - justifyContent: 'center' - }, - itemContainer: { - height: 50, - flexDirection: 'row' - }, - listContainer: { - flex: 1 - }, - closeButton: { - position: 'absolute', - left: 0, - top: 10 - } -}); -const standardEmojiStyle = { fontSize: 20 }; -const customEmojiStyle = { width: 20, height: 20 }; - -interface ISharedFields { - user?: Pick; - baseUrl: string; - getCustomEmoji: TGetCustomEmoji; -} - -interface IItem extends ISharedFields { - item: { - usernames: string[]; - emoji: string; - }; -} - -interface IModalContent extends ISharedFields { - message?: TMessageModel; - onClose: () => void; - theme: TSupportedThemes; -} - -interface IReactionsModal extends ISharedFields { - message?: TMessageModel; - isVisible: boolean; - onClose(): void; -} - -const Item = React.memo(({ item, user, baseUrl, getCustomEmoji }: IItem) => { - const { theme } = useTheme(); - const count = item.usernames.length; - let usernames = item.usernames - .slice(0, 3) - .map((username: string) => (username === user?.username ? I18n.t('you') : username)) - .join(', '); - if (count > 3) { - usernames = `${usernames} ${I18n.t('and_more')} ${count - 3}`; - } else { - usernames = usernames.replace(/,(?=[^,]*$)/, ` ${I18n.t('and')}`); - } - return ( - - - - - - - {count === 1 ? I18n.t('1_person_reacted') : I18n.t('N_people_reacted', { n: count })} - - {usernames} - - - ); -}); - -const ModalContent = React.memo(({ message, onClose, ...props }: IModalContent) => { - if (message && message.reactions) { - return ( - - - - - {I18n.t('Reactions')} - - - } - keyExtractor={item => item.emoji} - /> - - ); - } - return null; -}); - -const ReactionsModal = React.memo( - ({ isVisible, onClose, ...props }: IReactionsModal) => { - const { theme } = useTheme(); - return ( - - - - ); - }, - (prevProps, nextProps) => prevProps.isVisible === nextProps.isVisible -); - -ReactionsModal.displayName = 'ReactionsModal'; -ModalContent.displayName = 'ReactionsModalContent'; -Item.displayName = 'ReactionsModalItem'; - -export default withTheme(ReactionsModal); diff --git a/app/definitions/index.ts b/app/definitions/index.ts index 4f7d7c3d3..cbe244639 100644 --- a/app/definitions/index.ts +++ b/app/definitions/index.ts @@ -28,6 +28,7 @@ export * from './ICredentials'; export * from './ISearch'; export * from './TUserStatus'; export * from './IProfile'; +export * from './IReaction'; export interface IBaseScreen, S extends string> { navigation: StackNavigationProp; diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index 0c79ddd41..f12177925 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -3,7 +3,6 @@ import { InteractionManager, Text, View } from 'react-native'; import { connect } from 'react-redux'; import parse from 'url-parse'; import moment from 'moment'; -import * as Haptics from 'expo-haptics'; import { Q } from '@nozbe/watermelondb'; import { dequal } from 'dequal'; import { EdgeInsets, withSafeAreaInsets } from 'react-native-safe-area-context'; @@ -22,7 +21,7 @@ import EventEmitter from '../../lib/methods/helpers/events'; import I18n from '../../i18n'; import RoomHeader from '../../containers/RoomHeader'; import StatusBar from '../../containers/StatusBar'; -import ReactionsModal from '../../containers/ReactionsModal'; +import ReactionsList from '../../containers/ReactionsList'; import { LISTENER } from '../../containers/Toast'; import { getBadgeColor, isBlocked, makeThreadName } from '../../lib/methods/helpers/room'; import { isReadOnly } from '../../lib/methods/helpers/isReadOnly'; @@ -100,6 +99,7 @@ import { hasPermission } from '../../lib/methods/helpers'; import { Services } from '../../lib/services'; +import { withActionSheet, TActionSheetOptions } from '../../containers/ActionSheet'; type TStateAttrsUpdate = keyof IRoomViewState; @@ -170,6 +170,7 @@ interface IRoomViewProps extends IBaseScreen { transferLivechatGuestPermission?: string[]; // TODO: Check if its the correct type viewCannedResponsesPermission?: string[]; // TODO: Check if its the correct type livechatAllowManualOnHold?: boolean; + showActionSheet: (options: TActionSheetOptions) => void; } interface IRoomViewState { @@ -853,12 +854,21 @@ class RoomView extends React.Component { }; onReactionLongPress = (message: TAnyMessageModel) => { - this.setState({ selectedMessage: message, reactionsModalVisible: true }); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }; - - onCloseReactionsModal = () => { - this.setState({ selectedMessage: undefined, reactionsModalVisible: false }); + this.setState({ selectedMessage: message }); + const { showActionSheet, baseUrl, width } = this.props; + const { selectedMessage } = this.state; + showActionSheet({ + children: ( + + ), + snaps: ['50%'], + enableContentPanningGesture: false + }); }; onEncryptedPress = () => { @@ -1469,7 +1479,7 @@ class RoomView extends React.Component { render() { console.count(`${this.constructor.name}.render calls`); - const { room, reactionsModalVisible, selectedMessage, loading, reacting, showingBlockingLoader } = this.state; + const { room, selectedMessage, loading, reacting, showingBlockingLoader } = this.state; const { user, baseUrl, theme, navigation, Hide_System_Messages, width, height, serverVersion } = this.props; const { rid, t } = room; let sysMes; @@ -1512,14 +1522,6 @@ class RoomView extends React.Component { theme={theme} /> - @@ -1545,4 +1547,4 @@ const mapStateToProps = (state: IApplicationState) => ({ livechatAllowManualOnHold: state.settings.Livechat_allow_manual_on_hold as boolean }); -export default connect(mapStateToProps)(withDimensions(withTheme(withSafeAreaInsets(RoomView)))); +export default connect(mapStateToProps)(withDimensions(withTheme(withSafeAreaInsets(withActionSheet(RoomView)))));