diff --git a/src/account-info/AccountDetails.js b/src/account-info/AccountDetails.js index dbb1b04d03c..cb26b54be63 100644 --- a/src/account-info/AccountDetails.js +++ b/src/account-info/AccountDetails.js @@ -3,6 +3,8 @@ import React from 'react'; import type { Node } from 'react'; import { View } from 'react-native'; +import Emoji from '../emoji/Emoji'; +import { emojiTypeFromReactionType } from '../emoji/data'; import type { UserOrBot } from '../types'; import styles, { createStyleSheet } from '../styles'; import { useSelector } from '../react-redux'; @@ -21,6 +23,7 @@ const componentStyles = createStyleSheet({ }, statusWrapper: { justifyContent: 'center', + alignItems: 'center', flexDirection: 'row', }, presenceStatusIndicator: { @@ -42,6 +45,9 @@ export default function AccountDetails(props: Props): Node { const ownUser = useSelector(getOwnUser); const userStatusText = useSelector(state => getUserStatus(state, props.user.user_id).status_text); + const userStatusEmoji = useSelector( + state => getUserStatus(state, props.user.user_id).status_emoji, + ); const isSelf = user.user_id === ownUser.user_id; @@ -70,9 +76,22 @@ export default function AccountDetails(props: Props): Node { /> - {userStatusText !== null && ( - - )} + + {userStatusEmoji && ( + + )} + {userStatusEmoji && userStatusText !== null && } + {userStatusText !== null && ( + + )} + {!isSelf && ( diff --git a/src/action-sheets/index.js b/src/action-sheets/index.js index ba4bc462ac1..cddf00fc110 100644 --- a/src/action-sheets/index.js +++ b/src/action-sheets/index.js @@ -41,6 +41,7 @@ import * as logging from '../utils/logging'; import { getUnreadCountForTopic } from '../unread/unreadModel'; import getIsNotificationEnabled from '../streams/getIsNotificationEnabled'; import { getStreamTopicUrl, getStreamUrl } from '../utils/internalLinks'; +import { reactionTypeFromEmojiType } from '../emoji/data'; // TODO really this belongs in a libdef. export type ShowActionSheetWithOptions = ( @@ -343,8 +344,17 @@ const shareMessage = { const addReaction = { title: 'Add a reaction', errorMessage: 'Failed to add reaction', - action: ({ message }) => { - NavigationService.dispatch(navigateToEmojiPicker(message.id)); + action: ({ auth, message, _ }) => { + NavigationService.dispatch( + navigateToEmojiPicker(({ code, name, type }) => { + api + .emojiReactionAdd(auth, message.id, reactionTypeFromEmojiType(type, name), code, name) + .catch(err => { + logging.error('Error adding reaction emoji', err); + showToast(_('Failed to add reaction')); + }); + }), + ); }, }; diff --git a/src/emoji/Emoji.js b/src/emoji/Emoji.js index c35fd2ac948..b879ad5ddb2 100644 --- a/src/emoji/Emoji.js +++ b/src/emoji/Emoji.js @@ -1,5 +1,5 @@ /* @flow strict-local */ -import React from 'react'; +import React, { useMemo } from 'react'; import type { Node } from 'react'; import { Image } from 'react-native'; import { createIconSet } from 'react-native-vector-icons'; @@ -18,19 +18,20 @@ const UnicodeEmoji = createIconSet(codeToEmojiMap); type Props = $ReadOnly<{| type: EmojiType, code: string, + size?: number, |}>; -const componentStyles = createStyleSheet({ - image: { width: 20, height: 20 }, -}); - export default function Emoji(props: Props): Node { - const { code } = props; + const { code, size = 20 } = props; const imageEmoji = useSelector(state => props.type === 'image' ? getAllImageEmojiByCode(state)[props.code] : undefined, ); + const componentStyles = useMemo( + () => createStyleSheet({ image: { width: size, height: size } }), + [size], + ); if (imageEmoji) { return ; } - return ; + return ; } diff --git a/src/emoji/EmojiPickerScreen.js b/src/emoji/EmojiPickerScreen.js index 8284e7e6b99..66f47454b63 100644 --- a/src/emoji/EmojiPickerScreen.js +++ b/src/emoji/EmojiPickerScreen.js @@ -1,36 +1,50 @@ /* @flow strict-local */ -import React, { useState, useCallback, useContext } from 'react'; +import React, { useState, useCallback } from 'react'; import type { Node } from 'react'; -import { FlatList } from 'react-native'; +import { FlatList, LogBox } from 'react-native'; -import { TranslationContext } from '../boot/TranslationProvider'; import type { RouteProp } from '../react-navigation'; import type { AppNavigationProp } from '../nav/AppNavigator'; -import * as NavigationService from '../nav/NavigationService'; -import * as api from '../api'; import Screen from '../common/Screen'; import EmojiRow from './EmojiRow'; -import { getFilteredEmojis, reactionTypeFromEmojiType } from './data'; +import { getFilteredEmojis } from './data'; +import { getActiveImageEmojiByName } from '../selectors'; +import type { EmojiType } from '../types'; import { useSelector } from '../react-redux'; -import { getAuth, getActiveImageEmojiByName } from '../selectors'; -import { navigateBack } from '../nav/navActions'; -import * as logging from '../utils/logging'; -import { showToast } from '../utils/info'; type Props = $ReadOnly<{| navigation: AppNavigationProp<'emoji-picker'>, - route: RouteProp<'emoji-picker', {| messageId: number |}>, + route: RouteProp< + 'emoji-picker', + {| + // This param is a function, so React Nav is right to point out that + // it isn't serializable. But this is fine as long as we don't try to + // persist navigation state for this screen or set up deep linking to + // it, hence the LogBox suppression below. + // + // React Navigation doesn't offer a more sensible way to do have us + // pass the emoji data to the calling screen. …We could store the + // emoji data as a route param on the calling screen, or in Redux. But + // from this screen's perspective, that's basically just setting a + // global variable. Better to offer this explicit, side-effect-free + // way for the data to flow where it should, when it should. + onPressEmoji: ({| +type: EmojiType, +code: string, +name: string |}) => void, + |}, + >, |}>; -export default function EmojiPickerScreen(props: Props): Node { - const { route } = props; - const { messageId } = route.params; +// React Navigation would give us a console warning about non-serializable +// route params. For more about the warning, see +// https://reactnavigation.org/docs/5.x/troubleshooting/#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state +// See comment on this param, above. +LogBox.ignoreLogs([/emoji-picker > params\.onPressEmoji \(Function\)/]); - const _ = useContext(TranslationContext); +export default function EmojiPickerScreen(props: Props): Node { + const { navigation, route } = props; + const { onPressEmoji } = route.params; const activeImageEmojiByName = useSelector(getActiveImageEmojiByName); - const auth = useSelector(getAuth); const [filter, setFilter] = useState(''); @@ -38,17 +52,12 @@ export default function EmojiPickerScreen(props: Props): Node { setFilter(text.toLowerCase()); }, []); - const addReaction = useCallback( - ({ type, code, name }) => { - api - .emojiReactionAdd(auth, messageId, reactionTypeFromEmojiType(type, name), code, name) - .catch(err => { - logging.error('Error adding reaction emoji', err); - showToast(_('Failed to add reaction')); - }); - NavigationService.dispatch(navigateBack()); + const handlePressEmoji = useCallback( + (...args) => { + onPressEmoji(...args); + navigation.goBack(); }, - [auth, messageId, _], + [onPressEmoji, navigation], ); const emojiNames = getFilteredEmojis(filter, activeImageEmojiByName); @@ -65,7 +74,7 @@ export default function EmojiPickerScreen(props: Props): Node { type={item.emoji_type} code={item.code} name={item.name} - onPress={addReaction} + onPress={handlePressEmoji} /> )} /> diff --git a/src/nav/navActions.js b/src/nav/navActions.js index 6d7135dff51..ebfcf2ac94e 100644 --- a/src/nav/navActions.js +++ b/src/nav/navActions.js @@ -7,7 +7,7 @@ import { } from '@react-navigation/native'; import * as NavigationService from './NavigationService'; -import type { Message, Narrow, UserId } from '../types'; +import type { Message, Narrow, UserId, EmojiType } from '../types'; import type { PmKeyRecipients } from '../utils/recipient'; import type { SharedData } from '../sharing/types'; import type { ApiResponseServerSettings } from '../api/settings/getServerSettings'; @@ -52,8 +52,9 @@ export const navigateToUsersScreen = (): GenericNavigationAction => StackActions export const navigateToSearch = (): GenericNavigationAction => StackActions.push('search-messages'); -export const navigateToEmojiPicker = (messageId: number): GenericNavigationAction => - StackActions.push('emoji-picker', { messageId }); +export const navigateToEmojiPicker = ( + onPressEmoji: ({| +type: EmojiType, +code: string, +name: string |}) => void, +): GenericNavigationAction => StackActions.push('emoji-picker', { onPressEmoji }); export const navigateToAuth = ( serverSettings: ApiResponseServerSettings, diff --git a/src/user-statuses/EmojiInput.js b/src/user-statuses/EmojiInput.js new file mode 100644 index 00000000000..f08ef4d4a46 --- /dev/null +++ b/src/user-statuses/EmojiInput.js @@ -0,0 +1,89 @@ +/* @flow strict-local */ +import React, { useContext, useCallback, useMemo } from 'react'; +import type { Node } from 'react'; +import { Platform } from 'react-native'; +import type { AppNavigationProp } from '../nav/AppNavigator'; + +import { ThemeContext } from '../styles/theme'; +import Touchable from '../common/Touchable'; +import Emoji from '../emoji/Emoji'; +import { Icon } from '../common/Icons'; +import type { EmojiType } from '../types'; +import { createStyleSheet, BORDER_COLOR } from '../styles'; + +export type Value = null | {| +type: EmojiType, +code: string, +name: string |}; + +export type Props = $ReadOnly<{| + value: Value, + onChangeValue: Value => void, + + /** + * Component must be under the stack nav that has the emoji-picker screen + * + * Pass this down from props or `useNavigation`. + */ + navigation: AppNavigationProp<>, + + /** + * Give appropriate right margin + */ + rightMargin?: true, +|}>; + +/** + * A controlled input component to let the user choose an emoji. + * + * When pressed, opens the emoji-picker screen, and populates with the emoji + * chosen by the user, if any. + * + * Designed for harmony with our Input component. If changing the appearance + * of this or that component, we should try to keep that harmony. + */ +export default function EmojiInput(props: Props): Node { + const { value, onChangeValue, navigation, rightMargin } = props; + + const { color } = useContext(ThemeContext); + + const handlePress = useCallback(() => { + navigation.push('emoji-picker', { onPressEmoji: onChangeValue }); + }, [navigation, onChangeValue]); + + const styles = useMemo( + () => + createStyleSheet({ + touchable: { + // Min touch-target size + minWidth: 48, + minHeight: 48, + + alignItems: 'center', + justifyContent: 'center', + + marginRight: rightMargin ? 4 : undefined, + + // For harmony with the `Input` component, which differs between + // platforms because of platform conventions. Border on iOS, no + // border on Android. + ...(Platform.OS === 'ios' + ? { + borderWidth: 1, + borderColor: BORDER_COLOR, + borderRadius: 2, + padding: 8, + } + : Object.freeze({})), + }, + }), + [rightMargin], + ); + + return ( + + {value ? ( + + ) : ( + + )} + + ); +} diff --git a/src/user-statuses/UserStatusScreen.js b/src/user-statuses/UserStatusScreen.js index 4e00189df40..c471432c078 100644 --- a/src/user-statuses/UserStatusScreen.js +++ b/src/user-statuses/UserStatusScreen.js @@ -1,32 +1,70 @@ /* @flow strict-local */ +// $FlowFixMe[untyped-import] +import isEqual from 'lodash.isequal'; import React, { useState, useContext, useCallback } from 'react'; import type { Node } from 'react'; -import { FlatList, View } from 'react-native'; -import { TranslationContext } from '../boot/TranslationProvider'; -import { createStyleSheet } from '../styles'; +import { FlatList, View, Pressable } from 'react-native'; +import { TranslationContext } from '../boot/TranslationProvider'; +import { createStyleSheet, BRAND_COLOR, HIGHLIGHT_COLOR } from '../styles'; import type { RouteProp } from '../react-navigation'; import type { AppNavigationProp } from '../nav/AppNavigator'; import { useSelector } from '../react-redux'; import Input from '../common/Input'; +import EmojiInput from './EmojiInput'; +import type { Value as EmojiInputValue } from './EmojiInput'; +import { unicodeCodeByName } from '../emoji/codePointMap'; +import { + emojiTypeFromReactionType, + reactionTypeFromEmojiType, + parseUnicodeEmojiCode, +} from '../emoji/data'; import SelectableOptionRow from '../common/SelectableOptionRow'; import Screen from '../common/Screen'; import ZulipButton from '../common/ZulipButton'; -import { getAuth, getOwnUserId } from '../selectors'; +import { getZulipFeatureLevel, getAuth, getOwnUserId } from '../selectors'; import { getUserStatus } from './userStatusesModel'; -import { IconCancel, IconDone } from '../common/Icons'; -import statusSuggestions from './userStatusTextSuggestions'; +import type { UserStatus } from '../api/modelTypes'; +import { Icon } from '../common/Icons'; import * as api from '../api'; +type StatusSuggestion = [ + $ReadOnly<{| emoji_name: string, emoji_code: string, reaction_type: 'unicode_emoji' |}>, + string, +]; + +const statusSuggestions: $ReadOnlyArray = [ + ['working_on_it', 'Busy'], + ['calendar', 'In a meeting'], + ['bus', 'Commuting'], + ['sick', 'Out sick'], + ['palm_tree', 'Vacationing'], + ['house', 'Working remotely'], +].map(([emoji_name, status_text]) => [ + { emoji_name, emoji_code: unicodeCodeByName[emoji_name], reaction_type: 'unicode_emoji' }, + status_text, +]); + const styles = createStyleSheet({ - statusTextInput: { + inputRow: { + flexDirection: 'row', margin: 16, }, - buttonsWrapper: { - flexDirection: 'row', + statusTextInput: { + flex: 1, + }, + clearButton: { + // Min touch-target size + minWidth: 48, + minHeight: 48, + + alignItems: 'center', + justifyContent: 'center', + + // To match margin between the emoji and text inputs + marginLeft: 4, }, button: { - flex: 1, margin: 8, }, }); @@ -36,74 +74,137 @@ type Props = $ReadOnly<{| route: RouteProp<'user-status', void>, |}>; +const statusTextFromInputValue = (v: string): $PropertyType => + v.trim() || null; + +const inputValueFromStatusText = (t: $PropertyType): string => t ?? ''; + +const statusEmojiFromInputValue = (v: EmojiInputValue): $PropertyType => + v + ? { + emoji_name: v.name, + emoji_code: v.code, + reaction_type: reactionTypeFromEmojiType(v.type, v.name), + } + : null; + +const inputValueFromStatusEmoji = (e: $PropertyType): EmojiInputValue => + e + ? { + type: emojiTypeFromReactionType(e.reaction_type), + code: e.emoji_code, + name: e.emoji_name, + } + : null; + export default function UserStatusScreen(props: Props): Node { const { navigation } = props; + // TODO(server-5.0): Cut conditionals on emoji-status support (emoji + // supported as of FL 86: https://zulip.com/api/changelog ) + const serverSupportsEmojiStatus = useSelector(getZulipFeatureLevel) >= 86; + + const _ = useContext(TranslationContext); const auth = useSelector(getAuth); const ownUserId = useSelector(getOwnUserId); const userStatusText = useSelector(state => getUserStatus(state, ownUserId).status_text); + const userStatusEmoji = useSelector(state => getUserStatus(state, ownUserId).status_emoji); - const [statusText, setStatusText] = useState(userStatusText); - const _ = useContext(TranslationContext); + const [textInputValue, setTextInputValue] = useState( + inputValueFromStatusText(userStatusText), + ); + const [emojiInputValue, setEmojiInputValue] = useState( + inputValueFromStatusEmoji(userStatusEmoji), + ); const sendToServer = useCallback( partialUserStatus => { - api.updateUserStatus(auth, partialUserStatus); + const copy = { ...partialUserStatus }; + // TODO: Put conditional inside `api.updateUserStatus` itself; see + // https://github.com/zulip/zulip-mobile/issues/4659#issuecomment-914996061 + if (!serverSupportsEmojiStatus) { + delete copy.status_emoji; + } + api.updateUserStatus(auth, copy); navigation.goBack(); }, - [navigation, auth], + [serverSupportsEmojiStatus, navigation, auth], ); - const handlePressUpdate = useCallback(() => { - sendToServer({ status_text: statusText }); - }, [statusText, sendToServer]); + const handlePressSave = useCallback(() => { + sendToServer({ + status_text: statusTextFromInputValue(textInputValue), + status_emoji: statusEmojiFromInputValue(emojiInputValue), + }); + }, [textInputValue, emojiInputValue, sendToServer]); const handlePressClear = useCallback(() => { - setStatusText(null); - sendToServer({ status_text: null }); - }, [sendToServer]); + setTextInputValue(inputValueFromStatusText(null)); + setEmojiInputValue(inputValueFromStatusEmoji(null)); + }, []); return ( - - item} - renderItem={({ item, index }) => ( - { - setStatusText(_(itemKey)); - }} + + {serverSupportsEmojiStatus && ( + )} - /> - - - + {(emojiInputValue !== null || textInputValue.length > 0) && ( + + {({ pressed }) => ( + + )} + + )} + index.toString() /* list is constant; index OK */} + renderItem={({ item: [emoji, text], index }) => { + const translatedText = _(text); + return ( + { + setTextInputValue(translatedText); + setEmojiInputValue(inputValueFromStatusEmoji(emoji)); + }} + /> + ); + }} + /> + ); } diff --git a/src/user-statuses/userStatusTextSuggestions.js b/src/user-statuses/userStatusTextSuggestions.js deleted file mode 100644 index d0137f52511..00000000000 --- a/src/user-statuses/userStatusTextSuggestions.js +++ /dev/null @@ -1,11 +0,0 @@ -/* @flow strict-local */ - -// This is a separate file due to Prettier having issues with formatting unicode -// Using `prettier-ignore` does not help. -export default [ - '📅 In a meeting', - '🚌 Commuting', - '🤒 Out sick', - '🌴 Vacationing', - '🏠 Working remotely', -]; diff --git a/static/translations/messages_en.json b/static/translations/messages_en.json index 40109d23206..01b750f5807 100644 --- a/static/translations/messages_en.json +++ b/static/translations/messages_en.json @@ -274,11 +274,12 @@ "Couldn’t load information about {fullName}": "Couldn’t load information about {fullName}", "What’s your status?": "What’s your status?", "Click to join video call": "Click to join video call", - "📅 In a meeting": "📅 In a meeting", - "🚌 Commuting": "🚌 Commuting", - "🤒 Out sick": "🤒 Out sick", - "🌴 Vacationing": "🌴 Vacationing", - "🏠 Working remotely": "🏠 Working remotely", + "Busy": "Busy", + "In a meeting": "In a meeting", + "Commuting": "Commuting", + "Out sick": "Out sick", + "Vacationing": "Vacationing", + "Working remotely": "Working remotely", "This message was hidden because it is from a user you have muted. Long-press to view.": "This message was hidden because it is from a user you have muted. Long-press to view.", "Signed out": "Signed out", "Remove account?": "Remove account?",