Skip to content
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

user-status: Add UI for setting emoji status #5277

Merged
merged 20 commits into from
Mar 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e6e8f21
UserStatusScreen [nfc]: Make a conditional more transparent
chrisbobbe Mar 3, 2022
8e516b6
UserStatusScreen [nfc]: Consistently represent "unset" in statusText …
chrisbobbe Mar 4, 2022
d99c077
UserStatusScreen [nfc]: Make statusText state a string
chrisbobbe Mar 3, 2022
2ce27e5
UserStatusScreen [nfc]: Rename input-value state
chrisbobbe Mar 3, 2022
32a127d
UserStatusScreen [nfc]: Pull out status <-> input-value converters
chrisbobbe Mar 3, 2022
4304dea
UserStatusScreen: Trim entered text status before interpreting it
chrisbobbe Mar 4, 2022
f25b469
EmojiPickerScreen: Use plain-old navigation.goBack()
chrisbobbe Mar 3, 2022
7a8208b
EmojiPickerScreen: Put callback in route params
chrisbobbe Mar 3, 2022
96901e3
Emoji [nfc]: Allow setting size
chrisbobbe Mar 3, 2022
bbba246
UserStatusScreen [nfc]: Add "input row" View, to soon include emoji i…
chrisbobbe Mar 3, 2022
cf1a32c
UserStatusScreen [nfc]: Move `useContext` upward, with selectors
chrisbobbe Mar 7, 2022
261341c
UserStatusScreen: Translate status-text suggestions
chrisbobbe Mar 7, 2022
c7013e3
AccountDetails: Show status emoji, if set
chrisbobbe Mar 8, 2022
254b00f
UserStatusScreen: Add and use EmojiInput, for status emoji
chrisbobbe Mar 2, 2022
a0643fd
UserStatusScreen: Move and re-style "clear" button
chrisbobbe Mar 3, 2022
5b0e2f8
UserStatusScreen: Have clear button only clear the inputs, not save too
chrisbobbe Mar 3, 2022
5100d57
UserStatusScreen: Rename "Update" button to "Save"; remove checkmark …
chrisbobbe Mar 3, 2022
977d59f
UserStatusScreen: Disable "Save" button when status unchanged
chrisbobbe Mar 4, 2022
ed6e6e1
UserStatusScreen: Make status suggestions use new emoji-status feature!
chrisbobbe Mar 4, 2022
6d62335
UserStatusScreen: Add "Busy" option
chrisbobbe Mar 5, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/account-info/AccountDetails.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +23,7 @@ const componentStyles = createStyleSheet({
},
statusWrapper: {
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
},
presenceStatusIndicator: {
Expand All @@ -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;

Expand Down Expand Up @@ -70,9 +76,22 @@ export default function AccountDetails(props: Props): Node {
/>
<ZulipText style={[styles.largerText, styles.halfMarginRight]} text={user.full_name} />
</View>
{userStatusText !== null && (
<ZulipText style={[styles.largerText, componentStyles.statusText]} text={userStatusText} />
)}
<View style={componentStyles.statusWrapper}>
{userStatusEmoji && (
<Emoji
code={userStatusEmoji.emoji_code}
type={emojiTypeFromReactionType(userStatusEmoji.reaction_type)}
size={24}
/>
)}
{userStatusEmoji && userStatusText !== null && <View style={{ width: 2 }} />}
{userStatusText !== null && (
<ZulipText
style={[styles.largerText, componentStyles.statusText]}
text={userStatusText}
/>
)}
</View>
{!isSelf && (
<View>
<ActivityText style={styles.largerText} user={user} />
Expand Down
14 changes: 12 additions & 2 deletions src/action-sheets/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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'));
});
}),
);
},
};

Expand Down
15 changes: 8 additions & 7 deletions src/emoji/Emoji.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 <Image style={componentStyles.image} source={{ uri: imageEmoji.source_url }} />;
}
return <UnicodeEmoji name={code} size={20} />;
return <UnicodeEmoji name={code} size={size} />;
}
63 changes: 36 additions & 27 deletions src/emoji/EmojiPickerScreen.js
Original file line number Diff line number Diff line change
@@ -1,54 +1,63 @@
/* @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<string>('');

const handleInputChange = useCallback((text: string) => {
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);
Expand All @@ -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}
/>
)}
/>
Expand Down
7 changes: 4 additions & 3 deletions src/nav/navActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
89 changes: 89 additions & 0 deletions src/user-statuses/EmojiInput.js
Original file line number Diff line number Diff line change
@@ -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({})),
Comment on lines +67 to +74
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm interesting. Why are the border and padding only on iOS?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah right, this needs a comment (and should possibly change). It's meant to mimic Input, which has

const componentStyles = createStyleSheet({
  input: {
    ...Platform.select({
      ios: {
        borderWidth: 1,
        borderColor: BORDER_COLOR,
        borderRadius: 2,
        padding: 8,
      },
    }),
  },
});

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, got it. IIRC that conditional is there in order to conform to platform conventions. This UI might call for something else, though.

},
}),
[rightMargin],
);

return (
<Touchable style={styles.touchable} onPress={handlePress}>
{value ? (
<Emoji code={value.code} type={value.type} size={24} />
) : (
<Icon color={color} size={24} name="smile" />
)}
</Touchable>
);
}
Loading