MM-34842 global threads options (#5630)

* fixes MM-37294 MM-37296 MM-37297

* Added conditions to check for post & thread existence

* Update app/mm-redux/selectors/entities/threads.ts

Co-authored-by: Kyriakos Z. <3829551+koox00@users.noreply.github.com>

* type fix

* Never disabling Mark All as unread

* Added follow/unfollow message for not yet thread posts

* Test case fix for mark all as unread enabled all the time

* Removed hardcoded condition

* Fixed MM-37509

* Updated snapshot for sidebar

* Global thread actions init

* Added options

* Update post_options.js

* Test cases fix

* Added border bottom for each thread option

* Update test case

* Reverting snapshot

* Updated snapshot

* Moved options to screens & removed redundants translations

* Reusing post_option for thread_option

* Component name changed to PostOption from ThreadOption

* Snapshot updated

* Removed factory

* Update app/screens/thread_options/index.ts

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

* Update app/screens/thread_options/index.ts

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

Co-authored-by: Kyriakos Z. <3829551+koox00@users.noreply.github.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Anurag Shivarathri
2021-10-11 13:24:38 +05:30
committed by GitHub
parent 5e0c75d772
commit 856d8bd05f
16 changed files with 914 additions and 181 deletions

View File

@@ -5,9 +5,14 @@ import React from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {Alert, FlatList} from 'react-native';
import {goToScreen} from '@actions/navigation';
import {THREAD} from '@constants/screen';
import EventEmitter from '@mm-redux/utils/event_emitter';
import ThreadList from './thread_list';
import type {ActionResult} from '@mm-redux/types/actions';
import type {Post} from '@mm-redux/types/posts';
import type {Team} from '@mm-redux/types/teams';
import type {Theme} from '@mm-redux/types/theme';
import type {ThreadsState, UserThread} from '@mm-redux/types/threads';
@@ -16,10 +21,12 @@ import type {$ID} from '@mm-redux/types/utilities';
type Props = {
actions: {
getPostThread: (postId: string) => void;
getThreads: (userId: $ID<UserProfile>, teamId: $ID<Team>, before?: $ID<UserThread>, after?: $ID<UserThread>, perPage?: number, deleted?: boolean, unread?: boolean) => Promise<ActionResult>;
handleViewingGlobalThreadsAll: () => void;
handleViewingGlobalThreadsUnreads: () => void;
markAllThreadsInTeamRead: (userId: $ID<UserProfile>, teamId: $ID<Team>) => void;
selectPost: (postId: string) => void;
};
allThreadIds: $ID<UserThread>[];
intl: typeof intlShape;
@@ -119,6 +126,23 @@ function GlobalThreadsList({actions, allThreadIds, intl, teamId, theme, threadCo
);
};
const goToThread = React.useCallback((post: Post) => {
actions.getPostThread(post.id);
actions.selectPost(post.id);
const passProps = {
channelId: post.channel_id,
rootId: post.id,
};
goToScreen(THREAD, '', passProps);
}, []);
React.useEffect(() => {
EventEmitter.on('goToThread', goToThread);
return () => {
EventEmitter.off('goToThread', goToThread);
};
}, []);
return (
<ThreadList
haveUnreads={haveUnreads}

View File

@@ -4,7 +4,9 @@
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch} from 'redux';
import {getPostThread} from '@actions/views/post';
import {handleViewingGlobalThreadsAll, handleViewingGlobalThreadsUnreads} from '@actions/views/threads';
import {selectPost} from '@mm-redux/actions/posts';
import {getThreads, markAllThreadsInTeamRead} from '@mm-redux/actions/threads';
import {getCurrentUserId} from '@mm-redux/selectors/entities/common';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
@@ -32,10 +34,12 @@ function mapStateToProps(state: GlobalState) {
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
getPostThread,
getThreads,
handleViewingGlobalThreadsAll,
handleViewingGlobalThreadsUnreads,
markAllThreadsInTeamRead,
selectPost,
}, dispatch),
};
}

View File

@@ -2,6 +2,7 @@
exports[`Global Thread Item Should render thread item with unread messages dot 1`] = `
<TouchableHighlight
onLongPress={[Function]}
onPress={[Function]}
testID="thread_item.post1.item"
underlayColor="rgba(28,88,217,0.08)"
@@ -185,6 +186,7 @@ exports[`Global Thread Item Should render thread item with unread messages dot 1
exports[`Global Thread Item Should show unread mentions count 1`] = `
<TouchableHighlight
onLongPress={[Function]}
onPress={[Function]}
testID="thread_item.post1.item"
underlayColor="rgba(28,88,217,0.08)"

View File

@@ -4,8 +4,7 @@
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch} from 'redux';
import {getPost, getPostThread} from '@actions/views/post';
import {selectPost} from '@mm-redux/actions/posts';
import {getPost} from '@actions/views/post';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {getPost as getPostSelector} from '@mm-redux/selectors/entities/posts';
import {getThread} from '@mm-redux/selectors/entities/threads';
@@ -30,8 +29,6 @@ function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
getPost,
getPostThread,
selectPost,
}, dispatch),
};
}

View File

@@ -5,13 +5,12 @@ import {shallow} from 'enzyme';
import React from 'react';
import {Text} from 'react-native';
import * as navigationActions from '@actions/navigation';
import {THREAD} from '@constants/screen';
import {Preferences} from '@mm-redux/constants';
import {Channel} from '@mm-redux/types/channels';
import {Post} from '@mm-redux/types/posts';
import {UserThread} from '@mm-redux/types/threads';
import {UserProfile} from '@mm-redux/types/users';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {intl} from '@test/intl-test-helper';
import {ThreadItem} from './thread_item';
@@ -99,7 +98,7 @@ describe('Global Thread Item', () => {
});
test('Should goto threads when pressed on thread item', () => {
const goToScreen = jest.spyOn(navigationActions, 'goToScreen');
EventEmitter.emit = jest.fn();
const wrapper = shallow(
<ThreadItem
{...baseProps}
@@ -108,6 +107,6 @@ describe('Global Thread Item', () => {
const threadItem = wrapper.find({testID: `${testIDPrefix}.item`});
expect(threadItem.exists()).toBeTruthy();
threadItem.simulate('press');
expect(goToScreen).toHaveBeenCalledWith(THREAD, expect.anything(), expect.anything());
expect(EventEmitter.emit).toHaveBeenCalledWith('goToThread', expect.anything());
});
});

View File

@@ -3,29 +3,28 @@
import React from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {View, Text, TouchableHighlight} from 'react-native';
import {Keyboard, Text, TouchableHighlight, View} from 'react-native';
import {goToScreen} from '@actions/navigation';
import {showModalOverCurrentContext} from '@actions/navigation';
import FriendlyDate from '@components/friendly_date';
import RemoveMarkdown from '@components/remove_markdown';
import {GLOBAL_THREADS, THREAD} from '@constants/screen';
import {GLOBAL_THREADS} from '@constants/screen';
import {Posts, Preferences} from '@mm-redux/constants';
import {Channel} from '@mm-redux/types/channels';
import {Post} from '@mm-redux/types/posts';
import {UserThread} from '@mm-redux/types/threads';
import {UserProfile} from '@mm-redux/types/users';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {displayUsername} from '@mm-redux/utils/user_utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import ThreadFooter from '../thread_footer';
import type {Channel} from '@mm-redux/types/channels';
import type {Post} from '@mm-redux/types/posts';
import type {Theme} from '@mm-redux/types/theme';
import type {UserThread} from '@mm-redux/types/threads';
import type {UserProfile} from '@mm-redux/types/users';
export type DispatchProps = {
actions: {
getPost: (postId: string) => void;
getPostThread: (postId: string) => void;
selectPost: (postId: string) => void;
};
}
@@ -66,13 +65,18 @@ function ThreadItem({actions, channel, intl, post, threadId, testID, theme, thre
const threadStarterName = displayUsername(threadStarter, Preferences.DISPLAY_PREFER_FULL_NAME);
const showThread = () => {
actions.getPostThread(postItem.id);
actions.selectPost(postItem.id);
EventEmitter.emit('goToThread', postItem);
};
const showThreadOptions = () => {
const screen = 'GlobalThreadOptions';
const passProps = {
channelId: postItem.channel_id,
rootId: postItem.id,
rootId: post.id,
};
goToScreen(THREAD, '', passProps);
Keyboard.dismiss();
requestAnimationFrame(() => {
showModalOverCurrentContext(screen, passProps);
});
};
const testIDPrefix = `${testID}.${postItem?.id}`;
@@ -134,6 +138,7 @@ function ThreadItem({actions, channel, intl, post, threadId, testID, theme, thre
return (
<TouchableHighlight
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
onLongPress={showThreadOptions}
onPress={showThread}
testID={`${testIDPrefix}.item`}
>

View File

@@ -5,7 +5,7 @@
import {updateThreadLastViewedAt} from '@actions/views/threads';
import {Client4} from '@client/rest';
import {WebsocketEvents} from '@constants';
import {THREAD} from '@constants/screen';
import {GLOBAL_THREADS, THREAD} from '@constants/screen';
import {analytics} from '@init/analytics';
import {PostTypes, ChannelTypes, FileTypes, IntegrationTypes} from '@mm-redux/action_types';
import {handleFollowChanged, updateThreadRead} from '@mm-redux/actions/threads';
@@ -444,8 +444,8 @@ export function setUnreadPost(userId: string, postId: string, location: string)
return {};
}
const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state);
const isUnreadFromThreadScreen = collapsedThreadsEnabled && location === THREAD;
if (isUnreadFromThreadScreen) {
const isUnreadFromThread = collapsedThreadsEnabled && (location === THREAD || location === GLOBAL_THREADS);
if (isUnreadFromThread) {
const currentTeamId = getThreadTeamId(state, postId);
const threadId = post.root_id || post.id;
const actions: GenericAction[] = [];

View File

@@ -105,6 +105,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case 'Gallery':
screen = require('@screens/gallery').default;
break;
case 'GlobalThreadOptions':
screen = require('@screens/thread_options').default;
break;
case 'InteractiveDialog':
screen = require('@screens/interactive_dialog').default;
break;

View File

@@ -200,7 +200,7 @@ class Option extends React.PureComponent<OptionProps> {
return (
<PostOption
icon={{uri: binding.icon}}
icon={{uri: binding.icon!}}
text={binding.label}
onPress={this.onPress}
theme={theme}

View File

@@ -1,155 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React, {PureComponent} from 'react';
import {
Text,
Platform,
TouchableHighlight,
TouchableNativeFeedback,
View,
} from 'react-native';
import FastImage from 'react-native-fast-image';
import CompassIcon from '@components/compass_icon';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {isValidUrl} from '@utils/url';
export default class PostOption extends PureComponent {
static propTypes = {
testID: PropTypes.string,
destructive: PropTypes.bool,
icon: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
]).isRequired,
onPress: PropTypes.func.isRequired,
text: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
};
handleOnPress = preventDoubleTap(() => {
this.props.onPress();
}, 500);
render() {
const {destructive, icon, testID, text, theme} = this.props;
const style = getStyleSheet(theme);
const Touchable = Platform.select({
ios: TouchableHighlight,
android: TouchableNativeFeedback,
});
const touchableProps = Platform.select({
ios: {
underlayColor: 'rgba(0, 0, 0, 0.1)',
},
android: {
background: TouchableNativeFeedback.Ripple( //eslint-disable-line new-cap
'rgba(0, 0, 0, 0.1)',
false,
),
},
});
const imageStyle = [style.icon, destructive ? style.destructive : null];
let image;
let iconStyle = [style.iconContainer];
if (typeof icon === 'object') {
if (icon.uri) {
imageStyle.push({width: 24, height: 24});
image = isValidUrl(icon.uri) && (
<FastImage
source={icon}
style={imageStyle}
/>
);
} else {
iconStyle = [style.noIconContainer];
}
} else {
image = (
<CompassIcon
name={icon}
size={24}
style={[style.icon, destructive ? style.destructive : null]}
/>
);
}
return (
<View
testID={testID}
style={style.container}
>
<Touchable
onPress={this.handleOnPress}
{...touchableProps}
style={style.row}
>
<View style={style.row}>
<View style={iconStyle}>
{image}
</View>
<View style={style.textContainer}>
<Text style={[style.text, destructive ? style.destructive : null]}>
{text}
</Text>
</View>
</View>
</Touchable>
<View style={style.footer}/>
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
height: 51,
width: '100%',
},
destructive: {
color: '#D0021B',
},
row: {
flex: 1,
flexDirection: 'row',
},
iconContainer: {
alignItems: 'center',
height: 50,
justifyContent: 'center',
width: 60,
},
noIconContainer: {
height: 50,
width: 18,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.64),
},
textContainer: {
justifyContent: 'center',
flex: 1,
height: 50,
marginRight: 5,
},
text: {
color: theme.centerChannelColor,
fontSize: 16,
lineHeight: 19,
opacity: 0.9,
letterSpacing: -0.45,
},
footer: {
marginHorizontal: 17,
borderBottomWidth: 0.5,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2),
},
};
});

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {
Text,
Platform,
TouchableHighlight,
TouchableNativeFeedback,
View,
} from 'react-native';
import FastImage from 'react-native-fast-image';
import CompassIcon from '@components/compass_icon';
import {Theme} from '@mm-redux/types/theme';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {isValidUrl} from '@utils/url';
type Props = {
testID?: string;
destructive?: boolean;
icon: string | {
uri: string;
};
onPress: () => void;
text: string;
theme: Theme;
};
function PostOption({destructive, icon, onPress, testID, text, theme}: Props) {
const style = getStyleSheet(theme);
const handleOnPress = React.useCallback(preventDoubleTap(onPress, 500), []);
let Touchable: React.ElementType;
if (Platform.OS === 'android') {
Touchable = TouchableNativeFeedback;
} else {
Touchable = TouchableHighlight;
}
const touchableProps = Platform.select({
ios: {
underlayColor: 'rgba(0, 0, 0, 0.1)',
},
android: {
background: TouchableNativeFeedback.Ripple( //eslint-disable-line new-cap
'rgba(0, 0, 0, 0.1)',
false,
),
},
});
const imageStyle = [style.icon, destructive ? style.destructive : null];
let image;
let iconStyle = [style.iconContainer];
if (typeof icon === 'object') {
if (icon.uri) {
imageStyle.push({width: 24, height: 24});
image = isValidUrl(icon.uri) && (
<FastImage
source={icon}
style={imageStyle}
/>
);
} else {
iconStyle = [style.noIconContainer];
}
} else {
image = (
<CompassIcon
name={icon}
size={24}
style={[style.icon, destructive ? style.destructive : null]}
/>
);
}
return (
<View
testID={testID}
style={style.container}
>
<Touchable
onPress={handleOnPress}
{...touchableProps}
style={style.row}
>
<View style={style.row}>
<View style={iconStyle}>
{image}
</View>
<View style={style.textContainer}>
<Text style={[style.text, destructive ? style.destructive : null]}>
{text}
</Text>
</View>
</View>
</Touchable>
<View style={style.footer}/>
</View>
);
}
export default PostOption;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
height: 51,
width: '100%',
},
destructive: {
color: '#D0021B',
},
row: {
flex: 1,
flexDirection: 'row',
},
iconContainer: {
alignItems: 'center',
height: 50,
justifyContent: 'center',
width: 60,
},
noIconContainer: {
height: 50,
width: 18,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.64),
},
textContainer: {
justifyContent: 'center',
flex: 1,
height: 50,
marginRight: 5,
},
text: {
color: theme.centerChannelColor,
fontSize: 16,
lineHeight: 19,
opacity: 0.9,
letterSpacing: -0.45,
},
footer: {
marginHorizontal: 17,
borderBottomWidth: 0.5,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2),
},
};
});

View File

@@ -0,0 +1,278 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ThreadOptions should match snapshot, showing all possible options 1`] = `
<View
style={
Object {
"flex": 1,
}
}
testID="global_threads.item.options"
>
<Connect(SlideUpPanel)
allowStayMiddle={false}
initialPosition={400}
marginFromTop={200}
onRequestClose={[Function]}
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
>
<InjectIntl(FormattedText)
defaultMessage="THREAD ACTIONS"
id="global_threads.options.title"
style={
Object {
"color": "rgba(63,67,80,0.65)",
"fontSize": 12,
"paddingBottom": 8,
"paddingLeft": 16,
"paddingTop": 16,
}
}
/>
<PostOption
destructive={false}
icon="reply-outline"
onPress={[Function]}
testID="global_threads.options.reply.action"
text="Reply"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
<PostOption
destructive={false}
icon="message-minus-outline"
onPress={[Function]}
testID="global_threads.options.unfollow.action"
text="Unfollow Thread"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
<PostOption
destructive={false}
icon="globe"
onPress={[Function]}
testID="global_threads.options.open_in_channel.action"
text="Open in Channel"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
<PostOption
destructive={false}
icon="mark-as-unread"
onPress={[Function]}
testID="global_threads.options.mark_as_read.action"
text="Mark as Read"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
<PostOption
destructive={false}
icon="bookmark-outline"
onPress={[Function]}
testID="global_threads.options.unflag.action"
text="Unsave"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
<PostOption
destructive={false}
icon="link-variant"
onPress={[Function]}
testID="global_threads.options.permalink.action"
text="Copy Link"
theme={
Object {
"awayIndicator": "#ffbc1f",
"buttonBg": "#1c58d9",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3f4350",
"codeTheme": "github",
"dndIndicator": "#d24b4e",
"errorTextColor": "#d24b4e",
"linkColor": "#386fe5",
"mentionBg": "#ffffff",
"mentionColor": "#1e325c",
"mentionHighlightBg": "#ffd470",
"mentionHighlightLink": "#1b1d22",
"newMessageSeparator": "#cc8f00",
"onlineIndicator": "#3db887",
"sidebarBg": "#1e325c",
"sidebarHeaderBg": "#192a4d",
"sidebarHeaderTextColor": "#ffffff",
"sidebarTeamBarBg": "#14213e",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#5d89ea",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#28427b",
"sidebarUnreadText": "#ffffff",
"type": "Denim",
}
}
/>
</Connect(SlideUpPanel)>
</View>
`;

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch} from 'redux';
import {showPermalink} from '@actions/views/permalink';
import {
flagPost,
setUnreadPost,
unflagPost,
} from '@mm-redux/actions/posts';
import {setThreadFollow, updateThreadRead} from '@mm-redux/actions/threads';
import {getCurrentUserId} from '@mm-redux/selectors/entities/common';
import {getPost} from '@mm-redux/selectors/entities/posts';
import {getMyPreferences, getTheme} from '@mm-redux/selectors/entities/preferences';
import {getCurrentTeam, getCurrentTeamUrl} from '@mm-redux/selectors/entities/teams';
import {getThread} from '@mm-redux/selectors/entities/threads';
import {isPostFlagged} from '@mm-redux/utils/post_utils';
import {getDimensions} from '@selectors/device';
import ThreadOptions, {OwnProps} from './thread_options';
import type {GlobalState} from '@mm-redux/types/store';
export function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const myPreferences = getMyPreferences(state);
return {
...getDimensions(state),
currentTeamName: getCurrentTeam(state)?.name,
currentTeamUrl: getCurrentTeamUrl(state),
currentUserId: getCurrentUserId(state),
isFlagged: isPostFlagged(ownProps.rootId, myPreferences),
post: getPost(state, ownProps.rootId),
theme: getTheme(state),
thread: getThread(state, ownProps.rootId),
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
flagPost,
setThreadFollow,
setUnreadPost,
showPermalink,
unflagPost,
updateThreadRead,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ThreadOptions);

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import Preferences from '@mm-redux/constants/preferences';
import {intl} from '@test/intl-test-helper';
import {ThreadOptions} from './thread_options';
import type {Post} from '@mm-redux/types/posts';
import type {UserThread} from '@mm-redux/types/threads';
describe('ThreadOptions', () => {
const actions = {
flagPost: jest.fn(),
setThreadFollow: jest.fn(),
setUnreadPost: jest.fn(),
showPermalink: jest.fn(),
unflagPost: jest.fn(),
updateThreadRead: jest.fn(),
};
const post = {
id: 'post_id',
message: 'message',
is_pinned: false,
channel_id: 'channel_id',
} as Post;
const thread = {
id: 'post_id',
unread_replies: 4,
} as UserThread;
const baseProps = {
actions,
currentTeamName: 'current team name',
currentTeamUrl: 'http://localhost:8065/team-name',
currentUserId: 'user1',
deviceHeight: 600,
isFlagged: true,
intl,
post,
rootId: 'post_id',
theme: Preferences.THEMES.denim,
thread,
};
function getWrapper(props = {}) {
return shallow(
<ThreadOptions
{...baseProps}
{...props}
/>,
);
}
test('should match snapshot, showing all possible options', () => {
const wrapper = getWrapper();
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.findWhere((node) => node.key() === 'flagged')).toMatchObject({});
expect(wrapper.findWhere((node) => node.key() === 'mark_as_read')).toMatchObject({});
});
test('should show unflag option', () => {
const wrapper = getWrapper({isFlagged: false});
expect(wrapper.findWhere((node) => node.key() === 'unflag')).toMatchObject({});
});
test('should show unflag option', () => {
const wrapper = getWrapper({
thread: {
...thread,
unread_replies: 0,
},
});
expect(wrapper.findWhere((node) => node.key() === 'mark_as_unread')).toMatchObject({});
});
});

View File

@@ -0,0 +1,285 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Clipboard from '@react-native-community/clipboard';
import React from 'react';
import {intlShape, injectIntl} from 'react-intl';
import {View} from 'react-native';
import {dismissModal} from '@actions/navigation';
import FormattedText from '@components/formatted_text';
import SlideUpPanel from '@components/slide_up_panel';
import {BOTTOM_MARGIN} from '@components/slide_up_panel/slide_up_panel';
import {GLOBAL_THREADS} from '@constants/screen';
import EventEmitter from '@mm-redux/utils/event_emitter';
import ThreadOption from '@screens/post_options/post_option';
import {OPTION_HEIGHT, getInitialPosition} from '@screens/post_options/post_options_utils';
import {t} from '@utils/i18n';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {Post} from '@mm-redux/types/posts';
import type {Theme} from '@mm-redux/types/theme';
import type {UserThread} from '@mm-redux/types/threads';
import type {UserProfile} from '@mm-redux/types/users';
import type {$ID} from '@mm-redux/types/utilities';
export type StateProps = {
currentTeamName: string;
currentTeamUrl: string;
currentUserId: $ID<UserProfile>;
deviceHeight: number;
isFlagged: boolean;
post: Post;
theme: Theme;
thread: UserThread;
};
export type DispatchProps = {
actions: {
flagPost: (postId: $ID<Post>) => void;
setThreadFollow: (currentUserId: $ID<UserProfile>, threadId: $ID<UserThread>, newState: boolean) => void;
setUnreadPost: (currentUserId: $ID<UserProfile>, postId: $ID<Post>, location: string) => void;
showPermalink: (currentUserId: $ID<UserProfile>, teamName: string, postId: $ID<Post>) => void;
unflagPost: (postId: $ID<Post>) => void;
updateThreadRead: (currentUserId: $ID<UserProfile>, threadId: $ID<UserThread>, timestamp: number) => void;
};
};
export type OwnProps = {
rootId: $ID<Post>;
};
type Props = StateProps & DispatchProps & OwnProps & {
intl: typeof intlShape;
};
function ThreadOptions({actions, currentTeamName, currentTeamUrl, currentUserId, deviceHeight, intl, isFlagged, post, theme, thread}: Props) {
const style = getStyleSheet(theme);
const slideUpPanelRef = React.useRef<any>();
const close = async (cb?: () => void) => {
await dismissModal();
if (typeof cb === 'function') {
requestAnimationFrame(cb);
}
};
const closeWithAnimation = (cb?: () => void) => {
if (slideUpPanelRef.current) {
slideUpPanelRef.current.closeWithAnimation(cb);
} else {
close(cb);
}
};
const getOption = (key: string, icon: string, message: Record<string, any>, onPress: () => void, destructive = false) => {
const testID = `global_threads.options.${key}.action`;
return (
<ThreadOption
testID={testID}
key={key}
icon={icon}
text={intl.formatMessage(message)}
onPress={onPress}
destructive={destructive}
theme={theme}
/>
);
};
//
// Option: Reply
//
const handleReply = () => {
closeWithAnimation(() => {
EventEmitter.emit('goToThread', post);
});
};
const getReplyOption = () => {
const key = 'reply';
const icon = 'reply-outline';
const message = {id: t('mobile.post_info.reply'), defaultMessage: 'Reply'};
const onPress = handleReply;
return getOption(key, icon, message, onPress);
};
//
// Option: Unfollow thread
//
const handleUnfollowThread = () => {
closeWithAnimation(() => {
actions.setThreadFollow(currentUserId, thread.id, false);
});
};
const getUnfollowThread = () => {
const key = 'unfollow';
const icon = 'message-minus-outline';
const message = {id: t('global_threads.options.unfollow'), defaultMessage: 'Unfollow Thread'};
const onPress = handleUnfollowThread;
return getOption(key, icon, message, onPress);
};
//
// Option: Open in Channel
//
const handleOpenInChannel = () => {
closeWithAnimation(() => {
actions.showPermalink(intl, currentTeamName, post.id);
});
};
const getOpenInChannel = () => {
const key = 'open_in_channel';
const icon = 'globe';
const message = {id: t('global_threads.options.open_in_channel'), defaultMessage: 'Open in Channel'};
const onPress = handleOpenInChannel;
return getOption(key, icon, message, onPress);
};
//
// Option: Mark as Read
//
const handleMarkAsRead = () => {
closeWithAnimation(() => {
actions.updateThreadRead(
currentUserId,
post.id,
Date.now(),
);
});
};
const handleMarkAsUnread = () => {
closeWithAnimation(() => {
actions.setUnreadPost(currentUserId, post.id, GLOBAL_THREADS);
});
};
const getMarkAsUnreadOption = () => {
const icon = 'mark-as-unread';
let key;
let message;
let onPress;
if (thread.unread_replies) {
key = 'mark_as_read';
message = {id: t('global_threads.options.mark_as_read'), defaultMessage: 'Mark as Read'};
onPress = handleMarkAsRead;
} else {
key = 'mark_as_unread';
message = {id: t('mobile.post_info.mark_unread'), defaultMessage: 'Mark as Unread'};
onPress = handleMarkAsUnread;
}
return getOption(key, icon, message, onPress);
};
//
// Option: Flag/Unflag
//
const handleFlagPost = () => {
closeWithAnimation(() => {
actions.flagPost(post.id);
});
};
const handleUnflagPost = () => {
closeWithAnimation(() => {
actions.unflagPost(post.id);
});
};
const getFlagOption = () => {
let key;
let message;
let onPress;
const icon = 'bookmark-outline';
if (isFlagged) {
key = 'unflag';
message = {id: t('mobile.post_info.unflag'), defaultMessage: 'Unsave'};
onPress = handleUnflagPost;
} else {
key = 'flagged';
message = {id: t('mobile.post_info.flag'), defaultMessage: 'Save'};
onPress = handleFlagPost;
}
return getOption(key, icon, message, onPress);
};
//
// Option: Copy Link
//
const handleCopyPermalink = () => {
closeWithAnimation(() => {
const permalink = `${currentTeamUrl}/pl/${post.id}`;
Clipboard.setString(permalink);
});
};
const getCopyPermalink = () => {
const key = 'permalink';
const icon = 'link-variant';
const message = {id: t('get_post_link_modal.title'), defaultMessage: 'Copy Link'};
const onPress = handleCopyPermalink;
return getOption(key, icon, message, onPress);
};
const options = [
getReplyOption(),
getUnfollowThread(),
getOpenInChannel(),
getMarkAsUnreadOption(),
getFlagOption(),
getCopyPermalink(),
].filter((option) => option !== null);
const marginFromTop = deviceHeight - BOTTOM_MARGIN - ((options.length + 2) * OPTION_HEIGHT);
const initialPosition = getInitialPosition(deviceHeight, marginFromTop);
return (
<View
testID='global_threads.item.options'
style={style.container}
>
<SlideUpPanel
allowStayMiddle={false}
marginFromTop={marginFromTop > 0 ? marginFromTop : 0}
onRequestClose={close}
initialPosition={initialPosition}
key={marginFromTop}
ref={slideUpPanelRef}
theme={theme}
>
<FormattedText
id='global_threads.options.title'
defaultMessage='THREAD ACTIONS'
style={style.title}
/>
{options}
</SlideUpPanel>
</View>
);
}
export {ThreadOptions};
export default injectIntl(ThreadOptions);
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
},
title: {
color: changeOpacity(theme.centerChannelColor, 0.65),
fontSize: 12,
paddingLeft: 16,
paddingTop: 16,
paddingBottom: 8,
},
};
});

View File

@@ -199,6 +199,10 @@
"global_threads.markAllRead.markRead": "Mark read",
"global_threads.markAllRead.message": "This will clear any unread status for all of your threads shown here",
"global_threads.markAllRead.title": "Are you sure you want to mark all threads as read?",
"global_threads.options.mark_as_read": "Mark as Read",
"global_threads.options.open_in_channel": "Open in Channel",
"global_threads.options.title": "THREAD ACTIONS",
"global_threads.options.unfollow": "Unfollow Thread",
"global_threads.unreads": "Unreads",
"integrations.add": "Add",
"intro_messages.anyMember": " Any member can join and read this channel.",