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