MM-41602 Gekidou long press menu UI only (#5950)

* skeleton in place

* fix ts error

* creating base option component

* Added all options except reaction

* moved options under /component/options

* added destructive styling

* skeleton - need polishing now

* default emojis for quick reaction

* rename files and small refactor

* Properly close bottom sheet

* redid reaction component

* canSave, isSaved

* canAddReaction condition

* fix aligment

* code clean up

* fix opening on tablet

* undo comment on local reaction action

* undo needless formatting

* clean up comment

* shows selected reaction

* fix marginTop and added title for Tablet

* code clean up

* investigating navigation

* fixed navigation

* Post options bottomSheet and renamed DrawerItem to MenuItem

* renamed optionType to testID

* update navigation_close_modal to close_bottom

* removed context in favor of Pressable

* Apply suggestions from code review

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* removed theme prop from PickReaction

* en.json and code fixes

* removed post_options from screen/index

* removed post_options from screens constant

* Revert "removed post_options from screen/index"

This reverts commit 24caa9773f.

* Revert "removed post_options from screens constant"

This reverts commit 863e2faaf7.

* fix theme import

* remove useless margin

* disabled post_options

* refactored render method for post_options

* fixing issue on iOS

* corrections from PR review

* fix for background on mobile

* Fix stack navigation styles

* i18n-extract output

* Feedback review

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Avinash Lingaloo
2022-02-15 00:26:16 +04:00
committed by GitHub
parent d35eac8bd3
commit 091bd8301b
33 changed files with 897 additions and 123 deletions

View File

@@ -30,7 +30,6 @@ exports[`DrawerItem should match snapshot 1`] = `
<View
style={
Object {
"backgroundColor": "#ffffff",
"flexDirection": "row",
"minHeight": 50,
}
@@ -38,13 +37,16 @@ exports[`DrawerItem should match snapshot 1`] = `
>
<View
style={
Object {
"alignItems": "center",
"height": 50,
"justifyContent": "center",
"marginLeft": 5,
"width": 45,
}
Array [
Object {
"alignItems": "center",
"height": 50,
"justifyContent": "center",
"marginLeft": 5,
"width": 45,
},
undefined,
]
}
>
<Icon
@@ -145,7 +147,6 @@ exports[`DrawerItem should match snapshot without separator and centered false 1
<View
style={
Object {
"backgroundColor": "#ffffff",
"flexDirection": "row",
"minHeight": 50,
}
@@ -153,13 +154,16 @@ exports[`DrawerItem should match snapshot without separator and centered false 1
>
<View
style={
Object {
"alignItems": "center",
"height": 50,
"justifyContent": "center",
"marginLeft": 5,
"width": 45,
}
Array [
Object {
"alignItems": "center",
"height": 50,
"justifyContent": "center",
"marginLeft": 5,
"width": 45,
},
undefined,
]
}
>
<Icon

View File

@@ -6,7 +6,7 @@ import React from 'react';
import {Preferences} from '@constants';
import {renderWithIntl} from '@test/intl-test-helper';
import DrawerItem from './';
import MenuItem from '.';
describe('DrawerItem', () => {
const baseProps = {
@@ -22,7 +22,7 @@ describe('DrawerItem', () => {
};
test('should match snapshot', () => {
const wrapper = renderWithIntl(<DrawerItem {...baseProps}/>);
const wrapper = renderWithIntl(<MenuItem {...baseProps}/>);
expect(wrapper.toJSON()).toMatchSnapshot();
});
@@ -34,7 +34,7 @@ describe('DrawerItem', () => {
separator: false,
};
const wrapper = renderWithIntl(
<DrawerItem {...props}/>,
<MenuItem {...props}/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();

View File

@@ -2,17 +2,20 @@
// See LICENSE.txt for license information.
import React, {ReactNode} from 'react';
import {Platform, View} from 'react-native';
import {Platform, StyleProp, View, ViewStyle} from 'react-native';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type DrawerItemProps = {
export const ITEM_HEIGHT = 50;
type MenuItemProps = {
centered?: boolean;
defaultMessage?: string;
i18nId?: string;
iconContainerStyle?: StyleProp<ViewStyle>;
iconName?: string;
isDestructor?: boolean;
labelComponent?: ReactNode;
@@ -23,11 +26,55 @@ type DrawerItemProps = {
theme: Theme;
};
const DrawerItem = (props: DrawerItemProps) => {
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flexDirection: 'row',
minHeight: ITEM_HEIGHT,
},
iconContainer: {
width: 45,
height: ITEM_HEIGHT,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 5,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.64),
fontSize: 24,
},
wrapper: {
flex: 1,
},
labelContainer: {
flex: 1,
justifyContent: 'center',
paddingTop: 14,
paddingBottom: 14,
},
centerLabel: {
textAlign: 'center',
textAlignVertical: 'center',
},
label: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 17,
textAlignVertical: 'center',
includeFontPadding: false,
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
},
};
});
const MenuItem = (props: MenuItemProps) => {
const {
centered,
defaultMessage = '',
i18nId,
iconContainerStyle,
iconName,
isDestructor = false,
labelComponent,
@@ -87,7 +134,7 @@ const DrawerItem = (props: DrawerItemProps) => {
>
<View style={style.container}>
{icon && (
<View style={style.iconContainer}>
<View style={[style.iconContainer, iconContainerStyle]}>
{icon}
</View>
)}
@@ -102,48 +149,4 @@ const DrawerItem = (props: DrawerItemProps) => {
);
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
backgroundColor: theme.centerChannelBg,
flexDirection: 'row',
minHeight: 50,
},
iconContainer: {
width: 45,
height: 50,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 5,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.64),
fontSize: 24,
},
wrapper: {
flex: 1,
},
labelContainer: {
flex: 1,
justifyContent: 'center',
paddingTop: 14,
paddingBottom: 14,
},
centerLabel: {
textAlign: 'center',
textAlignVertical: 'center',
},
label: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 17,
textAlignVertical: 'center',
includeFontPadding: false,
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
},
};
});
export default DrawerItem;
export default MenuItem;

View File

@@ -13,7 +13,8 @@ import TouchableWithFeedback from '@components/touchable_with_feedback';
import * as Screens from '@constants/screens';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {showModalOverCurrentContext} from '@screens/navigation';
import {useIsTablet} from '@hooks/device';
import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {fromAutoResponder, isFromWebhook, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -104,6 +105,7 @@ const Post = ({
const intl = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const isTablet = useIsTablet();
const styles = getStyleSheet(theme);
const isAutoResponder = fromAutoResponder(post);
const isPendingOrFailed = isPostPendingOrFailed(post);
@@ -162,18 +164,16 @@ const Post = ({
return;
}
const screen = 'PostOptions';
const passProps = {
location,
post,
showAddReaction,
};
Keyboard.dismiss();
const postOptionsRequest = requestAnimationFrame(() => {
showModalOverCurrentContext(screen, passProps);
cancelAnimationFrame(postOptionsRequest);
});
const passProps = {location, post};
const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : '';
if (isTablet) {
showModal(Screens.POST_OPTIONS, title, passProps, bottomSheetModalOptions(theme, 'close-post-options'));
} else {
showModalOverCurrentContext(Screens.POST_OPTIONS, passProps);
}
};
const highlightFlagged = isFlagged && !skipFlaggedHeader;

View File

@@ -28,6 +28,7 @@ export const SETTINGS_SIDEBAR = 'SettingsSidebar';
export const SSO = 'SSO';
export const THREAD = 'Thread';
export const USER_PROFILE = 'UserProfile';
export const POST_OPTIONS = 'PostOptions';
export default {
ABOUT,
@@ -57,4 +58,5 @@ export default {
SSO,
THREAD,
USER_PROFILE,
POST_OPTIONS,
};

View File

@@ -45,10 +45,12 @@ const ThemeProvider = ({currentTeamId, children, themes}: Props) => {
if (teamTheme?.value) {
try {
const theme = setThemeDefaults(JSON.parse(teamTheme.value) as Theme);
EphemeralStore.theme = theme;
requestAnimationFrame(() => {
setNavigationStackStyles(theme);
});
if (theme !== EphemeralStore.theme) {
EphemeralStore.theme = theme;
requestAnimationFrame(() => {
setNavigationStackStyles(theme);
});
}
return theme;
} catch {
// no theme change

View File

@@ -7,7 +7,7 @@ import {DeviceEventEmitter} from 'react-native';
import {updateLocalCustomStatus} from '@actions/local/user';
import {unsetCustomStatus} from '@actions/remote/user';
import DrawerItem from '@components/drawer_item';
import MenuItem from '@components/menu_item';
import {Events, Screens} from '@constants';
import {SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status';
import {useServerUrl} from '@context/server';
@@ -68,7 +68,7 @@ const CustomStatus = ({isCustomStatusExpirySupported, isTablet, currentUser}: Cu
}), [isTablet]);
return (
<DrawerItem
<MenuItem
testID='settings.sidebar.custom_status.action'
labelComponent={
<CustomLabel

View File

@@ -6,8 +6,8 @@ import {useIntl} from 'react-intl';
import {TextStyle, View} from 'react-native';
import {logout} from '@actions/remote/session';
import DrawerItem from '@components/drawer_item';
import FormattedText from '@components/formatted_text';
import MenuItem from '@components/menu_item';
import {useServerDisplayName, useServerUrl} from '@context/server';
import {alertServerLogout} from '@utils/server';
import {preventDoubleTap} from '@utils/tap';
@@ -49,7 +49,7 @@ const Settings = ({style, theme}: Props) => {
}), [serverDisplayName, serverUrl, intl]);
return (
<DrawerItem
<MenuItem
testID='account.logout.action'
labelComponent={(
<View>

View File

@@ -4,8 +4,8 @@
import React, {useCallback} from 'react';
import {TextStyle} from 'react-native';
import DrawerItem from '@components/drawer_item';
import FormattedText from '@components/formatted_text';
import MenuItem from '@components/menu_item';
import {preventDoubleTap} from '@utils/tap';
type Props = {
@@ -20,7 +20,7 @@ const SavedMessages = ({isTablet, style, theme}: Props) => {
}), [isTablet]);
return (
<DrawerItem
<MenuItem
testID='account.saved_messages.action'
labelComponent={
<FormattedText

View File

@@ -4,8 +4,8 @@
import React, {useCallback} from 'react';
import {TextStyle} from 'react-native';
import DrawerItem from '@components/drawer_item';
import FormattedText from '@components/formatted_text';
import MenuItem from '@components/menu_item';
import {preventDoubleTap} from '@utils/tap';
type Props = {
@@ -20,7 +20,7 @@ const Settings = ({isTablet, style, theme}: Props) => {
}), [isTablet]);
return (
<DrawerItem
<MenuItem
testID='account.settings.action'
labelComponent={
<FormattedText

View File

@@ -6,7 +6,7 @@ import {useIntl} from 'react-intl';
import {DeviceEventEmitter, TextStyle} from 'react-native';
import {setStatus} from '@actions/remote/user';
import DrawerItem from '@components/drawer_item';
import MenuItem from '@components/menu_item';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import StatusLabel from '@components/status_label';
import UserStatusIndicator from '@components/user_status';
@@ -116,7 +116,7 @@ const UserStatus = ({currentUser, style, theme}: Props) => {
}, []);
return (
<DrawerItem
<MenuItem
testID='account.status.action'
labelComponent={
<StatusLabel

View File

@@ -5,8 +5,8 @@ import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, TextStyle} from 'react-native';
import DrawerItem from '@components/drawer_item';
import FormattedText from '@components/formatted_text';
import MenuItem from '@components/menu_item';
import {Events, Screens} from '@constants';
import {ACCOUNT_OUTLINE_IMAGE} from '@constants/profile';
import {showModal} from '@screens/navigation';
@@ -32,7 +32,7 @@ const YourProfile = ({isTablet, style, theme}: Props) => {
}), [isTablet, theme]);
return (
<DrawerItem
<MenuItem
testID='account.your_profile.action'
labelComponent={
<FormattedText

View File

@@ -21,7 +21,7 @@ const withGestures = (Screen: React.ComponentType, styles: StyleProp<ViewStyle>)
if (Platform.OS === 'android') {
return (
<GestureHandlerRootView style={[{flex: 1}, styles]}>
<Screen {...props}/>
<Screen {...props}/>
</GestureHandlerRootView>
)
}
@@ -186,9 +186,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
// case 'PinnedPosts':
// screen = require('@screens/pinned_posts').default;
// break;
// case 'PostOptions':
// screen = require('@screens/post_options').default;
// break;
case Screens.POST_OPTIONS:
screen = withServerDatabase(require('@screens/post_options').default);
break;
// case 'ReactionList':
// screen = require('@screens/reaction_list').default;
// break;

View File

@@ -75,6 +75,30 @@ export const loginAnimationOptions = () => {
};
};
export const bottomSheetModalOptions = (theme: Theme, closeButtonId: string) => {
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor);
return {
modalPresentationStyle: OptionsModalPresentationStyle.formSheet,
modal: {
swipeToDismiss: true,
},
topBar: {
leftButtons: [{
id: closeButtonId,
icon: closeButton,
testID: closeButtonId,
}],
leftButtonColor: changeOpacity(theme.centerChannelColor, 0.56),
background: {
color: theme.centerChannelBg,
},
title: {
color: theme.centerChannelColor,
},
},
};
};
Navigation.setDefaultOptions({
layout: {
@@ -565,32 +589,12 @@ export async function bottomSheet({title, renderContent, snapPoints, initialSnap
const isTablet = Device.IS_TABLET && !isSplitView;
if (isTablet) {
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.centerChannelColor);
showModal(Screens.BOTTOM_SHEET, title, {
closeButtonId,
initialSnapIndex,
renderContent,
snapPoints,
}, {
modalPresentationStyle: OptionsModalPresentationStyle.formSheet,
modal: {
swipeToDismiss: true,
},
topBar: {
leftButtons: [{
id: closeButtonId,
icon: closeButton,
testID: closeButtonId,
}],
leftButtonColor: changeOpacity(theme.centerChannelColor, 0.56),
background: {
color: theme.centerChannelBg,
},
title: {
color: theme.centerChannelColor,
},
},
});
}, bottomSheetModalOptions(theme, closeButtonId));
} else {
showModalOverCurrentContext(Screens.BOTTOM_SHEET, {
initialSnapIndex,

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import FormattedText from '@components/formatted_text';
import MenuItem from '@components/menu_item';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
destructive: {
color: theme.dndIndicator,
},
label: {
color: theme.centerChannelColor,
...typography('Body', 200),
},
iconContainerStyle: {
marginLeft: 0,
},
}));
type BaseOptionType = {
i18nId: string;
defaultMessage: string;
iconName: string;
onPress: () => void;
testID: string;
isDestructive?: boolean;
}
const BaseOption = ({
i18nId,
defaultMessage,
iconName,
onPress,
testID,
isDestructive = false,
}: BaseOptionType) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const labelStyles = useMemo(() => {
if (isDestructive) {
return [styles.label, styles.destructive];
}
return styles.label;
}, [isDestructive, styles.label, styles.destructive]);
const label = useMemo(() => (
<FormattedText
id={i18nId}
defaultMessage={defaultMessage}
style={labelStyles}
/>
), [i18nId, defaultMessage, labelStyles]);
return (
<MenuItem
testID={testID}
labelComponent={label}
iconContainerStyle={styles.iconContainerStyle}
iconName={iconName}
onPress={onPress}
separator={false}
theme={theme}
isDestructor={isDestructive}
/>
);
};
export default BaseOption;

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {t} from '@i18n';
import BaseOption from './base_option';
const CopyPermalinkOption = () => {
const handleCopyLink = () => {
//todo:
};
return (
<BaseOption
i18nId={t('get_post_link_modal.title')}
defaultMessage='Copy Link'
onPress={handleCopyLink}
iconName='link-variant'
testID='post.options.copy.permalink'
/>
);
};
export default CopyPermalinkOption;

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {t} from '@i18n';
import BaseOption from './base_option';
const CopyTextOption = () => {
const handleCopyText = () => {
//todo:
};
return (
<BaseOption
i18nId={t('mobile.post_info.copy_text')}
defaultMessage='Copy Text'
iconName='content-copy'
onPress={handleCopyText}
testID='post.options.copy.text'
/>
);
};
export default CopyTextOption;

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {t} from '@i18n';
import BaseOption from './base_option';
//fixme: wire up handleDeletePost
const DeletePostOption = () => {
const handleDeletePost = () => null;
return (
<BaseOption
i18nId={t('post_info.del')}
defaultMessage='Delete'
iconName='trash-can-outline'
onPress={handleDeletePost}
testID='post.options.delete.post'
isDestructive={true}
/>
);
};
export default DeletePostOption;

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {t} from '@i18n';
import BaseOption from './base_option';
const EditOption = () => {
const handleEdit = () => {
//todo:
};
return (
<BaseOption
i18nId={t('post_info.edit')}
defaultMessage='Edit'
onPress={handleEdit}
iconName='pencil-outline'
testID='post.options.edit'
/>
);
};
export default EditOption;

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Screens} from '@constants';
import {t} from '@i18n';
import BaseOption from './base_option';
type FollowThreadOptionProps = {
thread?: any;
location?: typeof Screens[keyof typeof Screens];
};
//todo: to implement CRT follow thread
const FollowThreadOption = ({thread}: FollowThreadOptionProps) => {
let id: string;
let defaultMessage: string;
let icon: string;
if (thread.is_following) {
icon = 'message-minus-outline';
if (thread?.participants?.length) {
id = t('threads.unfollowThread');
defaultMessage = 'Unfollow Thread';
} else {
id = t('threads.unfollowMessage');
defaultMessage = 'Unfollow Message';
}
} else {
icon = 'message-plus-outline';
if (thread?.participants?.length) {
id = t('threads.followThread');
defaultMessage = 'Follow Thread';
} else {
id = t('threads.followMessage');
defaultMessage = 'Follow Message';
}
}
const handleToggleFollow = () => {
//todo:
};
return (
<BaseOption
i18nId={id}
defaultMessage={defaultMessage}
testID='post.options.follow.thread'
iconName={icon}
onPress={handleToggleFollow}
/>
);
};
export default FollowThreadOption;

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {t} from '@i18n';
import BaseOption from './base_option';
//fixme: wire up canMarkAsUnread
const MarkAsUnreadOption = () => {
const handleMarkUnread = () => {
//todo:
};
return (
<BaseOption
i18nId={t('mobile.post_info.mark_unread')}
defaultMessage='Mark as Unread'
iconName='mark-as-unread'
onPress={handleMarkUnread}
testID='post.options.mark.unread'
/>
);
};
export default MarkAsUnreadOption;

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {t} from '@i18n';
import BaseOption from './base_option';
type PinChannelProps = {
isPostPinned: boolean;
}
//fixme: wire up handlePinChannel
const PinChannelOption = ({isPostPinned}: PinChannelProps) => {
//todo: add useCallback for the handler callbacks
const handlePinPost = () => null;
const handleUnpinPost = () => null;
let defaultMessage;
let id;
let key;
let onPress;
if (isPostPinned) {
defaultMessage = 'Unpin from Channel';
id = t('mobile.post_info.unpin');
key = 'unpin';
onPress = handleUnpinPost;
} else {
defaultMessage = 'Pin to Channel';
id = t('mobile.post_info.pin');
key = 'pin';
onPress = handlePinPost;
}
return (
<BaseOption
i18nId={id}
defaultMessage={defaultMessage}
iconName='pin-outline'
onPress={onPress}
testID={`post.options.${key}.channel`}
/>
);
};
export default PinChannelOption;

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {t} from '@i18n';
import BaseOption from './base_option';
const ReplyOption = () => {
const handleReply = () => {
//todo:
};
return (
<BaseOption
i18nId={t('mobile.post_info.reply')}
defaultMessage='Reply'
iconName='reply-outline'
onPress={handleReply}
testID='post.options.reply'
/>
);
};
export default ReplyOption;

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {t} from '@i18n';
import BaseOption from './base_option';
type CopyTextProps = {
isSaved: boolean;
}
const SaveOption = ({isSaved}: CopyTextProps) => {
const handleUnsavePost = () => {
//todo:
};
const handleSavePost = () => {
//todo:
};
const id = isSaved ? t('mobile.post_info.unsave') : t('mobile.post_info.save');
const defaultMessage = isSaved ? 'Unsave' : 'Save';
const onPress = isSaved ? handleUnsavePost : handleSavePost;
return (
<BaseOption
i18nId={id}
defaultMessage={defaultMessage}
iconName='bookmark-outline'
onPress={onPress}
testID='post.options.flag.unflag'
/>
);
};
export default SaveOption;

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
},
icon: {
...typography('Body', 1000),
color: theme.centerChannelColor,
},
};
});
type PickReactionProps = {
openEmojiPicker: () => void;
width: number;
height: number;
}
const PickReaction = ({openEmojiPicker, width, height}: PickReactionProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View
style={[styles.container, {
width, height,
}]}
>
<CompassIcon
onPress={openEmojiPicker}
name='emoticon-plus-outline'
style={styles.icon}
/>
</View>
);
};
export default PickReaction;

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {Pressable, PressableStateCallbackType, View} from 'react-native';
import Emoji from '@components/emoji';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
emoji: {
color: '#000',
fontWeight: 'bold',
},
highlight: {
backgroundColor: changeOpacity(theme.linkColor, 0.1),
},
reactionContainer: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
},
};
});
type ReactionProps = {
onPressReaction: (emoji: string) => void;
emoji: string;
iconSize: number;
containerSize: number;
}
const Reaction = ({onPressReaction, emoji, iconSize, containerSize}: ReactionProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const handleReactionPressed = useCallback(() => {
onPressReaction(emoji);
}, [onPressReaction, emoji]);
const highlightedStyle = useCallback(({pressed}: PressableStateCallbackType) => pressed && styles.highlight, [styles.highlight]);
const reactionStyle = useMemo(() => [
styles.reactionContainer,
{
width: containerSize,
height: containerSize,
},
], [containerSize]);
return (
<Pressable
key={emoji}
onPress={handleReactionPressed}
style={highlightedStyle}
>
<View
style={reactionStyle}
>
<Emoji
emojiName={emoji}
textStyle={styles.emoji}
size={iconSize}
/>
</View>
</Pressable>
);
};
export default Reaction;

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {catchError, switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {safeParseJSON} from '@utils/helpers';
import ReactionBar from './reaction_bar';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
const DEFAULT_EMOJIS = [
'thumbsup',
'smiley',
'white_check_mark',
'heart',
'eyes',
'raised_hands',
];
const mergeRecentWithDefault = (recentEmojis: string[]) => {
const filterUsed = DEFAULT_EMOJIS.filter((e) => !recentEmojis.includes(e));
return recentEmojis.concat(filterUsed).splice(0, 6);
};
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
recentEmojis: database.
get<SystemModel>(MM_TABLES.SERVER.SYSTEM).
findAndObserve(SYSTEM_IDENTIFIERS.RECENT_REACTIONS).
pipe(
switchMap((recent) => of$(mergeRecentWithDefault(safeParseJSON(recent.value) as string[]))),
catchError(() => of$(mergeRecentWithDefault([]))),
),
}));
export default withDatabase(enhanced(ReactionBar));

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, useWindowDimensions, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {Events, Screens} from '@constants';
import {
LARGE_CONTAINER_SIZE,
LARGE_ICON_SIZE,
REACTION_PICKER_HEIGHT,
SMALL_CONTAINER_SIZE,
SMALL_ICON_BREAKPOINT,
SMALL_ICON_SIZE,
} from '@constants/reaction_picker';
import {useTheme} from '@context/theme';
import {showModal} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {makeStyleSheetFromTheme} from '@utils/theme';
import PickReaction from './components/pick_reaction';
import Reaction from './components/reaction';
type QuickReactionProps = {
recentEmojis: string[];
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
backgroundColor: theme.centerChannelBg,
flexDirection: 'row',
alignItems: 'center',
height: REACTION_PICKER_HEIGHT,
justifyContent: 'space-between',
},
};
});
const ReactionBar = ({recentEmojis = []}: QuickReactionProps) => {
const theme = useTheme();
const intl = useIntl();
const {width} = useWindowDimensions();
const isSmallDevice = width < SMALL_ICON_BREAKPOINT;
const styles = getStyleSheet(theme);
const handleEmojiPress = useCallback((emoji: string) => {
// eslint-disable-next-line no-console
console.log('>>> selected this emoji', emoji);
}, []);
const openEmojiPicker = useCallback(async () => {
DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET);
await EphemeralStore.waitUntilScreensIsRemoved(Screens.BOTTOM_SHEET);
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const screen = Screens.EMOJI_PICKER;
const title = intl.formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'});
const passProps = {closeButton, onEmojiPress: handleEmojiPress};
showModal(screen, title, passProps);
}, [intl, theme]);
let containerSize = LARGE_CONTAINER_SIZE;
let iconSize = LARGE_ICON_SIZE;
if (isSmallDevice) {
containerSize = SMALL_CONTAINER_SIZE;
iconSize = SMALL_ICON_SIZE;
}
return (
<View style={styles.container}>
{
recentEmojis.map((emoji) => {
return (
<Reaction
key={emoji}
onPressReaction={handleEmojiPress}
emoji={emoji}
iconSize={iconSize}
containerSize={containerSize}
/>
);
})
}
<PickReaction
openEmojiPicker={openEmojiPicker}
width={containerSize}
height={containerSize}
/>
</View>
);
};
export default ReactionBar;

View File

@@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PostOptions from './post_options';
export default PostOptions;

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {ITEM_HEIGHT} from '@components/menu_item';
import {Screens} from '@constants';
import BottomSheet from '@screens/bottom_sheet';
import {isSystemMessage} from '@utils/post';
import CopyLinkOption from './components/options/copy_link_option';
import CopyTextOption from './components/options/copy_text_option';
import DeletePostOption from './components/options/delete_post_option';
import EditOption from './components/options/edit_option';
import FollowThreadOption from './components/options/follow_option';
import MarkAsUnreadOption from './components/options/mark_unread_option';
import PinChannelOption from './components/options/pin_channel_option';
import ReplyOption from './components/options/reply_option';
import SaveOption from './components/options/save_option';
import ReactionBar from './components/reaction_bar';
import type PostModel from '@typings/database/models/servers/post';
//fixme: some props are optional - review them
type PostOptionsProps = {
canAddReaction?: boolean;
canCopyPermalink?: boolean;
canCopyText?: boolean;
canDelete?: boolean;
canEdit?: boolean;
canEditUntil?: number;
canMarkAsUnread?: boolean;
canPin?: boolean;
canSave?: boolean;
canReply?: boolean;
isSaved?: boolean;
location: typeof Screens[keyof typeof Screens];
post: PostModel;
thread?: Partial<PostModel>;
};
const PostOptions = ({
canAddReaction = true,
canCopyPermalink = true,
canCopyText = true,
canDelete = true,
canEdit = true,
canEditUntil = -1,
canMarkAsUnread = true,
canPin = true,
canReply = true,
canSave = true,
isSaved = true,
location,
post,
thread,
}: PostOptionsProps) => {
const shouldRenderEdit = canEdit && (canEditUntil === -1 || canEditUntil > Date.now());
const shouldRenderFollow = !(location !== Screens.CHANNEL || !thread);
const snapPoints = [
canAddReaction, canCopyPermalink, canCopyText,
canDelete, shouldRenderEdit, shouldRenderFollow,
canMarkAsUnread, canPin, canReply, canSave,
].reduce((acc, v) => {
return v ? acc + 1 : acc;
}, 0);
const renderContent = () => {
return (
<>
{canAddReaction && <ReactionBar/>}
{canReply && <ReplyOption/>}
{shouldRenderFollow &&
<FollowThreadOption
location={location}
thread={thread}
/>
}
{canMarkAsUnread && !isSystemMessage(post) && (
<MarkAsUnreadOption/>
)}
{canCopyPermalink && <CopyLinkOption/>}
{canSave &&
<SaveOption
isSaved={isSaved}
/>
}
{canCopyText && <CopyTextOption/>}
{canPin && <PinChannelOption isPostPinned={post.isPinned}/>}
{shouldRenderEdit && <EditOption/>}
{canDelete && <DeletePostOption/>}
</>
);
};
return (
<BottomSheet
renderContent={renderContent}
closeButtonId='close-post-options'
initialSnapIndex={0}
snapPoints={[((snapPoints + 2) * ITEM_HEIGHT), 10]}
/>
);
};
export default PostOptions;

View File

@@ -5,7 +5,7 @@ import merge from 'deepmerge';
import {StatusBar, StyleSheet} from 'react-native';
import tinyColor from 'tinycolor2';
import {Preferences, Screens} from '@constants';
import {Preferences} from '@constants';
import {appearanceControlledScreens, mergeNavigationOptions} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
@@ -129,7 +129,7 @@ export function setNavigatorStyles(componentId: string, theme: Theme, additional
export function setNavigationStackStyles(theme: Theme) {
EphemeralStore.allNavigationComponentIds.forEach((componentId) => {
if (componentId !== Screens.BOTTOM_SHEET && !appearanceControlledScreens.includes(componentId)) {
if (!appearanceControlledScreens.includes(componentId)) {
setNavigatorStyles(componentId, theme);
}
});

View File

@@ -120,6 +120,7 @@
"emoji_skin.medium_light_skin_tone": "medium light skin tone",
"emoji_skin.medium_skin_tone": "medium skin tone",
"file_upload.fileAbove": "Files must be less than {max}",
"get_post_link_modal.title": "Copy Link",
"intro.add_people": "Add People",
"intro.channel_details": "Details",
"intro.created_by": "created by {creator} on {date}.",
@@ -259,6 +260,13 @@
"mobile.permission_denied_dismiss": "Don't Allow",
"mobile.permission_denied_retry": "Settings",
"mobile.post_info.add_reaction": "Add Reaction",
"mobile.post_info.copy_text": "Copy Text",
"mobile.post_info.mark_unread": "Mark as Unread",
"mobile.post_info.pin": "Pin to Channel",
"mobile.post_info.reply": "Reply",
"mobile.post_info.save": "Save",
"mobile.post_info.unpin": "Unpin from Channel",
"mobile.post_info.unsave": "Unsave",
"mobile.post_pre_header.flagged": "Saved",
"mobile.post_pre_header.pinned": "Pinned",
"mobile.post_pre_header.pinned_flagged": "Pinned and Saved",
@@ -368,6 +376,8 @@
"post_body.deleted": "(message deleted)",
"post_info.auto_responder": "AUTOMATIC REPLY",
"post_info.bot": "BOT",
"post_info.del": "Delete",
"post_info.edit": "Edit",
"post_info.guest": "GUEST",
"post_info.system": "System",
"post_message_view.edited": "(edited)",
@@ -397,6 +407,10 @@
"status_dropdown.set_ooo": "Out Of Office",
"team_list.no_other_teams.description": "To join another team, ask a Team Admin for an invitation, or create your own team.",
"team_list.no_other_teams.title": "No additional teams to join",
"threads.followMessage": "Follow Message",
"threads.followThread": "Follow Thread",
"threads.unfollowMessage": "Unfollow Message",
"threads.unfollowThread": "Unfollow Thread",
"user.edit_profile.email.auth_service": "Login occurs through {service}. Email cannot be updated. Email address used for notifications is {email}.",
"user.edit_profile.email.web_client": "Email must be updated using a web client or desktop application.",
"user.settings.general.email": "Email",

1
package-lock.json generated
View File

@@ -5,6 +5,7 @@
"requires": true,
"packages": {
"": {
"name": "mattermost-mobile",
"version": "2.0.0",
"hasInstallScript": true,
"license": "Apache 2.0",