|
| 1 | +/* @flow strict-local */ |
| 2 | +import React, { useCallback, useContext, useMemo } from 'react'; |
| 3 | +import type { Node } from 'react'; |
| 4 | +import { View } from 'react-native'; |
| 5 | +import * as typeahead from '@zulip/shared/js/typeahead'; |
| 6 | + |
| 7 | +import type { GetText, Narrow } from '../types'; |
| 8 | +import { IconWildcardMention } from '../common/Icons'; |
| 9 | +import ZulipText from '../common/ZulipText'; |
| 10 | +import Touchable from '../common/Touchable'; |
| 11 | +import { createStyleSheet, ThemeContext } from '../styles'; |
| 12 | +import { caseNarrowDefault, isStreamOrTopicNarrow } from '../utils/narrow'; |
| 13 | +import { TranslationContext } from '../boot/TranslationProvider'; |
| 14 | + |
| 15 | +/** |
| 16 | + * A type of wildcard mention recognized by the server. |
| 17 | + * |
| 18 | + * For the string the server knows this by, see serverCanonicalStringOf. |
| 19 | + * |
| 20 | + * See user doc on wildcard mentions: |
| 21 | + * https://zulip.com/help/pm-mention-alert-notifications#wildcard-mentions |
| 22 | + */ |
| 23 | +export enum WildcardMentionType { |
| 24 | + // These values are arbitrary. For the string the server knows these by, |
| 25 | + // see serverCanonicalStringOf. |
| 26 | + All = 0, |
| 27 | + Everyone = 1, |
| 28 | + Stream = 2, |
| 29 | +} |
| 30 | + |
| 31 | +/** |
| 32 | + * The canonical English string, like "everyone" or "all". |
| 33 | + * |
| 34 | + * See also serverCanonicalStringOf for the string the server knows this by. |
| 35 | + * (It might differ in a future where servers localize these.) |
| 36 | + */ |
| 37 | +// All of these should appear in messages_en.json so we can make the |
| 38 | +// wildcard mentions discoverable in the people autocomplete in the client's |
| 39 | +// own language. See getWildcardMentionForQuery. |
| 40 | +const englishCanonicalStringOf = (type: WildcardMentionType): string => { |
| 41 | + switch (type) { |
| 42 | + case WildcardMentionType.All: |
| 43 | + return 'all'; |
| 44 | + case WildcardMentionType.Everyone: |
| 45 | + return 'everyone'; |
| 46 | + case WildcardMentionType.Stream: |
| 47 | + return 'stream'; |
| 48 | + } |
| 49 | +}; |
| 50 | + |
| 51 | +/** |
| 52 | + * The string recognized by the server, like "everyone" or "all". |
| 53 | + * |
| 54 | + * Currently the same as englishCanonicalStringOf, but that should change if |
| 55 | + * servers start localizing these. |
| 56 | + */ |
| 57 | +const serverCanonicalStringOf = englishCanonicalStringOf; |
| 58 | + |
| 59 | +const descriptionOf = ( |
| 60 | + type: WildcardMentionType, |
| 61 | + destinationNarrow: Narrow, |
| 62 | + _: GetText, |
| 63 | +): string => { |
| 64 | + switch (type) { |
| 65 | + case WildcardMentionType.All: |
| 66 | + case WildcardMentionType.Everyone: |
| 67 | + return caseNarrowDefault( |
| 68 | + destinationNarrow, |
| 69 | + { topic: () => _('Notify stream'), pm: () => _('Notify recipients') }, |
| 70 | + // A "destination narrow" should really be a conversation narrow |
| 71 | + // (i.e., stream or topic), but including this default case is easy, |
| 72 | + // and better than an error/crash in case we've missed that somehow. |
| 73 | + () => _('Notify everyone'), |
| 74 | + ); |
| 75 | + case WildcardMentionType.Stream: |
| 76 | + return _('Notify stream'); |
| 77 | + } |
| 78 | +}; |
| 79 | + |
| 80 | +/** |
| 81 | + * The enum's members, as an array in order of preference, with "stream". |
| 82 | + * |
| 83 | + * When a query matches more than one of these, choose the first one. For a |
| 84 | + * similar array that doesn't include WildcardMentionType.Stream, see |
| 85 | + * kOrderedTypesWithoutStream. |
| 86 | + */ |
| 87 | +// Greg points out: |
| 88 | +// |
| 89 | +// > The help center mentions @-all, sometimes also @-everyone, and never |
| 90 | +// > @-stream that I can see: |
| 91 | +// > https://zulip.com/help/mention-a-user-or-group |
| 92 | +// > https://zulip.com/help/pm-mention-alert-notifications |
| 93 | +// > So I think the right order of preference is [all, everyone, stream]. |
| 94 | +const kOrderedTypesWithStream = [ |
| 95 | + WildcardMentionType.All, |
| 96 | + WildcardMentionType.Everyone, |
| 97 | + WildcardMentionType.Stream, |
| 98 | +]; |
| 99 | + |
| 100 | +/** |
| 101 | + * The enum's members, as an array in order of preference, without "stream". |
| 102 | + * |
| 103 | + * When a query matches more than one of these, choose the first one. For a |
| 104 | + * similar array that includes WildcardMentionType.Stream, see |
| 105 | + * kOrderedTypesWithStream. |
| 106 | + */ |
| 107 | +// See implementation note at kOrderedTypesWithStream. |
| 108 | +const kOrderedTypesWithoutStream = [WildcardMentionType.All, WildcardMentionType.Everyone]; |
| 109 | + |
| 110 | +// This assumes that we'll never want to show two suggestions for one query. |
| 111 | +// That's OK, as long as all of WildcardMentionType's members are synonyms, |
| 112 | +// and it's nice not to crowd the autocomplete with multiple items that mean |
| 113 | +// the same thing. But we'll need to adapt if it turns out that all the |
| 114 | +// members aren't synonyms. |
| 115 | +export const getWildcardMentionForQuery = ( |
| 116 | + query: string, |
| 117 | + destinationNarrow: Narrow, |
| 118 | + _: GetText, |
| 119 | +): WildcardMentionType | void => |
| 120 | + (isStreamOrTopicNarrow(destinationNarrow) |
| 121 | + ? kOrderedTypesWithStream |
| 122 | + : kOrderedTypesWithoutStream |
| 123 | + ).find( |
| 124 | + type => |
| 125 | + typeahead.query_matches_string(query, serverCanonicalStringOf(type), ' ') |
| 126 | + || typeahead.query_matches_string(query, _(englishCanonicalStringOf(type)), ' '), |
| 127 | + ); |
| 128 | + |
| 129 | +type Props = $ReadOnly<{| |
| 130 | + type: WildcardMentionType, |
| 131 | + destinationNarrow: Narrow, |
| 132 | + onPress: (type: WildcardMentionType, serverCanonicalString: string) => void, |
| 133 | +|}>; |
| 134 | + |
| 135 | +export default function WildcardMentionItem(props: Props): Node { |
| 136 | + const { type, destinationNarrow, onPress } = props; |
| 137 | + |
| 138 | + const _ = useContext(TranslationContext); |
| 139 | + |
| 140 | + const handlePress = useCallback(() => { |
| 141 | + onPress(type, serverCanonicalStringOf(type)); |
| 142 | + }, [onPress, type]); |
| 143 | + |
| 144 | + const themeContext = useContext(ThemeContext); |
| 145 | + |
| 146 | + const styles = useMemo( |
| 147 | + () => |
| 148 | + createStyleSheet({ |
| 149 | + wrapper: { |
| 150 | + flexDirection: 'row', |
| 151 | + alignItems: 'center', |
| 152 | + padding: 8, |
| 153 | + |
| 154 | + // Minimum touch target height: |
| 155 | + // https://material.io/design/usability/accessibility.html#layout-and-typography |
| 156 | + minHeight: 48, |
| 157 | + }, |
| 158 | + textWrapper: { |
| 159 | + flex: 1, |
| 160 | + marginLeft: 8, |
| 161 | + }, |
| 162 | + text: {}, |
| 163 | + descriptionText: { |
| 164 | + fontSize: 10, |
| 165 | + color: 'hsl(0, 0%, 60%)', |
| 166 | + }, |
| 167 | + }), |
| 168 | + [], |
| 169 | + ); |
| 170 | + |
| 171 | + return ( |
| 172 | + <Touchable onPress={handlePress}> |
| 173 | + <View style={styles.wrapper}> |
| 174 | + <IconWildcardMention |
| 175 | + // Match the size of the avatar in UserItem, which also appears in |
| 176 | + // the people autocomplete. We're counting on this icon being a |
| 177 | + // square. |
| 178 | + size={32} |
| 179 | + color={themeContext.color} |
| 180 | + /> |
| 181 | + <View style={styles.textWrapper}> |
| 182 | + <ZulipText |
| 183 | + style={styles.text} |
| 184 | + text={serverCanonicalStringOf(type)} |
| 185 | + numberOfLines={1} |
| 186 | + ellipsizeMode="tail" |
| 187 | + /> |
| 188 | + <ZulipText |
| 189 | + style={styles.descriptionText} |
| 190 | + text={descriptionOf(type, destinationNarrow, _)} |
| 191 | + numberOfLines={1} |
| 192 | + ellipsizeMode="tail" |
| 193 | + /> |
| 194 | + </View> |
| 195 | + </View> |
| 196 | + </Touchable> |
| 197 | + ); |
| 198 | +} |
0 commit comments