MM-41532 Gekidou Snack Bars (#6181)

* styling mobile

wip

* styling tablet

wip

tablet portrait

* removed offSetY

* Update en.json

* corrections from reviews

* removed space

* changed location to sourceScreen in post.tsx

* adjust width

* update message to text

* adding PanGesture

adding PanGesture- wip

PanGesture- wip

* able to touch through on iOS

* using EphemeralStore and listeners to discard overlays

* snack positioning and touches

* PanGesture- wip

* PanGesture- fine tuning the animation

PanGesture- wip

* removed toast keyword

* checks for ongoing animation

* dismiss overlay on navigating away

* dismiss overlay on navigating away

* dismiss overlay on tabPress

* dismiss overlay on tabPress

* cancelled timers on panning start

* add entering layoutAnimation

* add exitingg layoutAnimation

* fix  layoutAnimation

* fix styling

* eslint fix

* style fix

* fix timer not stopping

* revert changes made to the ephemeral store

* bumping the toast vertically by 4px

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Avinash Lingaloo
2022-05-05 18:00:32 +04:00
committed by GitHub
parent 2deb2e01e0
commit 83c2cfff97
18 changed files with 390 additions and 39 deletions

View File

@@ -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 (

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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<ViewStyle>;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}
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) &&
<View style={styles.flex}>
<Text style={styles.text}>{message}</Text>
<Text style={[styles.text, textStyle]}>{message}</Text>
</View>
}
{children}

View File

@@ -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,
};

View File

@@ -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 = [

View File

@@ -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<string, SnackBarConfig> = {
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,
};

View File

@@ -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});

View File

@@ -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,

View File

@@ -629,7 +629,10 @@ export function showOverlay(name: string, passProps = {}, options = {}) {
component: {
id: name,
name,
passProps,
passProps: {
...passProps,
overlay: true,
},
options: merge(defaultOptions, options),
},
});

View File

@@ -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 (

View File

@@ -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 (
<BaseOption

View File

@@ -31,25 +31,17 @@ type PostOptionsProps = {
canReply: boolean;
combinedPost?: Post;
isSaved: boolean;
location: typeof Screens[keyof typeof Screens];
sourceScreen: typeof Screens[keyof typeof Screens];
post: PostModel;
thread?: ThreadModel;
componentId: string;
};
const PostOptions = ({
canAddReaction,
canDelete,
canEdit,
canMarkAsUnread,
canPin,
canReply,
combinedPost,
componentId,
isSaved,
location,
post,
thread,
canAddReaction, canDelete, canEdit,
canMarkAsUnread, canPin, canReply,
combinedPost, componentId, isSaved,
sourceScreen, post, thread,
}: PostOptionsProps) => {
const managedConfig = useManagedConfig<ManagedConfig>();
@@ -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 &&
<MarkAsUnreadOption
post={post}
location={location}
sourceScreen={sourceScreen}
/>
}
{canCopyPermalink &&
<CopyPermalinkOption
post={post}
sourceScreen={sourceScreen}
/>
}
{canCopyPermalink && <CopyPermalinkOption post={post}/>}
{!isSystemPost &&
<SaveOption
isSaved={isSaved}
postId={post.id}
/>
}
{Boolean(canCopyText && post.message) && <CopyTextOption postMessage={post.message}/>}
{Boolean(canCopyText && post.message) &&
<CopyTextOption
postMessage={post.message}
sourceScreen={sourceScreen}
/>}
{canPin &&
<PinChannelOption
isPostPinned={post.isPinned}
@@ -129,7 +130,7 @@ const PostOptions = ({
};
// This fixes opening "post options modal" on top of "thread modal"
const additionalSnapPoints = location === Screens.THREAD ? 3 : 2;
const additionalSnapPoints = sourceScreen === Screens.THREAD ? 3 : 2;
return (
<BottomSheet

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter, Text, TouchableOpacity, useWindowDimensions, ViewStyle} from 'react-native';
import {Gesture, GestureDetector, GestureHandlerRootView} from 'react-native-gesture-handler';
import {Navigation} from 'react-native-navigation';
import Animated, {
AnimatedStyleProp,
Extrapolation,
FadeIn,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import Toast, {TOAST_HEIGHT} from '@components/toast';
import {Navigation as NavigationConstants, Screens} from '@constants';
import {SNACK_BAR_CONFIG, SNACK_BAR_TYPE} from '@constants/snack_bar';
import {TABLET_SIDEBAR_WIDTH} from '@constants/view';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {dismissOverlay} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const SNACK_BAR_WIDTH = 96;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
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<boolean | undefined>();
const intl = useIntl();
const theme = useTheme();
const isTablet = useIsTablet();
const {width: windowWidth} = useWindowDimensions();
const offset = useSharedValue(0);
const isPanned = useSharedValue(false);
const baseTimer = useRef<NodeJS.Timeout>();
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<ViewStyle>;
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<ViewStyle>;
}, [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 (
<GestureHandlerRootView style={styles.gestureRoot}>
<GestureDetector gesture={gesture}>
<Animated.View
style={animatedMotion}
entering={FadeIn.duration(300)}
>
<Toast
animatedStyle={snackBarStyle}
message={intl.formatMessage({id: config.id, defaultMessage: config.defaultMessage})}
iconName={config.iconName}
textStyle={styles.text}
style={styles.toast}
>
{config.canUndo && onUndoPress && (
<TouchableOpacity onPress={onPressHandler}>
<Text style={styles.undo}>
{intl.formatMessage({
id: 'snack.bar.undo',
defaultMessage: 'Undo',
})}
</Text>
</TouchableOpacity>
)}
</Toast>
</Animated.View>
</GestureDetector>
</GestureHandlerRootView>
);
};
export default SnackBar;

View File

@@ -107,6 +107,7 @@ const ThreadOptions = ({
<CopyPermalinkOption
key='copy-link'
post={post}
sourceScreen={Screens.THREAD_OPTIONS}
/>,
);
}

View File

@@ -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);
};

View File

@@ -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",

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",