-
-
Notifications
You must be signed in to change notification settings - Fork 674
home screen: Add ability to mark messages in bulk, mute topics. #4194
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,29 @@ | ||
| /* @flow strict-local */ | ||
|
|
||
| import React, { PureComponent } from 'react'; | ||
| import { StyleSheet, View } from 'react-native'; | ||
| import { StyleSheet, View, BackHandler, Alert } from 'react-native'; | ||
|
|
||
| import type { Dispatch } from '../types'; | ||
| import { Set } from 'immutable'; | ||
| import type { Dispatch, Narrow, GetText, Auth, Subscription } from '../types'; | ||
| import { connect } from '../react-redux'; | ||
| import { HOME_NARROW, MENTIONED_NARROW, STARRED_NARROW } from '../utils/narrow'; | ||
| import { TranslationContext } from '../boot/TranslationProvider'; | ||
| import { | ||
| HOME_NARROW, | ||
| MENTIONED_NARROW, | ||
| STARRED_NARROW, | ||
| topicNarrow, | ||
| caseNarrowPartial, | ||
| } from '../utils/narrow'; | ||
| import NavButton from '../nav/NavButton'; | ||
| import NavButtonGeneral from '../nav/NavButtonGeneral'; | ||
| import UnreadCards from '../unread/UnreadCards'; | ||
| import { doNarrow, navigateToSearch } from '../actions'; | ||
| import IconUnreadMentions from '../nav/IconUnreadMentions'; | ||
| import { BRAND_COLOR } from '../styles'; | ||
| import { LoadingBanner } from '../common'; | ||
| import commonStyles, { BRAND_COLOR, NAVBAR_SIZE } from '../styles'; | ||
| import * as api from '../api'; | ||
| import { getAuth, getSubscriptionsByName } from '../selectors'; | ||
| import { LoadingBanner, Label } from '../common'; | ||
| import { showToast } from '../utils/info'; | ||
|
|
||
| const styles = StyleSheet.create({ | ||
| wrapper: { | ||
|
|
@@ -23,18 +34,143 @@ const styles = StyleSheet.create({ | |
| justifyContent: 'space-between', | ||
| flexDirection: 'row', | ||
| }, | ||
| bulkSelectionNav: { | ||
| flexDirection: 'row', | ||
| height: NAVBAR_SIZE, | ||
| alignItems: 'center', | ||
| borderBottomWidth: 1, | ||
| borderColor: 'hsla(0, 0%, 50%, 0.25)', | ||
| }, | ||
| selectionCountText: { | ||
| textAlign: 'left', | ||
| marginLeft: 8, | ||
| }, | ||
| button: { | ||
| paddingHorizontal: 8, | ||
| paddingVertical: 4, | ||
| borderRadius: 18, | ||
| marginLeft: 4, | ||
| marginRight: 4, | ||
| }, | ||
| }); | ||
|
|
||
| type SelectorProps = $ReadOnly<{| | ||
| auth: Auth, | ||
| subscriptionsByName: Map<string, Subscription>, | ||
| |}>; | ||
|
|
||
| type Props = $ReadOnly<{| | ||
| dispatch: Dispatch, | ||
| ...SelectorProps, | ||
| |}>; | ||
|
|
||
| class HomeTab extends PureComponent<Props> { | ||
| render() { | ||
| type State = $ReadOnly<{| | ||
| bulkSelection: Set<string> | null, | ||
| |}>; | ||
|
|
||
| class HomeTab extends PureComponent<Props, State> { | ||
| static contextType = TranslationContext; | ||
| backHandler; | ||
| context: GetText; | ||
| state = { | ||
| bulkSelection: null, | ||
| }; | ||
|
|
||
| handleBackPress = (): boolean => { | ||
| if (this.state.bulkSelection !== null) { | ||
| this.setState({ bulkSelection: null }); | ||
| return true; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the meaning of returning true or false from this function? That would be good to answer in a code comment. |
||
| } | ||
| return false; | ||
| }; | ||
|
|
||
| componentDidMount() { | ||
| this.backHandler = BackHandler.addEventListener('hardwareBackPress', this.handleBackPress); | ||
| } | ||
|
|
||
| componentWillUnmount() { | ||
| this.backHandler.remove(); | ||
| } | ||
|
|
||
| handleTopicSelect = (stream: string, topic: string) => { | ||
| const { bulkSelection } = this.state; | ||
| const narrow = JSON.stringify(topicNarrow(stream, topic)); | ||
|
|
||
| if (bulkSelection == null) { | ||
| const selection = Set<Narrow>([narrow]); | ||
| this.setState({ bulkSelection: selection }); | ||
| return; | ||
| } | ||
|
|
||
| if (bulkSelection.has(narrow)) { | ||
| this.setState({ bulkSelection: bulkSelection.delete(narrow) }); | ||
| } else { | ||
| this.setState({ bulkSelection: bulkSelection.add(narrow) }); | ||
| } | ||
| }; | ||
|
|
||
| processBulkCommon = (type: 'mute' | 'read') => { | ||
| const { auth, subscriptionsByName } = this.props; | ||
| const { bulkSelection } = this.state; | ||
| const _ = this.context; | ||
| const apiPromises = []; | ||
|
|
||
| if (type === 'mute') { | ||
| showToast(_('Muting selected topics')); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it would be good to announce the success of the action in a toast, once it's succeeded, as something like "Muted selected topics". It's good to tell the user that their request has been acknowledged, as this "Muting..." / "Marking..." toast does. Unfortunately, our toast library doesn't handle the case where we have multiple toasts in quick succession. One way it might handle that is to move any still-present toasts upwards, to make room for a new one. But it doesn't; ah, well. Still, even if it did, I think there's a better way to tell the user that their request has been acknowledged, and using fewer words. How about using the component's |
||
| } else { | ||
| showToast(_('Marking selected topics as read')); | ||
| } | ||
|
|
||
| if (bulkSelection == null) { | ||
| return; | ||
| } | ||
|
|
||
| bulkSelection.forEach(narrow => { | ||
| const parsedNarrow: Narrow = JSON.parse(narrow); | ||
| caseNarrowPartial(parsedNarrow, { | ||
| topic: (stream: string, topic: string) => { | ||
| if (type === 'mute') { | ||
| apiPromises.push(api.muteTopic(auth, stream, topic)); | ||
| } else { | ||
| const subscription = subscriptionsByName.get(stream); | ||
| if (subscription === undefined) { | ||
| return; | ||
| } | ||
| apiPromises.push(api.markTopicAsRead(auth, subscription.stream_id, topic)); | ||
| } | ||
| }, | ||
| }); | ||
| }); | ||
|
|
||
| this.setState({ bulkSelection: null }); | ||
|
|
||
| Promise.all(apiPromises).catch(err => { | ||
| const alertTitle = | ||
| type === 'mute' ? _('Failed to mute some topics') : _('Failed to mark some topics as read'); | ||
|
|
||
| Alert.alert(alertTitle, err.message); | ||
| }); | ||
| }; | ||
|
|
||
| muteBulkSelection = () => { | ||
| this.processBulkCommon('mute'); | ||
| }; | ||
|
|
||
| readBulkSelection = () => { | ||
| this.processBulkCommon('read'); | ||
| }; | ||
|
|
||
| cancelBulkSelection = () => { | ||
| this.setState({ bulkSelection: null }); | ||
| }; | ||
|
|
||
| renderHeader = () => { | ||
| const { dispatch } = this.props; | ||
| const { bulkSelection } = this.state; | ||
| const _ = this.context; | ||
|
|
||
| return ( | ||
| <View style={styles.wrapper}> | ||
| if (bulkSelection === null) { | ||
| return ( | ||
| <View style={styles.iconList}> | ||
| <NavButton | ||
| name="globe" | ||
|
|
@@ -62,11 +198,59 @@ class HomeTab extends PureComponent<Props> { | |
| }} | ||
| /> | ||
| </View> | ||
| ); | ||
| } | ||
|
|
||
| const color = BRAND_COLOR; | ||
| const countText = _.intl.formatMessage( | ||
| { | ||
| id: '{count} topics selected', | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This needs to have an entry in static/translations/messages_en.json. |
||
| defaultMessage: '{count} topics selected', | ||
| }, | ||
| { count: bulkSelection.size }, | ||
| ); | ||
|
|
||
| return ( | ||
| <View style={styles.bulkSelectionNav}> | ||
| <NavButton | ||
| name="arrow-left" | ||
| color={color} | ||
| onPress={this.cancelBulkSelection} | ||
| title={_('Cancel')} | ||
| /> | ||
| <View style={commonStyles.navWrapper}> | ||
| <Label text={countText} style={styles.selectionCountText} /> | ||
| </View> | ||
| <NavButton | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a little confusing to use a "nav" button here, where the action isn't to navigate somewhere, but to execute an action on selected topics. Still, it is in the nav bar, and there's not an obvious other place it could go. |
||
| name="volume-x" | ||
| color={color} | ||
| onPress={this.muteBulkSelection} | ||
| title={_('Mute')} | ||
| /> | ||
| <NavButton | ||
| name="check" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I'm not sure a checkmark really conveys the meaning "mark as read". A checkmark is also used to indicate selecting a topic on the same screen, for one thing. It looks like Gmail uses an open envelope icon, in both the mobile app (iOS) and in the browser. The situation there is slightly different, though; email threads appear whether they've been read or not, and the button changes to a closed envelope when you select only threads that have been read. That's a bit of extra context that helps solidify the meaning of the open envelope (that and the fact that their product is email). Still, I think an open envelope icon might be recognizable enough, in our app, or at least more so than a checkmark? What do you think? edit: Huh, though, looks like the web app uses fa-book for "mark as read" in at least one place: |
||
| color={color} | ||
| onPress={this.readBulkSelection} | ||
| title={_('Mark as read')} | ||
| /> | ||
| </View> | ||
| ); | ||
| }; | ||
|
|
||
| render() { | ||
| const { bulkSelection } = this.state; | ||
|
|
||
| return ( | ||
| <View style={styles.wrapper}> | ||
| {this.renderHeader()} | ||
| <LoadingBanner /> | ||
| <UnreadCards /> | ||
| <UnreadCards bulkSelection={bulkSelection} handleTopicSelect={this.handleTopicSelect} /> | ||
| </View> | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export default connect<{||}, _, _>()(HomeTab); | ||
| export default connect<SelectorProps, _, _>((state, props) => ({ | ||
| auth: getAuth(state), | ||
| subscriptionsByName: getSubscriptionsByName(state), | ||
| }))(HomeTab); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,9 +2,9 @@ | |
| import React, { PureComponent } from 'react'; | ||
| import { StyleSheet, View } from 'react-native'; | ||
|
|
||
| import styles, { BRAND_COLOR } from '../styles'; | ||
| import styles, { BRAND_COLOR, HALF_COLOR } from '../styles'; | ||
| import { RawLabel, Touchable, UnreadCount } from '../common'; | ||
| import { showToast } from '../utils/info'; | ||
| import { IconCheckMarkCircle } from '../common/Icons'; | ||
|
|
||
| const componentStyles = StyleSheet.create({ | ||
| selectedRow: { | ||
|
|
@@ -19,22 +19,38 @@ const componentStyles = StyleSheet.create({ | |
| muted: { | ||
| opacity: 0.5, | ||
| }, | ||
| checkIcon: { | ||
| marginRight: 12, | ||
| }, | ||
| emptyCircle: { | ||
| width: 24, | ||
| height: 24, | ||
| borderRadius: 12, | ||
| borderWidth: 1, | ||
| borderColor: HALF_COLOR, | ||
| marginRight: 12, | ||
| }, | ||
| }); | ||
|
|
||
| type Props = $ReadOnly<{| | ||
| stream: string, | ||
| name: string, | ||
| isMuted: boolean, | ||
| isSelected: boolean, | ||
| inBulkSelectionMode: boolean, | ||
| isBulkSelected: boolean, | ||
| unreadCount: number, | ||
| onPress: (topic: string, stream: string) => void, | ||
| onLongPress: (topic: string, stream: string) => void, | ||
| |}>; | ||
|
|
||
| export default class TopicItem extends PureComponent<Props> { | ||
| static defaultProps = { | ||
| stream: '', | ||
| isMuted: false, | ||
| isSelected: false, | ||
| inBulkSelectionMode: false, | ||
| isBulkSelected: false, | ||
| unreadCount: 0, | ||
| }; | ||
|
|
||
|
|
@@ -44,8 +60,24 @@ export default class TopicItem extends PureComponent<Props> { | |
| }; | ||
|
|
||
| handleLongPress = () => { | ||
| const { name } = this.props; | ||
| showToast(name); | ||
| const { name, stream, onLongPress } = this.props; | ||
| onLongPress(stream, name); | ||
| }; | ||
|
|
||
| renderBulkSelectionIcon = () => { | ||
| const { isBulkSelected, inBulkSelectionMode } = this.props; | ||
|
|
||
| if (isBulkSelected) { | ||
| return ( | ||
| <IconCheckMarkCircle style={componentStyles.checkIcon} size={24} color={BRAND_COLOR} /> | ||
| ); | ||
| } | ||
|
|
||
| if (inBulkSelectionMode) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The empty circle is just a bit bigger than the circular checkmark icon; they should be exactly the same size, I think. I wonder if there's a pair of icons that are meant to complement each other? Like this and this, although that particular pair uses a rounded square, and it seems unavailable in the latest version of Font Awesome, hmm. If we can find two icons, from the same icon set, that are explicitly meant to complement each other, then we don't even have to think about inconsistencies between them.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not all the icons present online are actually present in the RN library. For example, the empty circle icon is missing from most icon sets while it's present in the online list. |
||
| return <View style={componentStyles.emptyCircle} />; | ||
| } | ||
|
|
||
| return null; | ||
| }; | ||
|
|
||
| render() { | ||
|
|
@@ -60,6 +92,7 @@ export default class TopicItem extends PureComponent<Props> { | |
| isMuted && componentStyles.muted, | ||
| ]} | ||
| > | ||
| {this.renderBulkSelectionIcon()} | ||
| <RawLabel | ||
| style={[componentStyles.label, isSelected && componentStyles.selectedText]} | ||
| text={name} | ||
|
|
||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's have
contextdirectly followstatic contextType. From the JSDoc onGetTextin src/types.js:Then a blank line after those, to set them apart.
For
backHandler, it looks like it's just sitting there; is there a type annotation we can give it?