diff --git a/src/autocomplete/PeopleAutocomplete.js b/src/autocomplete/PeopleAutocomplete.js index a4a9acac97b..ae9b561a2d2 100644 --- a/src/autocomplete/PeopleAutocomplete.js +++ b/src/autocomplete/PeopleAutocomplete.js @@ -21,6 +21,7 @@ import WildcardMentionItem, { } from './WildcardMentionItem'; import { TranslationContext } from '../boot/TranslationProvider'; import { getZulipFeatureLevel } from '../account/accountsSelectors'; +import { streamChannelRenameFeatureLevel } from '../boot/streamChannelRenamesMap'; type Props = $ReadOnly<{| filter: string, @@ -74,6 +75,7 @@ export default function PeopleAutocomplete(props: Props): Node { destinationNarrow, // TODO(server-8.0) zulipFeatureLevel >= 224, + zulipFeatureLevel >= streamChannelRenameFeatureLevel, _, ); const filteredUsers = getAutocompleteSuggestion(users, filter, ownUserId, mutedUsers); diff --git a/src/autocomplete/WildcardMentionItem.js b/src/autocomplete/WildcardMentionItem.js index 7a1c78d2fc8..3cca6aa336a 100644 --- a/src/autocomplete/WildcardMentionItem.js +++ b/src/autocomplete/WildcardMentionItem.js @@ -11,6 +11,9 @@ import Touchable from '../common/Touchable'; import { createStyleSheet, ThemeContext } from '../styles'; import { caseNarrowDefault, isStreamOrTopicNarrow } from '../utils/narrow'; import { TranslationContext } from '../boot/TranslationProvider'; +import { useSelector } from '../react-redux'; +import { getZulipFeatureLevel } from '../account/accountsSelectors'; +import { streamChannelRenameFeatureLevel } from '../boot/streamChannelRenamesMap'; /** * A type of wildcard mention recognized by the server. @@ -38,14 +41,18 @@ export enum WildcardMentionType { // All of these should appear in messages_en.json so we can make the // wildcard mentions discoverable in the people autocomplete in the client's // own language. See getWildcardMentionsForQuery. -const englishCanonicalStringOf = (type: WildcardMentionType): string => { +const englishCanonicalStringOf = ( + type: WildcardMentionType, + useChannelTerminology: boolean, +): string => { switch (type) { case WildcardMentionType.All: return 'all'; case WildcardMentionType.Everyone: return 'everyone'; case WildcardMentionType.Stream: - return 'stream'; + // TODO(server-9.0) remove "stream" terminology + return useChannelTerminology ? 'channel' : 'stream'; case WildcardMentionType.Topic: return 'topic'; } @@ -86,11 +93,20 @@ export const getWildcardMentionsForQuery = ( query: string, destinationNarrow: Narrow, topicMentionSupported: boolean, + useChannelTerminology: boolean, _: GetText, ): $ReadOnlyArray => { const queryMatchesWildcard = (type: WildcardMentionType): boolean => - typeahead.query_matches_string(query, serverCanonicalStringOf(type), ' ') - || typeahead.query_matches_string(query, _(englishCanonicalStringOf(type)), ' '); + typeahead.query_matches_string( + query, + serverCanonicalStringOf(type, useChannelTerminology), + ' ', + ) + || typeahead.query_matches_string( + query, + _(englishCanonicalStringOf(type, useChannelTerminology)), + ' ', + ); const results = []; @@ -135,9 +151,12 @@ export default function WildcardMentionItem(props: Props): Node { const _ = useContext(TranslationContext); + const zulipFeatureLevel = useSelector(getZulipFeatureLevel); + const useChannelTerminology = zulipFeatureLevel >= streamChannelRenameFeatureLevel; + const handlePress = useCallback(() => { - onPress(type, serverCanonicalStringOf(type)); - }, [onPress, type]); + onPress(type, serverCanonicalStringOf(type, useChannelTerminology)); + }, [onPress, type, useChannelTerminology]); const themeContext = useContext(ThemeContext); @@ -179,7 +198,7 @@ export default function WildcardMentionItem(props: Props): Node { diff --git a/src/boot/TranslationProvider.js b/src/boot/TranslationProvider.js index 7c43c3ad68b..7f490566ae0 100644 --- a/src/boot/TranslationProvider.js +++ b/src/boot/TranslationProvider.js @@ -8,6 +8,13 @@ import type { GetText } from '../types'; import { useGlobalSelector } from '../react-redux'; import { getGlobalSettings } from '../selectors'; import messagesByLanguage from '../i18n/messagesByLanguage'; +import { getZulipFeatureLevel, tryGetActiveAccountState } from '../account/accountsSelectors'; +import { objectFromEntries } from '../jsBackport'; +import { objectEntries } from '../flowPonyfill'; +import { + streamChannelRenameFeatureLevel, + streamChannelRenamesMap, +} from './streamChannelRenamesMap'; // $FlowFixMe[incompatible-type] could put a well-typed mock value here, to help write tests export const TranslationContext: React.Context = React.createContext(undefined); @@ -53,12 +60,59 @@ type Props = $ReadOnly<{| children: React.Node, |}>; +/** + * Like messagesByLanguage but with "channel" terminology instead of "stream". + */ +const messagesByLanguageRenamed = objectFromEntries( + objectEntries(messagesByLanguage).map(([language, messages]) => [ + language, + objectFromEntries( + objectEntries(messages).map(([messageId, message]) => { + const renamedMessageId = streamChannelRenamesMap[messageId]; + if (renamedMessageId == null) { + return [messageId, message]; + } + + const renamedMessage = messages[renamedMessageId]; + if (renamedMessage === renamedMessageId && message !== messageId) { + // The newfangled "channel" string hasn't been translated yet, but + // the older "stream" string has. Consider falling back to that. + if (/^en($|-)/.test(language)) { + // The language is a variety of English. Prefer the newer + // terminology, even though awaiting translation. (Most of our + // strings don't change at all between one English and another.) + return [messageId, renamedMessage]; + } + // Use the translation we have, even of the older terminology. + // (In many languages the translations have used an equivalent + // of "channel" all along anyway.) + return [messageId, message]; + } + return [messageId, renamedMessage]; + }), + ), + ]), +); + export default function TranslationProvider(props: Props): React.Node { const { children } = props; const language = useGlobalSelector(state => getGlobalSettings(state).language); + const activeAccountState = useGlobalSelector(tryGetActiveAccountState); + + // TODO(server-9.0) remove "stream" terminology + const effectiveMessagesByLanguage = + activeAccountState == null + || getZulipFeatureLevel(activeAccountState) > streamChannelRenameFeatureLevel + ? messagesByLanguageRenamed + : messagesByLanguage; + return ( - + {children} ); diff --git a/src/boot/streamChannelRenamesMap.js b/src/boot/streamChannelRenamesMap.js new file mode 100644 index 00000000000..cdc02231f45 --- /dev/null +++ b/src/boot/streamChannelRenamesMap.js @@ -0,0 +1,91 @@ +/* @flow strict-local */ + +/** + * The feature level at which we want to say "channel" instead of "stream". + * + * Outside a per-account context, check the feature level of the active + * account, if there is one. If there isn't an active account, just choose + * "channel" terminology unconditionally. + */ +// TODO(server-9.0) simplify away +// https://chat.zulip.org/api/changelog#changes-in-zulip-90 +export const streamChannelRenameFeatureLevel = 255; + +/** + * A messageId: messageId map, from "stream" terminology to "channel". + * + * When appropriate (see streamChannelRenameFeatureLevel), use this to patch + * UI-string data for all languages, so that the UI says "channel" instead + * of "stream". See https://github.com/zulip/zulip-mobile/issues/5827 . + * + * For example, use this to make a copy of messages_en that has + * + * "Notify stream": "Notify channel", + * + * instead of + * + * "Notify stream": "Notify stream", + * "Notify channel": "Notify channel", + * + * and likewise for all the other languages. + */ +// TODO(server-9.0) simplify away +export const streamChannelRenamesMap: {| [string]: string |} = { + stream: 'channel', + 'Notify stream': 'Notify channel', + 'Who can access the stream?': 'Who can access the channel?', + 'Only organization administrators and owners can edit streams.': + 'Only organization administrators and owners can edit channels.', + '{realmName} only allows organization administrators or owners to make public streams.': + '{realmName} only allows organization administrators or owners to make public channels.', + '{realmName} only allows organization moderators, administrators, or owners to make public streams.': + '{realmName} only allows organization moderators, administrators, or owners to make public channels.', + '{realmName} only allows full organization members, moderators, administrators, or owners to make public streams.': + '{realmName} only allows full organization members, moderators, administrators, or owners to make public channels.', + '{realmName} only allows organization members, moderators, administrators, or owners to make public streams.': + '{realmName} only allows organization members, moderators, administrators, or owners to make public channels.', + '{realmName} only allows organization administrators or owners to make private streams.': + '{realmName} only allows organization administrators or owners to make private channels.', + '{realmName} only allows organization moderators, administrators, or owners to make private streams.': + '{realmName} only allows organization moderators, administrators, or owners to make private channels.', + '{realmName} only allows full organization members, moderators, administrators, or owners to make private streams.': + '{realmName} only allows full organization members, moderators, administrators, or owners to make private channels.', + '{realmName} only allows organization members, moderators, administrators, or owners to make private streams.': + '{realmName} only allows organization members, moderators, administrators, or owners to make private channels.', + '{realmName} does not allow anybody to make web-public streams.': + '{realmName} does not allow anybody to make web-public channels.', + '{realmName} only allows organization owners to make web-public streams.': + '{realmName} only allows organization owners to make web-public channels.', + '{realmName} only allows organization administrators or owners to make web-public streams.': + '{realmName} only allows organization administrators or owners to make web-public channels.', + '{realmName} only allows organization moderators, administrators, or owners to make web-public streams.': + '{realmName} only allows organization moderators, administrators, or owners to make web-public channels.', + 'Cannot subscribe to stream': 'Cannot subscribe to channel', + 'Stream #{name} is private.': 'Channel #{name} is private.', + 'Please specify a stream.': 'Please specify a channel.', + 'Please specify a valid stream.': 'Please specify a valid channel.', + 'No messages in stream': 'No messages in channel', + 'All streams': 'All channels', + // 'No messages in topic: {streamAndTopic}': 'No messages in topic: {channelAndTopic}', + 'Mute stream': 'Mute channel', + 'Unmute stream': 'Unmute channel', + '{username} will not be notified unless you subscribe them to this stream.': + '{username} will not be notified unless you subscribe them to this channel.', + 'Stream notifications': 'Channel notifications', + 'No streams found': 'No channels found', + 'Mark stream as read': 'Mark channel as read', + 'Failed to mute stream': 'Failed to mute channel', + 'Failed to unmute stream': 'Failed to unmute channel', + 'Stream settings': 'Channel settings', + 'Failed to show stream settings': 'Failed to show channel settings', + 'You are not subscribed to this stream': 'You are not subscribed to this channel', + 'Create new stream': 'Create new channel', + Stream: 'Channel', + 'Edit stream': 'Edit channel', + 'Only organization admins are allowed to post to this stream.': + 'Only organization admins are allowed to post to this channel.', + 'Copy link to stream': 'Copy link to channel', + 'Failed to copy stream link': 'Failed to copy channel link', + 'A stream with this name already exists.': 'A channel with this name already exists.', + Streams: 'Channels', +}; diff --git a/tools/check-messages-en b/tools/check-messages-en index acf460a8000..fb539018d6a 100755 --- a/tools/check-messages-en +++ b/tools/check-messages-en @@ -194,12 +194,9 @@ function main() { // Check each key ("message ID" in formatjs's lingo) against // possibleUiStrings, and make a list of any that aren't found. - const danglingMessageIds = Object.keys(messages_en) - .filter(messageId => !possibleUiStrings.has(messageId)) - // Ignore some UI strings that we want to offer to our translators - // but that aren't yet ready to show in the UI (although we intend to). - // TODO(#5827) Stop filtering out messages with "channel" terminology - .filter(messageId => !/[cC]hannel/.test(messageId)); + const danglingMessageIds = Object.keys(messages_en).filter( + messageId => !possibleUiStrings.has(messageId), + ); if (danglingMessageIds.length > 0) { console.error(