diff --git a/app/components/common_post_options/copy_permalink_option/copy_permalink_option.tsx b/app/components/common_post_options/copy_permalink_option/copy_permalink_option.tsx index 0bce40d696..43205ba4ae 100644 --- a/app/components/common_post_options/copy_permalink_option/copy_permalink_option.tsx +++ b/app/components/common_post_options/copy_permalink_option/copy_permalink_option.tsx @@ -6,23 +6,27 @@ import React, {useCallback} from 'react'; import {BaseOption} from '@components/common_post_options'; import {Screens} from '@constants'; +import {SNACK_BAR_TYPE} from '@constants/snack_bar'; import {useServerUrl} from '@context/server'; import {t} from '@i18n'; import {dismissBottomSheet} from '@screens/navigation'; +import {showSnackBar} from '@utils/snack_bar'; import type PostModel from '@typings/database/models/servers/post'; type Props = { - teamName: string; + sourceScreen: typeof Screens[keyof typeof Screens]; post: PostModel; + teamName: string; } -const CopyPermalinkOption = ({teamName, post}: Props) => { +const CopyPermalinkOption = ({teamName, post, sourceScreen}: Props) => { const serverUrl = useServerUrl(); - const handleCopyLink = useCallback(() => { + const handleCopyLink = useCallback(async () => { const permalink = `${serverUrl}/${teamName}/pl/${post.id}`; Clipboard.setString(permalink); - dismissBottomSheet(Screens.POST_OPTIONS); + await dismissBottomSheet(Screens.POST_OPTIONS); + showSnackBar({barType: SNACK_BAR_TYPE.LINK_COPIED, sourceScreen}); }, [teamName, post.id]); return ( diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 8a89f60c1d..78f552c347 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -171,7 +171,7 @@ const Post = ({ } Keyboard.dismiss(); - const passProps = {location, post, showAddReaction}; + const passProps = {sourceScreen: location, post, showAddReaction}; const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : ''; if (isTablet) { diff --git a/app/components/post_list/thread_overview/thread_overview.tsx b/app/components/post_list/thread_overview/thread_overview.tsx index 9bcd68331c..fd438853c0 100644 --- a/app/components/post_list/thread_overview/thread_overview.tsx +++ b/app/components/post_list/thread_overview/thread_overview.tsx @@ -74,7 +74,7 @@ const ThreadOverview = ({isSaved, repliesCount, rootPost, style, testID}: Props) const showPostOptions = useCallback(preventDoubleTap(() => { Keyboard.dismiss(); if (rootPost?.id) { - const passProps = {location: Screens.THREAD, post: rootPost, showAddReaction: true}; + const passProps = {sourceScreen: Screens.THREAD, post: rootPost, showAddReaction: true}; const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : ''; if (isTablet) { diff --git a/app/components/toast/index.tsx b/app/components/toast/index.tsx index b4da4dbc24..30a9b16cb9 100644 --- a/app/components/toast/index.tsx +++ b/app/components/toast/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useMemo} from 'react'; -import {StyleProp, Text, useWindowDimensions, View, ViewStyle} from 'react-native'; +import {StyleProp, Text, TextStyle, useWindowDimensions, View, ViewStyle} from 'react-native'; import Animated, {AnimatedStyleProp} from 'react-native-reanimated'; import CompassIcon from '@components/compass_icon'; @@ -15,9 +15,12 @@ type ToastProps = { children?: React.ReactNode; iconName?: string; message?: string; - style: StyleProp; + style?: StyleProp; + textStyle?: StyleProp; } +export const TOAST_HEIGHT = 56; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ center: { alignItems: 'center', @@ -31,7 +34,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ elevation: 6, flex: 1, flexDirection: 'row', - height: 56, + height: TOAST_HEIGHT, paddingLeft: 20, paddingRight: 10, shadowColor: changeOpacity('#000', 0.12), @@ -46,7 +49,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const Toast = ({animatedStyle, children, style, iconName, message}: ToastProps) => { +const Toast = ({animatedStyle, children, style, iconName, message, textStyle}: ToastProps) => { const theme = useTheme(); const styles = getStyleSheet(theme); const dim = useWindowDimensions(); @@ -65,11 +68,12 @@ const Toast = ({animatedStyle, children, style, iconName, message}: ToastProps) color={theme.buttonColor} name={iconName!} size={18} + style={textStyle} /> } {Boolean(message) && - {message} + {message} } {children} diff --git a/app/constants/index.ts b/app/constants/index.ts index c3120f1e45..3fc3edcf53 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -26,6 +26,7 @@ import Preferences from './preferences'; import Profile from './profile'; import Screens from './screens'; import ServerErrors from './server_errors'; +import SnackBar from './snack_bar'; import Sso from './sso'; import SupportedServer from './supported_server'; import View from './view'; @@ -35,9 +36,9 @@ export { ActionType, Apps, Categories, + Channel, Config, CustomStatusDuration, - Channel, Database, DateTime, DeepLink, @@ -57,8 +58,9 @@ export { Profile, Screens, ServerErrors, - SupportedServer, + SnackBar, Sso, + SupportedServer, View, WebsocketEvents, }; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 6f6ef80676..e00b6eb815 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -46,6 +46,7 @@ export const THREAD = 'Thread'; export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton'; export const THREAD_OPTIONS = 'ThreadOptions'; export const USER_PROFILE = 'UserProfile'; +export const SNACK_BAR = 'SnackBar'; export default { ABOUT, @@ -93,6 +94,7 @@ export default { THREAD_FOLLOW_BUTTON, THREAD_OPTIONS, USER_PROFILE, + SNACK_BAR, }; export const MODAL_SCREENS_WITHOUT_BACK = [ diff --git a/app/constants/snack_bar.ts b/app/constants/snack_bar.ts new file mode 100644 index 0000000000..484c4162fc --- /dev/null +++ b/app/constants/snack_bar.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {t} from '@i18n'; +import keyMirror from '@utils/key_mirror'; + +export const SNACK_BAR_TYPE = keyMirror({ + LINK_COPIED: null, + MESSAGE_COPIED: null, + FOLLOW_THREAD: null, + MUTE_CHANNEL: null, + FAILED_TO_SAVE_MESSAGE: null, +}); + +type SnackBarConfig = { + id: string; + defaultMessage: string; + iconName: string; + canUndo: boolean; +} +export const SNACK_BAR_CONFIG: Record = { + LINK_COPIED: { + id: t('snack.bar.link.copied'), + defaultMessage: 'Link copied to clipboard', + iconName: 'link-variant', + canUndo: false, + }, + MESSAGE_COPIED: { + id: t('snack.bar.message.copied'), + defaultMessage: 'Text copied to clipboard', + iconName: 'content-copy', + canUndo: false, + }, + FOLLOW_THREAD: { + id: t('snack.bar.follow.thread'), + defaultMessage: 'You\'re now following this thread', + iconName: 'message-check-outline', + canUndo: true, + }, + MUTE_CHANNEL: { + id: t('snack.bar.mute.channel'), + defaultMessage: 'This channel was muted', + iconName: 'bell-off-outline', + canUndo: true, + }, +}; + +export default { + SNACK_BAR_TYPE, + SNACK_BAR_CONFIG, +}; diff --git a/app/screens/home/tab_bar/index.tsx b/app/screens/home/tab_bar/index.tsx index 27fb01a15d..762cbf0b08 100644 --- a/app/screens/home/tab_bar/index.tsx +++ b/app/screens/home/tab_bar/index.tsx @@ -162,7 +162,7 @@ function TabBar({state, descriptors, navigation, theme}: BottomTabBarProps & {th target: route.key, canPreventDefault: true, }); - + DeviceEventEmitter.emit('tabPress'); if (!isFocused && !event.defaultPrevented) { // The `merge: true` option makes sure that the params inside the tab screen are preserved navigation.navigate({params: {direction}, name: route.name, merge: false}); diff --git a/app/screens/index.tsx b/app/screens/index.tsx index d908ff1eb7..90fa01cde2 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {withManagedConfig} from '@mattermost/react-native-emm'; -import React from 'react'; +import React, {ComponentType} from 'react'; import {IntlProvider} from 'react-intl'; import {Platform, StyleProp, ViewStyle} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; @@ -169,6 +169,17 @@ Navigation.setLazyComponentRegistrator((screenName) => { require('@screens/thread/thread_follow_button').default, )); break; + case Screens.SNACK_BAR: { + const snackBarScreen = withServerDatabase(require('@screens/snack_bar').default); + + Navigation.registerComponent(Screens.SNACK_BAR, () => + Platform.select({ + default: snackBarScreen, + ios: withSafeAreaInsets(snackBarScreen) as ComponentType, + }), + ); + break; + } case Screens.THREAD_OPTIONS: screen = withServerDatabase( require('@screens/thread_options').default, diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 971f68cc77..2f89cd2d61 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -629,7 +629,10 @@ export function showOverlay(name: string, passProps = {}, options = {}) { component: { id: name, name, - passProps, + passProps: { + ...passProps, + overlay: true, + }, options: merge(defaultOptions, options), }, }); diff --git a/app/screens/post_options/options/copy_text_option.tsx b/app/screens/post_options/options/copy_text_option.tsx index 696932b9fe..17c5827051 100644 --- a/app/screens/post_options/options/copy_text_option.tsx +++ b/app/screens/post_options/options/copy_text_option.tsx @@ -6,16 +6,20 @@ import React, {useCallback} from 'react'; import {BaseOption} from '@components/common_post_options'; import {Screens} from '@constants'; +import {SNACK_BAR_TYPE} from '@constants/snack_bar'; import {t} from '@i18n'; import {dismissBottomSheet} from '@screens/navigation'; +import {showSnackBar} from '@utils/snack_bar'; type Props = { + sourceScreen: typeof Screens[keyof typeof Screens]; postMessage: string; } -const CopyTextOption = ({postMessage}: Props) => { +const CopyTextOption = ({postMessage, sourceScreen}: Props) => { const handleCopyText = useCallback(async () => { await dismissBottomSheet(Screens.POST_OPTIONS); Clipboard.setString(postMessage); + showSnackBar({barType: SNACK_BAR_TYPE.MESSAGE_COPIED, sourceScreen}); }, [postMessage]); return ( diff --git a/app/screens/post_options/options/mark_unread_option/mark_unread_option.tsx b/app/screens/post_options/options/mark_unread_option/mark_unread_option.tsx index e525b5dbe8..e418699863 100644 --- a/app/screens/post_options/options/mark_unread_option/mark_unread_option.tsx +++ b/app/screens/post_options/options/mark_unread_option/mark_unread_option.tsx @@ -14,23 +14,23 @@ import {dismissBottomSheet} from '@screens/navigation'; import type PostModel from '@typings/database/models/servers/post'; type Props = { - location: typeof Screens[keyof typeof Screens]; + sourceScreen: typeof Screens[keyof typeof Screens]; post: PostModel; teamId: string; } -const MarkAsUnreadOption = ({location, post, teamId}: Props) => { +const MarkAsUnreadOption = ({sourceScreen, post, teamId}: Props) => { const serverUrl = useServerUrl(); const onPress = useCallback(async () => { await dismissBottomSheet(Screens.POST_OPTIONS); - if (location === Screens.THREAD) { + if (sourceScreen === Screens.THREAD) { const threadId = post.rootId || post.id; markThreadAsUnread(serverUrl, teamId, threadId, post.id); } else { markPostAsUnread(serverUrl, post.id); } - }, [location, post, serverUrl, teamId]); + }, [sourceScreen, post, serverUrl, teamId]); return ( { const managedConfig = useManagedConfig(); @@ -75,7 +67,7 @@ const PostOptions = ({ const canCopyPermalink = !isSystemPost && managedConfig?.copyAndPasteProtection !== 'true'; const canCopyText = canCopyPermalink && post.message; - const shouldRenderFollow = !(location !== Screens.CHANNEL || !thread); + const shouldRenderFollow = !(sourceScreen !== Screens.CHANNEL || !thread); const snapPoints = [ canAddReaction, canCopyPermalink, canCopyText, @@ -96,17 +88,26 @@ const PostOptions = ({ {canMarkAsUnread && !isSystemPost && + } + {canCopyPermalink && + } - {canCopyPermalink && } {!isSystemPost && } - {Boolean(canCopyText && post.message) && } + {Boolean(canCopyText && post.message) && + } {canPin && { + return { + text: { + color: theme.centerChannelBg, + }, + undo: { + color: theme.centerChannelBg, + ...typography('Body', 100, 'SemiBold'), + }, + gestureRoot: { + flex: 1, + height: 80, + width: '100%', + position: 'absolute', + bottom: 104, + }, + toast: { + width: '100%', + opacity: 1, + backgroundColor: theme.centerChannelColor, + }, + mobile: { + backgroundColor: theme.centerChannelColor, + width: `${SNACK_BAR_WIDTH}%`, + opacity: 1, + height: TOAST_HEIGHT, + alignSelf: 'center' as const, + borderRadius: 9, + shadowColor: '#1F000000', + shadowOffset: { + width: 0, + height: 6, + }, + shadowRadius: 4, + shadowOpacity: 0.12, + elevation: 2, + }, + }; +}); + +type SnackBarProps = { + componentId: string; + onUndoPress?: () => void; + barType: keyof typeof SNACK_BAR_TYPE; + sourceScreen: typeof Screens[keyof typeof Screens]; +} + +const SnackBar = ({barType, componentId, onUndoPress, sourceScreen}: SnackBarProps) => { + const [showSnackBar, setShowSnackBar] = useState(); + const intl = useIntl(); + const theme = useTheme(); + const isTablet = useIsTablet(); + const {width: windowWidth} = useWindowDimensions(); + const offset = useSharedValue(0); + const isPanned = useSharedValue(false); + const baseTimer = useRef(); + + const config = SNACK_BAR_CONFIG[barType]; + const styles = getStyleSheet(theme); + + const onPressHandler = useCallback(() => { + dismissOverlay(componentId); + onUndoPress?.(); + }, [onUndoPress, componentId]); + + const snackBarStyle = useMemo(() => { + const diffWidth = windowWidth - TABLET_SIDEBAR_WIDTH; + + let tabletStyle: Partial; + + switch (true) { + case sourceScreen === Screens.THREAD : + tabletStyle = { + marginLeft: 0, + width: `${SNACK_BAR_WIDTH}%`, + marginBottom: 30, + }; + break; + case sourceScreen === Screens.SAVED_POSTS : + tabletStyle = { + marginBottom: 20, + marginLeft: TABLET_SIDEBAR_WIDTH, + width: (SNACK_BAR_WIDTH / 100) * diffWidth, + }; + break; + case [Screens.PERMALINK, Screens.MENTIONS].includes(sourceScreen): + tabletStyle = { + marginBottom: 0, + marginLeft: 0, + width: (SNACK_BAR_WIDTH / 100) * windowWidth, + }; + break; + default: + tabletStyle = { + marginBottom: 40, + marginLeft: TABLET_SIDEBAR_WIDTH, + width: (SNACK_BAR_WIDTH / 100) * diffWidth, + }; + } + + return [ + styles.mobile, + isTablet && tabletStyle, + ] as AnimatedStyleProp; + }, [theme, barType]); + + const animatedMotion = useAnimatedStyle(() => { + return { + opacity: interpolate(offset.value, [0, 100], [1, 0], Extrapolation.EXTEND), + ...(isPanned.value && { + transform: [ + {translateY: offset.value}, + ], + }), + }; + }, [offset.value, isPanned.value]); + + const hideSnackBar = () => { + setShowSnackBar(false); + }; + + const stopTimers = () => { + if (baseTimer.current) { + clearTimeout(baseTimer.current); + } + }; + + const gesture = Gesture. + // eslint-disable-next-line new-cap + Pan(). + activeOffsetY(20). + onStart(() => { + isPanned.value = true; + runOnJS(stopTimers)(); + offset.value = withTiming(100, {duration: 200}); + }). + onEnd(() => { + runOnJS(hideSnackBar)(); + }); + + const animateHiding = (forceHiding: boolean) => { + const duration = forceHiding ? 0 : 200; + offset.value = withTiming(200, {duration}, () => runOnJS(hideSnackBar)()); + }; + + // This effect hides the snack bar after 3 seconds + useEffect(() => { + baseTimer.current = setTimeout(() => { + if (!isPanned.value) { + animateHiding(false); + } + }, 3000); + + return () => { + if (baseTimer.current) { + clearTimeout(baseTimer.current); + } + }; + }, [isPanned.value]); + + // This effect dismisses the Navigation Overlay after we have hidden the snack bar + useEffect(() => { + if (showSnackBar === false) { + dismissOverlay(componentId); + } + }, [showSnackBar]); + + // This effect checks if we are navigating away and if so, it dismisses the snack bar + useEffect(() => { + const onHideSnackBar = () => animateHiding(true); + const screenWillAppear = Navigation.events().registerComponentWillAppearListener(onHideSnackBar); + const screenDidDisappear = Navigation.events().registerComponentDidDisappearListener(onHideSnackBar); + const tabPress = DeviceEventEmitter.addListener('tabPress', onHideSnackBar); + const navigateToTab = DeviceEventEmitter.addListener(NavigationConstants.NAVIGATE_TO_TAB, onHideSnackBar); + + return () => { + screenWillAppear.remove(); + screenDidDisappear.remove(); + tabPress.remove(); + navigateToTab.remove(); + }; + }, []); + + return ( + + + + + {config.canUndo && onUndoPress && ( + + + {intl.formatMessage({ + id: 'snack.bar.undo', + defaultMessage: 'Undo', + })} + + + )} + + + + + ); +}; + +export default SnackBar; diff --git a/app/screens/thread_options/thread_options.tsx b/app/screens/thread_options/thread_options.tsx index d3232bf947..56e553124e 100644 --- a/app/screens/thread_options/thread_options.tsx +++ b/app/screens/thread_options/thread_options.tsx @@ -107,6 +107,7 @@ const ThreadOptions = ({ , ); } diff --git a/app/utils/snack_bar/index.ts b/app/utils/snack_bar/index.ts new file mode 100644 index 0000000000..a99fdaed3b --- /dev/null +++ b/app/utils/snack_bar/index.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {Screens} from '@constants'; +import {SNACK_BAR_TYPE} from '@constants/snack_bar'; +import {showOverlay} from '@screens/navigation'; + +type ShowSnackBarArgs = { + barType: keyof typeof SNACK_BAR_TYPE; + onPress?: () => void; + sourceScreen?: typeof Screens[keyof typeof Screens]; +}; + +export const showSnackBar = (passProps: ShowSnackBarArgs) => { + const screen = Screens.SNACK_BAR; + showOverlay(screen, passProps); +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 74613cc936..65ff9d5de4 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -543,6 +543,11 @@ "servers.login": "Log in", "servers.logout": "Log out", "servers.remove": "Remove", + "snack.bar.follow.thread": "You're now following this thread", + "snack.bar.link.copied": "Link copied to clipboard", + "snack.bar.message.copied": "Text copied to clipboard", + "snack.bar.mute.channel": "This channel was muted", + "snack.bar.undo": "Undo", "status_dropdown.set_away": "Away", "status_dropdown.set_dnd": "Do Not Disturb", "status_dropdown.set_offline": "Offline", diff --git a/package-lock.json b/package-lock.json index 1edd7d4b63..78feda8be0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "mattermost-mobile", "version": "2.0.0", "hasInstallScript": true, "license": "Apache 2.0",