diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 57a682cfe6..e17dc92264 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -40,6 +40,9 @@ - Added `onReactionTap` to `StreamMessageItem` and `StreamMessageListView`, reporting the tapped message's `BuildContext` and a `ReactionTapDetails` with the tapped `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction). - Exported `StreamEphemeralMessage`, the row `StreamMessageListView` builds for ephemeral messages, matching its already-exported `StreamSystemMessage` and `StreamModeratedMessage` siblings. - Added an `unreadIndicator` parameter to `StreamBackButton` that overlays a widget (typically a `StreamUnreadIndicator`) on the button's top-end corner. Pass `StreamUnreadIndicator(excludeCid: cid)` to show the total unread count of other channels, or `StreamUnreadIndicator.channels(cid: cid)` for a single channel's count. +- Exported this package's `StreamMessageContent`, which was previously unreachable from `package:stream_chat_flutter/stream_chat_flutter.dart`. +- Added `StreamChatConfigurationData.messageTranslation`, a `StreamMessageTranslationConfiguration` with `enabled` (default `true`) to display translations from `Message.i18n` and `annotationEnabled` (default `false`) to opt into a "Translated"/"Original" annotation with a toggle link. +- Added `StreamMessageTranslationStore`, tracking the annotation's toggle state per `StreamMessageListView`. Also drivable via `showTranslatedText` on `StreamMessageHeaderProps`, `StreamMessageContent`, and `StreamMessageText`, and `onToggleTranslatedText` on `StreamMessageHeaderProps`. ⚠️ Deprecated @@ -50,6 +53,7 @@ 🐞 Fixed +- Fixed message text and previews translating to English for users with no `User.language` set; they now show the original text. - Fixed a failed send surfacing as an unhandled async error when `StreamMessageComposer` has no `onError`; it is now reported through `FlutterError.reportError`. - Fixed the default `StreamChannel` loading and error states not being themed or localized; `StreamChat` now installs themed, connection-aware defaults, overridable per `StreamChannel` or via `DefaultStreamChannelBuilders`. - Fixed the default list/scroll-view error states (channel, message, member, user, thread, poll-vote, reaction, search, and photo) showing raw or fixed errors; they are now connection-aware (no internet / slow connection), falling back to each view's specific error text. diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart index 6bfc539da3..447f2151c7 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart @@ -2,6 +2,11 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// A widget that renders a preview of the message text. +/// +/// The preview is translated into the current user's language when +/// [Message.i18n] has one, unless disabled SDK-wide via +/// [StreamMessageTranslationConfiguration.enabled] — matching the same +/// opt-out [StreamMessageText] respects for the full message bubble. class StreamMessagePreviewText extends StatelessWidget { /// Creates a new instance of [StreamMessagePreviewText]. const StreamMessagePreviewText({ @@ -20,6 +25,8 @@ class StreamMessagePreviewText extends StatelessWidget { final ChannelModel? channel; /// The language to use for translations. + /// + /// Defaults to the current user's [User.language]. final String? language; /// The style to use for the text. @@ -34,11 +41,12 @@ class StreamMessagePreviewText extends StatelessWidget { @override Widget build(BuildContext context) { final currentUser = StreamChat.maybeOf(context)?.currentUser; - final translationLanguage = language ?? currentUser?.language ?? 'en'; - final translatedMessage = message.translate(translationLanguage); + final config = StreamChatConfiguration.of(context); + final translationConfig = config.messageTranslation; + final translationLanguage = language ?? currentUser?.language; + final translatedMessage = translationConfig.enabled ? message.translate(translationLanguage) : message; final previewMessage = translatedMessage.replaceMentions(linkify: false); - final config = StreamChatConfiguration.of(context); final formatter = config.messagePreviewFormatter; final previewTextSpan = formatter.formatMessage( diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index d495e6564f..13a2193cd2 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -729,6 +729,32 @@ abstract class Translations { /// The text displaying the reminder time (e.g. "Today at 3:00 PM"). String reminderAtText(String time); + /// The annotation label shown on a message currently displayed as a + /// translation (e.g. "Translated"). + String get translatedLabel; + + /// The annotation label shown on a message currently displayed in its + /// original language, despite a translation being available (e.g. + /// "Original"). + String get originalLabel; + + /// The link label that reverts a translated message to its original text. + String get showOriginalLabel; + + /// The link label that reveals a translation for a message currently shown + /// in its original language. + String get showTranslationLabel; + + /// The annotation label shown on a message currently displayed as a + /// translation, when the message's original language is known. + /// + /// [languageCode] is the raw source-language code Stream Chat's + /// translation API reports via `Message.i18n['language']` — an ISO 639-1 + /// code, optionally with a regional suffix (e.g. `es-MX`). Implementations + /// resolve it to a localized display name, producing something like + /// "Translated from Spanish". + String translatedFromLanguageText(String languageCode); + /// The label for "Create a poll and let everyone vote!" String get createPollPromptLabel; @@ -1601,6 +1627,21 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String reminderAtText(String time) => 'Today at $time'; + @override + String get translatedLabel => 'Translated'; + + @override + String get originalLabel => 'Original'; + + @override + String get showOriginalLabel => 'Show original'; + + @override + String get showTranslationLabel => 'Show translation'; + + @override + String translatedFromLanguageText(String languageCode) => 'Translated from ${_languageName(languageCode)}'; + @override String get createPollPromptLabel => 'Create a poll and let everyone vote!'; @@ -1646,3 +1687,69 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String notifyRoleText(String role) => 'Notify all $role members'; } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`), for use in +// `DefaultTranslations.translatedFromLanguageText`. Falls back to the +// uppercased code itself for anything unrecognized. +String _languageName(String code) => _languageNames[code] ?? code.toUpperCase(); + +const _languageNames = { + 'af': 'Afrikaans', + 'sq': 'Albanian', + 'am': 'Amharic', + 'ar': 'Arabic', + 'az': 'Azerbaijani', + 'bn': 'Bengali', + 'bs': 'Bosnian', + 'bg': 'Bulgarian', + 'zh': 'Chinese (Simplified)', + 'zh-TW': 'Chinese (Traditional)', + 'hr': 'Croatian', + 'cs': 'Czech', + 'da': 'Danish', + 'fa-AF': 'Dari', + 'nl': 'Dutch', + 'en': 'English', + 'et': 'Estonian', + 'fi': 'Finnish', + 'fr': 'French', + 'fr-CA': 'French (Canada)', + 'ka': 'Georgian', + 'de': 'German', + 'el': 'Greek', + 'ht': 'Haitian Creole', + 'ha': 'Hausa', + 'he': 'Hebrew', + 'hi': 'Hindi', + 'hu': 'Hungarian', + 'id': 'Indonesian', + 'it': 'Italian', + 'ja': 'Japanese', + 'ko': 'Korean', + 'lv': 'Latvian', + 'lt': 'Lithuanian', + 'ms': 'Malay', + 'no': 'Norwegian', + 'fa': 'Persian', + 'ps': 'Pashto', + 'pl': 'Polish', + 'pt': 'Portuguese', + 'ro': 'Romanian', + 'ru': 'Russian', + 'sr': 'Serbian', + 'sk': 'Slovak', + 'sl': 'Slovenian', + 'so': 'Somali', + 'es': 'Spanish', + 'es-MX': 'Spanish (Mexico)', + 'sw': 'Swahili', + 'sv': 'Swedish', + 'tl': 'Tagalog', + 'ta': 'Tamil', + 'th': 'Thai', + 'tr': 'Turkish', + 'uk': 'Ukrainian', + 'ur': 'Urdu', + 'vi': 'Vietnamese', +}; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index e0ca7f074e..f4d0536506 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -312,6 +312,11 @@ class _StreamMessageListViewState extends State { late final ItemPositionsListener _itemPositionListener; StreamChannelState? streamChannel; + // Owned per list instance, so a channel view and an open thread (a + // separate `StreamMessageListView`) each get independent translation + // toggle state. See [StreamMessageTranslationStore]. + final _translationStore = StreamMessageTranslationStore(); + // Drives the unread-messages separator. Held in a [ValueNotifier] so read // events can update it without rebuilding the entire list view. final _unreadState = ValueNotifier<({int count, String? firstUnreadId})>((count: 0, firstUnreadId: null)); @@ -479,6 +484,7 @@ class _StreamMessageListViewState extends State { debouncedMarkThreadRead.cancel(); _unreadState.dispose(); _highlightState.dispose(); + _translationStore.dispose(); super.dispose(); } @@ -616,21 +622,24 @@ class _StreamMessageListViewState extends State { // TODO: Revisit this nested Portal setup during desktop reactions refactor // and remove the extra layer if a dedicated message-list portal label is // no longer required. - return Portal( - labels: const [kPortalMessageListViewLabel], + return StreamMessageTranslations( + store: _translationStore, child: Portal( - child: ScaffoldMessenger( - child: MessageListCore( - paginationLimit: _config.paginationLimit, - maximumMessageLimit: _config.maximumMessageLimit, - retentionTrimBuffer: _config.retentionTrimBuffer, - messageFilter: widget.messageFilter, - loadingBuilder: defaultLoadingBuilder, - emptyBuilder: defaultEmptyBuilder, - messageListBuilder: defaultMessageListBuilder, - messageListController: _messageListController, - parentMessage: widget.parentMessage, - errorBuilder: defaultErrorBuilder, + labels: const [kPortalMessageListViewLabel], + child: Portal( + child: ScaffoldMessenger( + child: MessageListCore( + paginationLimit: _config.paginationLimit, + maximumMessageLimit: _config.maximumMessageLimit, + retentionTrimBuffer: _config.retentionTrimBuffer, + messageFilter: widget.messageFilter, + loadingBuilder: defaultLoadingBuilder, + emptyBuilder: defaultEmptyBuilder, + messageListBuilder: defaultMessageListBuilder, + messageListController: _messageListController, + parentMessage: widget.parentMessage, + errorBuilder: defaultErrorBuilder, + ), ), ), ), @@ -1246,7 +1255,12 @@ class _StreamMessageListViewState extends State { child: Builder( builder: (context) => switch (widget.messageBuilder) { final builder? => builder.call(context, message, messageItemProps), - _ => StreamMessageItem.fromProps(props: messageItemProps), + // Keyed by message id so per-message local state (e.g. in the + // content/attachments/leading widgets) stays attached to the + // correct message when pagination prepends older messages and + // shifts every already-rendered item's index, rather than leaking + // onto whatever message next occupies the same position. + _ => StreamMessageItem.fromProps(key: ValueKey(message.id), props: messageItemProps), }, ), ); diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart index 04909f5620..7f5cce5175 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/chat.dart' as core; /// Determines at which point in the [MessageListView] the initial index should /// be. @@ -161,7 +162,7 @@ StreamMessageContentKind resolveContentKind(Message message) { } if (!hasQuote && attachmentCount == 0) { - final emojiCount = StreamMessageText.emojiCount(message.text); + final emojiCount = core.StreamMessageText.emojiCount(message.text); if (emojiCount != null && emojiCount <= 3) return .jumbomoji; } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart index b397debf9e..3549d521c8 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart @@ -44,6 +44,7 @@ class StreamMessageContent extends StatefulWidget { this.onReactionLongPress, this.onQuotedMessageTap, this.reactionSorting, + this.showTranslatedText = true, }); /// The message to display. @@ -128,6 +129,12 @@ class StreamMessageContent extends StatefulWidget { /// Passed through to [StreamMessageReactions.sorting]. final Comparator? reactionSorting; + /// Whether [message] should display its translation when [Message.i18n] + /// has one for the current user's language. + /// + /// Passed through to [StreamMessageText.showTranslatedText]. + final bool showTranslatedText; + @override State createState() => _StreamMessageContentState(); } @@ -207,6 +214,7 @@ class _StreamMessageContentState extends State { onLinkTap: widget.onLinkTap, onMentionTap: widget.onMentionTap, onAnyMentionTap: widget.onAnyMentionTap, + showTranslatedText: widget.showTranslatedText, ), ], ), diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart index 120753b817..4c5cd6650c 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_core_flutter/chat.dart' as core; @@ -24,9 +26,13 @@ class StreamMessageHeader extends core.NullableStatelessWidget { super.key, required Message message, VoidCallback? onViewChannelTap, + bool showTranslatedText = true, + VoidCallback? onToggleTranslatedText, }) : props = .new( message: message, onViewChannelTap: onViewChannelTap, + showTranslatedText: showTranslatedText, + onToggleTranslatedText: onToggleTranslatedText, ); /// Creates a message header from pre-built [props]. @@ -54,6 +60,8 @@ class StreamMessageHeaderProps { const StreamMessageHeaderProps({ required this.message, this.onViewChannelTap, + this.showTranslatedText = true, + this.onToggleTranslatedText, }); /// The message whose annotations to display. @@ -62,22 +70,41 @@ class StreamMessageHeaderProps { /// Called when the "View" link in the show-in-channel annotation is tapped. final VoidCallback? onViewChannelTap; + /// Whether [message] is currently displaying its translation from + /// [Message.i18n] rather than its original text. + /// + /// Only relevant when a translation exists for the current user's + /// language; ignored otherwise. Toggled via [onToggleTranslatedText]. + final bool showTranslatedText; + + /// Called when the "Show original"/"Show translation" link in the + /// translation annotation is tapped. + /// + /// If null, the link is still rendered when a translation is available, + /// but tapping it has no effect. + final VoidCallback? onToggleTranslatedText; + /// Returns a copy of this [StreamMessageHeaderProps] with the given fields /// replaced with new values. StreamMessageHeaderProps copyWith({ Message? message, VoidCallback? onViewChannelTap, + bool? showTranslatedText, + VoidCallback? onToggleTranslatedText, }) { return StreamMessageHeaderProps( message: message ?? this.message, onViewChannelTap: onViewChannelTap ?? this.onViewChannelTap, + showTranslatedText: showTranslatedText ?? this.showTranslatedText, + onToggleTranslatedText: onToggleTranslatedText ?? this.onToggleTranslatedText, ); } } /// The default implementation of [StreamMessageHeader]. /// -/// Annotations are shown in the following order when applicable: +/// Annotations are shown in the following order when applicable, per the +/// design system's mixed-annotation priority: /// /// 1. **Saved for later** — when a reminder exists without a scheduled time. /// 2. **Pinned** — when [Message.pinned] is true, showing who pinned it. @@ -86,18 +113,56 @@ class StreamMessageHeaderProps { /// channel or thread view, and includes a tappable "View" link that /// invokes [StreamMessageHeaderProps.onViewChannelTap]. /// 4. **Reminder** — when a reminder exists with a scheduled time. +/// 5. **Translated** — when [Message.i18n] has a translation for the +/// current user's language and +/// [StreamMessageTranslationConfiguration.annotationEnabled] is set. +/// Reads "Translated from {language}" when the original language is +/// known, otherwise plain "Translated". Includes a "Show original"/"Show +/// translation" link that invokes +/// [StreamMessageHeaderProps.onToggleTranslatedText]. /// /// Returns `null` when no annotations apply, allowing the parent layout to /// collapse the slot and skip spacing automatically. -class DefaultStreamMessageHeader extends core.NullableStatelessWidget { +class DefaultStreamMessageHeader extends core.NullableStatefulWidget { /// Creates a default message header with the given [props]. const DefaultStreamMessageHeader({super.key, required this.props}); /// The properties that configure this widget. final StreamMessageHeaderProps props; + @override + core.NullableState createState() => _DefaultStreamMessageHeaderState(); +} + +class _DefaultStreamMessageHeaderState extends core.NullableState { + late String? _language; + StreamSubscription? _languageSubscription; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + // `StreamChat.of` registers an inherited-widget dependency, so this (and + // the `currentUserStream` subscription it seeds) must happen here rather + // than in `initState`. + final streamChat = StreamChat.of(context); + _language = streamChat.currentUser?.language; + final languageStream = streamChat.currentUserStream.map((it) => it?.language); + _languageSubscription ??= languageStream.listen((language) { + if (language == _language) return; + setState(() => _language = language); + }); + } + + @override + void dispose() { + _languageSubscription?.cancel(); + super.dispose(); + } + @override Widget? nullableBuild(BuildContext context) { + final props = widget.props; final message = props.message; final translations = context.translations; final icons = context.streamIcons; @@ -156,11 +221,48 @@ class DefaultStreamMessageHeader extends core.NullableStatelessWidget { ); } + Widget? translatedAnnotation; + final translationConfig = StreamChatConfiguration.of(context).messageTranslation; + // Opt-in: without an annotation, translated messages are still shown + // translated, just silently — the SDK's long-standing behaviour. + if (translationConfig.enabled && translationConfig.annotationEnabled) { + // Stream's API reports an unset `User.language` as `''` rather than + // omitting it, so both cases mean "nothing to translate to". + if (_language case final language? when language.isNotEmpty) { + // The server includes a self-referential entry for the message's own + // source language (e.g. `es_text` equal to `message.text` on a + // Spanish message), so a plain null-check isn't enough — a user + // whose language matches the source would otherwise see the + // annotation with nothing to actually toggle. + if (message.i18n?['${language}_text'] case final translatedText? when translatedText != message.text) { + final label = switch (props.showTranslatedText) { + false => translations.originalLabel, + true => switch (message.i18n?['language']) { + null || '' => translations.translatedLabel, + final sourceLanguage => translations.translatedFromLanguageText(sourceLanguage), + }, + }; + final trailing = props.showTranslatedText + ? translations.showOriginalLabel + : translations.showTranslationLabel; + + translatedAnnotation = core.StreamMessageAnnotation( + onTap: props.onToggleTranslatedText, + leading: Icon(icons.translate), + label: Text('$label ·'), + trailing: Text(trailing), + style: .from(trailingTextColor: linkColor), + ); + } + } + } + final children = [ ?savedForLaterAnnotation, ?pinnedAnnotation, ?showInChannelAnnotation, ?reminderAnnotation, + ?translatedAnnotation, ]; if (children.isEmpty) return null; diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart index 507b362c02..899b4c376f 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart @@ -3,6 +3,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/stream_chat.dart'; +import 'package:stream_chat_flutter/src/stream_chat_configuration.dart'; import 'package:stream_chat_flutter/src/utils/device_segmentation.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -13,7 +14,9 @@ import 'package:stream_core_flutter/chat.dart' as core; /// /// The message text is translated into the current user's language, mention /// syntax is replaced with display names, and the result is rendered as -/// markdown. +/// markdown. Pass `showTranslatedText: false` to display the original text +/// even when a translation is available. Automatic translation can be +/// turned off SDK-wide via [StreamMessageTranslationConfiguration.enabled]. /// /// The widget rebuilds automatically when the current user's language /// changes, ensuring the displayed text stays in sync. @@ -31,12 +34,19 @@ class StreamMessageText extends StatelessWidget { this.onLinkTap, this.onMentionTap, this.onAnyMentionTap, + this.showTranslatedText = true, this.onSelectionChanged, }); /// The message whose text to display. final Message message; + /// Whether to display the translation of [message] when [Message.i18n] has + /// one for the current user's language. + /// + /// Set to `false` to display the original text instead. Defaults to `true`. + final bool showTranslatedText; + /// Called when a link in the rendered markdown is tapped. /// /// If null, tapping a link has no effect. @@ -67,12 +77,19 @@ class StreamMessageText extends StatelessWidget { @override Widget build(BuildContext context) { final streamChat = StreamChat.of(context); + final translationConfig = StreamChatConfiguration.of(context).messageTranslation; - return BetterStreamBuilder( - initialData: streamChat.currentUser?.language ?? 'en', - stream: streamChat.currentUserStream.map((it) => it?.language ?? 'en'), - builder: (context, language) { - final messageText = message.translate(language).replaceMentions().text?.replaceAll('\n', '\n\n').trim(); + // `BetterStreamBuilder`'s type parameter can't be nullable, so the + // current user's (possibly unset) language is wrapped in a record — + // itself always non-null — rather than defaulting it to an empty string. + return BetterStreamBuilder<({String? language})>( + initialData: (language: streamChat.currentUser?.language), + stream: streamChat.currentUserStream.map((it) => (language: it?.language)), + builder: (context, data) { + final translated = (showTranslatedText && translationConfig.enabled) + ? message.translate(data.language) + : message; + final messageText = translated.replaceMentions().text?.replaceAll('\n', '\n\n').trim(); if (messageText == null || messageText.trim().isEmpty) return const Empty(); diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart index 58429371b6..195165bd4a 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart @@ -6,7 +6,6 @@ import 'package:flutter/services.dart'; import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget_builder.dart'; import 'package:stream_chat_flutter/src/context_menu/context_menu.dart'; import 'package:stream_chat_flutter/src/context_menu/context_menu_region.dart'; -import 'package:stream_chat_flutter/src/message_widget/components/stream_message_content.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_core_flutter/chat.dart' as core; @@ -467,6 +466,9 @@ class DefaultStreamMessageItem extends StatelessWidget { Widget build(BuildContext context) { final message = props.message; + final translationStore = StreamMessageTranslations.of(context); + final showsOriginalText = translationStore?.isShowingOriginalText(message.id) ?? false; + final placement = StreamMessageLayout.of(context); final theme = core.StreamMessageItemTheme.of(context); final defaults = _StreamMessageItemDefaults( @@ -505,6 +507,11 @@ class DefaultStreamMessageItem extends StatelessWidget { final onTap? => () => onTap(message), _ => () => _onViewThread(context, message), }, + showTranslatedText: !showsOriginalText, + onToggleTranslatedText: switch (translationStore) { + final store? => () => store.toggleOriginalText(message.id), + null => null, + }, ), ); @@ -540,6 +547,7 @@ class DefaultStreamMessageItem extends StatelessWidget { attachmentBuilders: props.attachmentBuilders, reactionSorting: props.reactionSorting, onQuotedMessageTap: props.onQuotedMessageTap, + showTranslatedText: !showsOriginalText, onLinkTap: (_, href, __) { if (href == null) return; if (props.onMessageLinkTap case final onTap?) return onTap(message, href); @@ -863,6 +871,21 @@ class DefaultStreamMessageItem extends StatelessWidget { leadingInset = effectiveAvatarSize.value + effectiveSpacing; } + // Captured before the dialog opens, since the modal's overlay sits + // outside the StreamMessageListView subtree that provides this scope. + final translationStore = StreamMessageTranslations.of(context); + + final messageItem = StreamMessageItem( + key: const Key('MessageItem'), + message: message.trimmed, + padding: EdgeInsets.zero, + backgroundColor: core.StreamColors.transparent, + ); + final messageWidget = switch (translationStore) { + final store? => StreamMessageTranslations(store: store, child: messageItem), + null => messageItem, + }; + final action = await showStreamDialog( context: context, useRootNavigator: false, @@ -879,12 +902,7 @@ class DefaultStreamMessageItem extends StatelessWidget { leadingInset: leadingInset, messageWidget: StreamChannel.value( channel: channel, - child: StreamMessageItem( - key: const Key('MessageItem'), - message: message.trimmed, - padding: EdgeInsets.zero, - backgroundColor: core.StreamColors.transparent, - ), + child: messageWidget, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_translation_configuration.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_translation_configuration.dart new file mode 100644 index 0000000000..1d9a275e54 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_translation_configuration.dart @@ -0,0 +1,63 @@ +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template streamMessageTranslationConfiguration} +/// Configures how messages that carry a translation in [Message.i18n] are +/// displayed. +/// +/// Pass an instance to [StreamChatConfigurationData.messageTranslation]: +/// +/// ```dart +/// StreamChat( +/// client: client, +/// configData: StreamChatConfigurationData( +/// messageTranslation: const StreamMessageTranslationConfiguration( +/// annotationEnabled: true, +/// ), +/// ), +/// child: ChannelListPage(), +/// ) +/// ``` +/// {@endtemplate} +class StreamMessageTranslationConfiguration { + /// {@macro streamMessageTranslationConfiguration} + const StreamMessageTranslationConfiguration({ + this.enabled = true, + this.annotationEnabled = false, + }); + + /// Whether a message displays its translation in place of its original + /// text when [Message.i18n] has one for the current user's [User.language]. + /// + /// Applies to full messages ([StreamMessageText]) as well as to the + /// previews shown in the channel and thread lists + /// ([StreamMessagePreviewText]). + /// + /// Defaults to `true`, which is what the SDK has always done. Set it to + /// `false` to always show a message's original text, regardless of the + /// translations available for it. + final bool enabled; + + /// Whether a message displaying a translation shows a + /// "Translated"/"Original" annotation, with a "Show original"/"Show + /// translation" link that switches between the two. + /// + /// Ignored when [enabled] is `false` — no translation is on display, so + /// there is nothing to annotate or switch away from. + /// + /// Defaults to `false`, matching the SDK's long-standing behaviour of + /// translating messages without annotating them. Opt in to make the + /// translation visible and reversible to the user. + final bool annotationEnabled; + + /// Copies the configuration options from one + /// [StreamMessageTranslationConfiguration] to another. + StreamMessageTranslationConfiguration copyWith({ + bool? enabled, + bool? annotationEnabled, + }) { + return StreamMessageTranslationConfiguration( + enabled: enabled ?? this.enabled, + annotationEnabled: annotationEnabled ?? this.annotationEnabled, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_translation_store.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_translation_store.dart new file mode 100644 index 0000000000..556d736ef3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_translation_store.dart @@ -0,0 +1,107 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; + +/// The translation display state of the messages within a single message +/// list. +/// +/// Showing the translation is the default, so this holds only the +/// exceptions — see [messagesShowingOriginalText]. +/// +/// Instances are created only by a [StreamMessageTranslationStore] — read +/// one from its value, and change it through the store. +@immutable +class StreamMessageTranslationState { + const StreamMessageTranslationState._({ + this.messagesShowingOriginalText = const {}, + }); + + /// The ids of the messages the user has switched back to their original + /// text. + /// + /// Empty while every message shows its translation. + final Set messagesShowingOriginalText; + + /// Whether [messageId] is currently showing its original text instead of + /// its translation. + bool isShowingOriginalText(String messageId) { + return messagesShowingOriginalText.contains(messageId); + } + + StreamMessageTranslationState _copyWith({ + Set? messagesShowingOriginalText, + }) { + return StreamMessageTranslationState._( + messagesShowingOriginalText: messagesShowingOriginalText ?? this.messagesShowingOriginalText, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is StreamMessageTranslationState && + const SetEquality().equals( + other.messagesShowingOriginalText, + messagesShowingOriginalText, + ); + } + + @override + int get hashCode => const SetEquality().hash(messagesShowingOriginalText); +} + +/// Tracks, for messages within a single message list, which ones the user +/// has switched back to their original text. +/// +/// Mirrors `MessageOriginalTranslationsStore` in the Swift SDK. +/// +/// One store is owned per [StreamMessageListView] instance — a channel view +/// and an open thread each get independent toggle state, since opening a +/// thread creates its own [StreamMessageListView]. The store, rather than +/// the individual message widget, is what makes the toggle survive message +/// items being disposed and recreated as they scroll out of and back into +/// the list's render window. +class StreamMessageTranslationStore extends ValueNotifier { + /// Creates a store in which every message shows its translation. + StreamMessageTranslationStore() : super(const StreamMessageTranslationState._()); + + /// Whether [messageId] is currently showing its original text instead of + /// its translation. + bool isShowingOriginalText(String messageId) => value.isShowingOriginalText(messageId); + + /// Switches [messageId] between showing its original text and its + /// translation. + void toggleOriginalText(String messageId) { + final showingOriginalText = value.messagesShowingOriginalText; + + value = value._copyWith( + messagesShowingOriginalText: Set.unmodifiable( + switch (showingOriginalText.contains(messageId)) { + true => {...showingOriginalText}..remove(messageId), + false => {...showingOriginalText, messageId}, + }, + ), + ); + } +} + +/// Provides a [StreamMessageTranslationStore] to the widget tree. +/// +/// See also: +/// +/// * [StreamMessageTranslationStore], the store this scope provides. +class StreamMessageTranslations extends InheritedNotifier { + /// Creates a message translation scope. + const StreamMessageTranslations({ + super.key, + required StreamMessageTranslationStore store, + required super.child, + }) : super(notifier: store); + + /// Returns the [StreamMessageTranslationStore] from the nearest ancestor + /// [StreamMessageTranslations], or `null` if there isn't one. + static StreamMessageTranslationStore? of(BuildContext context) { + final widget = context.dependOnInheritedWidgetOfExactType(); + return widget?.notifier; + } +} diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart index 9ddbefafdf..cb637e7ea0 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -166,6 +166,7 @@ class StreamChatConfigurationData { StreamReactionsType? reactionType, StreamReactionsPosition? reactionPosition, StreamMessageListViewConfiguration messageListViewConfiguration = const StreamMessageListViewConfiguration(), + StreamMessageTranslationConfiguration messageTranslation = const StreamMessageTranslationConfiguration(), }) { return StreamChatConfigurationData._( reactionIconResolver: reactionIconResolver ?? const DefaultReactionIconResolver(), @@ -177,6 +178,7 @@ class StreamChatConfigurationData { reactionType: reactionType, reactionPosition: reactionPosition, messageListViewConfiguration: messageListViewConfiguration, + messageTranslation: messageTranslation, ); } @@ -188,6 +190,7 @@ class StreamChatConfigurationData { required this.imageCDN, required this.attachmentBuilders, required this.messageListViewConfiguration, + required this.messageTranslation, this.reactionType, this.reactionPosition, }); @@ -204,6 +207,7 @@ class StreamChatConfigurationData { StreamReactionsType? reactionType, StreamReactionsPosition? reactionPosition, StreamMessageListViewConfiguration? messageListViewConfiguration, + StreamMessageTranslationConfiguration? messageTranslation, }) { return StreamChatConfigurationData( reactionIconResolver: reactionIconResolver ?? this.reactionIconResolver, @@ -215,6 +219,7 @@ class StreamChatConfigurationData { reactionType: reactionType ?? this.reactionType, reactionPosition: reactionPosition ?? this.reactionPosition, messageListViewConfiguration: messageListViewConfiguration ?? this.messageListViewConfiguration, + messageTranslation: messageTranslation ?? this.messageTranslation, ); } @@ -271,4 +276,10 @@ class StreamChatConfigurationData { /// Defaults to a [StreamMessageListViewConfiguration] with all fields at their /// defaults. final StreamMessageListViewConfiguration messageListViewConfiguration; + + /// How messages that carry a translation in [Message.i18n] are displayed. + /// + /// Defaults to [StreamMessageTranslationConfiguration]'s own defaults: + /// translations are displayed automatically, without an annotation. + final StreamMessageTranslationConfiguration messageTranslation; } diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 31c92963a8..dfaa6bfb39 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -459,8 +459,15 @@ extension MessageX on Message { return messageTextLength * (fontSize ?? 1) * multiplier; } - /// It returns the message with the translated text if available locally - Message translate(String language) => copyWith(text: i18n?['${language}_text'] ?? text); + /// It returns the message with the translated text if available locally. + /// + /// Returns the message unchanged when [language] is `null` or empty — + /// Stream's API defaults [User.language] to `''` rather than omitting it, + /// so both are treated as "no language to translate to". + Message translate(String? language) => switch (language) { + null || '' => this, + final language => copyWith(text: i18n?['${language}_text'] ?? text), + }; /// It returns the message replacing the mentioned user names with /// the respective user ids diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 68ff50d32c..838aacc718 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -94,11 +94,14 @@ export 'src/message_modal/message_action_confirmation_modal.dart'; export 'src/message_modal/message_actions_modal.dart'; export 'src/message_modal/message_modal.dart'; export 'src/message_modal/moderated_message_actions_modal.dart'; +export 'src/message_widget/components/stream_message_content.dart'; export 'src/message_widget/components/stream_message_footer.dart'; export 'src/message_widget/components/stream_message_header.dart'; export 'src/message_widget/components/stream_message_leading.dart'; export 'src/message_widget/stream_ephemeral_message.dart'; export 'src/message_widget/stream_message_item.dart'; +export 'src/message_widget/stream_message_translation_configuration.dart'; +export 'src/message_widget/stream_message_translation_store.dart'; export 'src/message_widget/stream_moderated_message.dart'; export 'src/message_widget/stream_quoted_message.dart'; export 'src/message_widget/stream_system_message.dart'; diff --git a/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart b/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart index 0b96c22f69..7bb6b0087a 100644 --- a/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart @@ -851,6 +851,45 @@ void main() { expect(find.text('Hello, world!'), findsOneWidget); }); + + testWidgets('shows original text when translations are disabled', (tester) async { + final message = Message( + text: 'Hello, world!', + user: User(id: 'other-user-id', name: 'Other User'), + i18n: const { + 'fr_text': 'Bonjour, monde!', + }, + ); + + await pumpMessagePreview( + tester, + message, + language: 'fr', + configData: StreamChatConfigurationData( + messageTranslation: const StreamMessageTranslationConfiguration(enabled: false), + ), + ); + + expect(find.text('Hello, world!'), findsOneWidget); + expect(find.text('Bonjour, monde!'), findsNothing); + }); + + testWidgets('shows original text when no language is set', (tester) async { + final message = Message( + text: 'Hello, world!', + user: User(id: 'other-user-id', name: 'Other User'), + i18n: const { + 'fr_text': 'Bonjour, monde!', + }, + ); + + // The mocked current user has no `language`, so there is nothing to + // translate to. + await pumpMessagePreview(tester, message); + + expect(find.text('Hello, world!'), findsOneWidget); + expect(find.text('Bonjour, monde!'), findsNothing); + }); }); group('Custom MessagePreviewFormatter', () { diff --git a/packages/stream_chat_flutter/test/src/message_widget/components/message_translation_annotation_test.dart b/packages/stream_chat_flutter/test/src/message_widget/components/message_translation_annotation_test.dart new file mode 100644 index 0000000000..74dd0e6377 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/components/message_translation_annotation_test.dart @@ -0,0 +1,313 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_text.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart' hide StreamMessageText; +import 'package:stream_core_flutter/chat.dart' as core; + +import '../../mocks.dart'; + +void main() { + group('DefaultStreamMessageHeader translation annotation', () { + Future pumpHeader( + WidgetTester tester, { + required Message message, + String? userLanguage, + StreamMessageTranslationConfiguration? translationConfig, + bool showTranslatedText = true, + }) { + final currentUser = OwnUser(id: 'current-user', language: userLanguage); + + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + + return tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + configData: StreamChatConfigurationData( + messageTranslation: translationConfig ?? const StreamMessageTranslationConfiguration(), + ), + child: Scaffold( + body: core.StreamMessageLayout( + data: const core.StreamMessageLayoutData(), + child: DefaultStreamMessageHeader( + props: StreamMessageHeaderProps( + message: message, + showTranslatedText: showTranslatedText, + onToggleTranslatedText: () {}, + ), + ), + ), + ), + ), + ), + ); + } + + final translated = Message( + id: 'translated', + text: 'Hola, mundo!', + createdAt: DateTime(2026), + user: User(id: 'other-user'), + i18n: const { + 'language': 'es', + 'es_text': 'Hola, mundo!', + 'en_text': 'Hello, world!', + }, + ); + + testWidgets('is hidden by default, matching the SDK behaviour before it existed', (tester) async { + await pumpHeader(tester, message: translated, userLanguage: 'en'); + + expect(find.textContaining('Translated'), findsNothing); + expect(find.text('Show original'), findsNothing); + }); + + testWidgets('is shown once annotationEnabled is set', (tester) async { + await pumpHeader( + tester, + message: translated, + userLanguage: 'en', + translationConfig: const StreamMessageTranslationConfiguration(annotationEnabled: true), + ); + + expect(find.text('Translated from Spanish ·'), findsOneWidget); + expect(find.text('Show original'), findsOneWidget); + }); + + testWidgets('flips to "Original" while the original text is shown', (tester) async { + await pumpHeader( + tester, + message: translated, + userLanguage: 'en', + translationConfig: const StreamMessageTranslationConfiguration(annotationEnabled: true), + showTranslatedText: false, + ); + + expect(find.text('Original ·'), findsOneWidget); + expect(find.text('Show translation'), findsOneWidget); + }); + + testWidgets('stays hidden when translations are disabled entirely', (tester) async { + await pumpHeader( + tester, + message: translated, + userLanguage: 'en', + translationConfig: const StreamMessageTranslationConfiguration( + enabled: false, + annotationEnabled: true, + ), + ); + + expect(find.textContaining('Translated'), findsNothing); + }); + + testWidgets('stays hidden when the user has no language set', (tester) async { + await pumpHeader( + tester, + message: translated, + translationConfig: const StreamMessageTranslationConfiguration(annotationEnabled: true), + ); + + expect(find.textContaining('Translated'), findsNothing); + }); + + testWidgets('stays hidden when the user language is empty', (tester) async { + // Stream's API reports an unset `User.language` as `''` rather than + // omitting it, so the empty string must not be looked up as `_text`. + await pumpHeader( + tester, + message: translated, + userLanguage: '', + translationConfig: const StreamMessageTranslationConfiguration(annotationEnabled: true), + ); + + expect(find.textContaining('Translated'), findsNothing); + }); + + testWidgets('falls back to a bare "Translated" when the source language is unknown', (tester) async { + // `i18n` carries the translation but no `language` key, so there is no + // source language to name. + final unknownSource = Message( + id: 'unknown-source', + text: 'Hola, mundo!', + createdAt: DateTime(2026), + user: User(id: 'other-user'), + i18n: const {'en_text': 'Hello, world!'}, + ); + + await pumpHeader( + tester, + message: unknownSource, + userLanguage: 'en', + translationConfig: const StreamMessageTranslationConfiguration(annotationEnabled: true), + ); + + expect(find.text('Translated ·'), findsOneWidget); + expect(find.text('Show original'), findsOneWidget); + }); + + testWidgets('follows the current user language changing after the first frame', (tester) async { + // The annotation names the source language and only appears once the + // user has a language, so a language arriving over `currentUserStream` + // has to rebuild the header rather than being read once at mount. + final users = StreamController(); + addTearDown(users.close); + + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'current-user')); + when(() => clientState.currentUserStream).thenAnswer((_) => users.stream); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + configData: StreamChatConfigurationData( + messageTranslation: const StreamMessageTranslationConfiguration(annotationEnabled: true), + ), + child: Scaffold( + body: core.StreamMessageLayout( + data: const core.StreamMessageLayoutData(), + child: DefaultStreamMessageHeader( + props: StreamMessageHeaderProps(message: translated), + ), + ), + ), + ), + ), + ); + + // No language yet, so there is no translation to annotate. + expect(find.textContaining('Translated'), findsNothing); + + users.add(OwnUser(id: 'current-user', language: 'en')); + await tester.pumpAndSettle(); + + expect(find.text('Translated from Spanish ·'), findsOneWidget); + }); + + testWidgets('stays hidden for a user whose language is the source language', (tester) async { + // The server echoes the message's own language back in `i18n`, so + // `es_text` exists but equals the original text — there is nothing to + // toggle between. + await pumpHeader( + tester, + message: translated, + userLanguage: 'es', + translationConfig: const StreamMessageTranslationConfiguration(annotationEnabled: true), + ); + + expect(find.textContaining('Translated'), findsNothing); + }); + }); + + group('StreamMessageText translated text', () { + Future pumpText( + WidgetTester tester, { + required Message message, + String? userLanguage = 'en', + StreamMessageTranslationConfiguration? translationConfig, + bool showTranslatedText = true, + }) { + final currentUser = OwnUser(id: 'current-user', language: userLanguage); + + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + + return tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + configData: StreamChatConfigurationData( + messageTranslation: translationConfig ?? const StreamMessageTranslationConfiguration(), + ), + child: Scaffold( + body: StreamMessageText( + message: message, + showTranslatedText: showTranslatedText, + ), + ), + ), + ), + ); + } + + final translated = Message( + id: 'translated', + text: 'Hola, mundo!', + createdAt: DateTime(2026), + user: User(id: 'other-user'), + i18n: const { + 'language': 'es', + 'es_text': 'Hola, mundo!', + 'en_text': 'Hello, world!', + }, + ); + + testWidgets('shows the translation by default', (tester) async { + await pumpText(tester, message: translated); + + expect(find.text('Hello, world!'), findsOneWidget); + }); + + testWidgets('shows the original text when showTranslatedText is false', (tester) async { + await pumpText(tester, message: translated, showTranslatedText: false); + + expect(find.text('Hola, mundo!'), findsOneWidget); + }); + + testWidgets('shows the original text when translations are disabled', (tester) async { + await pumpText( + tester, + message: translated, + translationConfig: const StreamMessageTranslationConfiguration(enabled: false), + ); + + expect(find.text('Hola, mundo!'), findsOneWidget); + }); + + testWidgets('shows the original text when the user has no language', (tester) async { + await pumpText(tester, message: translated, userLanguage: null); + + expect(find.text('Hola, mundo!'), findsOneWidget); + }); + }); + + group('StreamMessageHeaderProps copyWith', () { + final message = Message(id: 'message', text: 'Hola', createdAt: DateTime(2026)); + + test('carries the translation fields over unchanged', () { + void onToggle() {} + final props = StreamMessageHeaderProps( + message: message, + showTranslatedText: false, + onToggleTranslatedText: onToggle, + ); + + final copy = props.copyWith(); + expect(copy.showTranslatedText, isFalse); + expect(copy.onToggleTranslatedText, same(onToggle)); + }); + + test('replaces the translation fields when given', () { + void onToggle() {} + final props = StreamMessageHeaderProps(message: message, showTranslatedText: false); + + final copy = props.copyWith(showTranslatedText: true, onToggleTranslatedText: onToggle); + expect(copy.showTranslatedText, isTrue); + expect(copy.onToggleTranslatedText, same(onToggle)); + expect(copy.message, same(message)); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_content_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_content_test.dart index 9c6362fb41..9a1c88030a 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/stream_message_content_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_content_test.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/message_widget/components/stream_message_content.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; // A deterministic attachment renderer so the post-frame width measurement diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart index 5c1753408e..3f2f0d92d0 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart @@ -220,4 +220,78 @@ void main() { expect(DefaultTranslations.instance.threadReplyCountText(1), '1 reply'); expect(DefaultTranslations.instance.threadReplyCountText(3), '3 replies'); }); + + group('StreamMessageItem translation toggle', () { + final currentUser = OwnUser(id: 'current-user', language: 'en'); + final otherUser = User(id: 'other-user'); + + final translated = Message( + id: 'translated', + text: 'Hola, mundo!', + createdAt: DateTime(2026), + user: otherUser, + state: MessageState.sent, + i18n: const { + 'language': 'es', + 'es_text': 'Hola, mundo!', + 'en_text': 'Hello, world!', + }, + ); + + Widget buildScene(StreamMessageTranslationStore store) { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + + return MaterialApp( + home: StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + configData: StreamChatConfigurationData( + messageTranslation: const StreamMessageTranslationConfiguration(annotationEnabled: true), + ), + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamMessageTranslations( + store: store, + child: StreamMessageItem(message: translated), + ), + ), + ), + ), + ); + } + + testWidgets('tapping the annotation swaps the translation for the original text', (tester) async { + final store = StreamMessageTranslationStore(); + addTearDown(store.dispose); + + await tester.pumpWidget(buildScene(store)); + await tester.pumpAndSettle(); + + expect(find.text('Hello, world!'), findsOneWidget); + expect(find.text('Show original'), findsOneWidget); + + await tester.tap(find.text('Show original')); + await tester.pumpAndSettle(); + + expect(store.isShowingOriginalText(translated.id), isTrue); + expect(find.text('Hola, mundo!'), findsOneWidget); + expect(find.text('Show translation'), findsOneWidget); + + await tester.tap(find.text('Show translation')); + await tester.pumpAndSettle(); + + expect(store.isShowingOriginalText(translated.id), isFalse); + expect(find.text('Hello, world!'), findsOneWidget); + }); + }); } diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_translation_store_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_translation_store_test.dart new file mode 100644 index 0000000000..ed5dc845e5 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_translation_store_test.dart @@ -0,0 +1,267 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + group('StreamMessageTranslationState', () { + /// Returns the state of a store that has toggled [messageIds], in order. + StreamMessageTranslationState stateShowingOriginalText(List messageIds) { + final store = StreamMessageTranslationStore(); + addTearDown(store.dispose); + messageIds.forEach(store.toggleOriginalText); + return store.value; + } + + test('shows the translation for every message by default', () { + final state = stateShowingOriginalText([]); + + expect(state.messagesShowingOriginalText, isEmpty); + expect(state.isShowingOriginalText('message-1'), isFalse); + }); + + test('isShowingOriginalText only matches the tracked ids', () { + final state = stateShowingOriginalText(['message-1']); + + expect(state.isShowingOriginalText('message-1'), isTrue); + expect(state.isShowingOriginalText('message-2'), isFalse); + }); + + test('exposes the tracked ids as an unmodifiable set', () { + final state = stateShowingOriginalText(['message-1']); + + expect(() => state.messagesShowingOriginalText.add('message-2'), throwsUnsupportedError); + expect(() => state.messagesShowingOriginalText.remove('message-1'), throwsUnsupportedError); + }); + + test('compares equal by the tracked ids, not by set identity', () { + final state = stateShowingOriginalText(['message-1', 'message-2']); + final equal = stateShowingOriginalText(['message-2', 'message-1']); + final different = stateShowingOriginalText(['message-1']); + + expect(state, equal); + expect(state.hashCode, equal.hashCode); + expect(state, isNot(different)); + }); + }); + + group('StreamMessageTranslationStore', () { + late StreamMessageTranslationStore store; + + setUp(() => store = StreamMessageTranslationStore()); + tearDown(() => store.dispose()); + + test('shows the translation for every message by default', () { + expect(store.value.messagesShowingOriginalText, isEmpty); + expect(store.isShowingOriginalText('message-1'), isFalse); + }); + + test('toggleOriginalText switches a message to its original text', () { + store.toggleOriginalText('message-1'); + + expect(store.isShowingOriginalText('message-1'), isTrue); + }); + + test('toggleOriginalText switches a message back to its translation', () { + store + ..toggleOriginalText('message-1') + ..toggleOriginalText('message-1'); + + expect(store.isShowingOriginalText('message-1'), isFalse); + expect(store.value.messagesShowingOriginalText, isEmpty); + }); + + test('tracks each message independently', () { + store + ..toggleOriginalText('message-1') + ..toggleOriginalText('message-2') + ..toggleOriginalText('message-1'); + + expect(store.isShowingOriginalText('message-1'), isFalse); + expect(store.isShowingOriginalText('message-2'), isTrue); + }); + + test('notifies listeners on every toggle', () { + var notifications = 0; + store + ..addListener(() => notifications++) + ..toggleOriginalText('message-1') + ..toggleOriginalText('message-1'); + + expect(notifications, 2); + }); + + test('does not notify listeners when the state is unchanged', () { + store.toggleOriginalText('message-1'); + + // An equal state from another store, so the dedup cannot rely on the + // two being the same instance. + final equivalent = StreamMessageTranslationStore()..toggleOriginalText('message-1'); + addTearDown(equivalent.dispose); + + var notifications = 0; + store + ..addListener(() => notifications++) + ..value = equivalent.value; + + expect(notifications, 0); + }); + }); + + group('StreamMessageTranslations', () { + late StreamMessageTranslationStore store; + + setUp(() => store = StreamMessageTranslationStore()); + tearDown(() => store.dispose()); + + testWidgets('provides the store to its descendants', (tester) async { + StreamMessageTranslationStore? found; + + await tester.pumpWidget( + StreamMessageTranslations( + store: store, + child: Builder( + builder: (context) { + found = StreamMessageTranslations.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(found, same(store)); + }); + + testWidgets('returns null without an ancestor scope', (tester) async { + StreamMessageTranslationStore? found; + + await tester.pumpWidget( + Builder( + builder: (context) { + found = StreamMessageTranslations.of(context); + return const SizedBox.shrink(); + }, + ), + ); + + expect(found, isNull); + }); + + testWidgets('a nested scope shadows the outer store, the way an open thread does', (tester) async { + final threadStore = StreamMessageTranslationStore(); + addTearDown(threadStore.dispose); + + StreamMessageTranslationStore? found; + + await tester.pumpWidget( + StreamMessageTranslations( + store: store, + child: StreamMessageTranslations( + store: threadStore, + child: Builder( + builder: (context) { + found = StreamMessageTranslations.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + expect(found, same(threadStore)); + + // Toggling in the thread leaves the channel list's own state alone. + threadStore.toggleOriginalText('message-1'); + + expect(store.isShowingOriginalText('message-1'), isFalse); + }); + + testWidgets('keeps a toggle across a message item being disposed and recreated', (tester) async { + var showsOriginalText = false; + + Widget buildScope({required bool itemIsInRenderWindow}) { + return StreamMessageTranslations( + store: store, + child: switch (itemIsInRenderWindow) { + false => const SizedBox.shrink(), + true => Builder( + builder: (context) { + final store = StreamMessageTranslations.of(context)!; + showsOriginalText = store.isShowingOriginalText('message-1'); + return const SizedBox.shrink(); + }, + ), + }, + ); + } + + await tester.pumpWidget(buildScope(itemIsInRenderWindow: true)); + expect(showsOriginalText, isFalse); + + store.toggleOriginalText('message-1'); + await tester.pump(); + expect(showsOriginalText, isTrue); + + // The item scrolls out of the list's render window and is disposed... + showsOriginalText = false; + await tester.pumpWidget(buildScope(itemIsInRenderWindow: false)); + + // ...then scrolls back in, rebuilt from scratch as a new element. + await tester.pumpWidget(buildScope(itemIsInRenderWindow: true)); + + expect(showsOriginalText, isTrue); + }); + + testWidgets('rebuilds its descendants when a message is toggled', (tester) async { + var builds = 0; + + await tester.pumpWidget( + StreamMessageTranslations( + store: store, + child: Builder( + builder: (context) { + StreamMessageTranslations.of(context); + builds++; + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(builds, 1); + + store.toggleOriginalText('message-1'); + await tester.pump(); + + expect(builds, 2); + }); + }); + + group('StreamMessageTranslationConfiguration', () { + test('translates messages without annotating them by default', () { + const config = StreamMessageTranslationConfiguration(); + + expect(config.enabled, isTrue); + expect(config.annotationEnabled, isFalse); + }); + + test('copyWith replaces only the given fields', () { + const config = StreamMessageTranslationConfiguration(); + + final annotated = config.copyWith(annotationEnabled: true); + expect(annotated.enabled, isTrue); + expect(annotated.annotationEnabled, isTrue); + + final disabled = annotated.copyWith(enabled: false); + expect(disabled.enabled, isFalse); + expect(disabled.annotationEnabled, isTrue); + }); + + test('copyWith without arguments keeps every field', () { + const config = StreamMessageTranslationConfiguration(enabled: false, annotationEnabled: true); + + final copy = config.copyWith(); + expect(copy.enabled, isFalse); + expect(copy.annotationEnabled, isTrue); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/utils/extension_test.dart b/packages/stream_chat_flutter/test/src/utils/extension_test.dart index 3f862a9de8..fd5837f88f 100644 --- a/packages/stream_chat_flutter/test/src/utils/extension_test.dart +++ b/packages/stream_chat_flutter/test/src/utils/extension_test.dart @@ -152,6 +152,53 @@ void main() { }); group('Message Extension Tests', () { + group('translate', () { + const original = 'Hello, world!'; + final message = Message( + text: original, + i18n: const { + 'language': 'en', + 'en_text': original, + 'fr_text': 'Bonjour, monde!', + }, + ); + + test('returns the translation for the given language', () { + expect(message.translate('fr').text, 'Bonjour, monde!'); + }); + + test('returns the original text when the language has no translation', () { + expect(message.translate('de').text, original); + }); + + test('returns the original text for a null language', () { + expect(message.translate(null).text, original); + }); + + test('returns the original text for an empty language', () { + // Stream's API reports an unset `User.language` as `''` rather than + // omitting it, so an empty string must not be looked up as `_text`. + expect(message.translate('').text, original); + }); + + test('returns the same instance when there is no language', () { + expect(message.translate(null), same(message)); + expect(message.translate(''), same(message)); + }); + + test('returns the original text when the message has no translations', () { + final untranslated = Message(text: original); + expect(untranslated.translate('fr').text, original); + }); + + test('leaves the rest of the message untouched', () { + final translated = message.translate('fr'); + + expect(translated.id, message.id); + expect(translated.i18n, message.i18n); + }); + }); + test('replaceMentions should replace user mentions with names and IDs', () { final user1 = User(id: 'user1', name: 'Alice'); final user2 = User(id: 'user2', name: 'Bob'); diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 404aae0b92..32cac6298b 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,5 +1,9 @@ ## Upcoming +✅ Added + +- Added message-translation strings for all supported locales: `translatedLabel`, `originalLabel`, `showOriginalLabel`, `showTranslationLabel`, and `translatedFromLanguageText(String languageCode)`, which names the source language of a translated message in the current locale. + 🔄 Changed - Raised minimum Flutter to `>=3.44.0` and Dart SDK to `^3.12.0`. diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index 0e3fcfe28a..8b8b9c16d4 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -815,6 +815,21 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String reminderAtText(String time) => 'Today at $time'; + @override + String get translatedLabel => 'Translated'; + + @override + String get originalLabel => 'Original'; + + @override + String get showOriginalLabel => 'Show original'; + + @override + String get showTranslationLabel => 'Show translation'; + + @override + String translatedFromLanguageText(String languageCode) => 'Translated from ${languageCode.toUpperCase()}'; + @override String get createPollPromptLabel => 'Create a poll and let everyone vote!'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index 063cd39e74..f0bec00216 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -795,6 +795,21 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String reminderAtText(String time) => 'Avui a les $time'; + @override + String get translatedLabel => 'Traduït'; + + @override + String get originalLabel => 'Original'; + + @override + String get showOriginalLabel => "Mostra l'original"; + + @override + String get showTranslationLabel => 'Mostra la traducció'; + + @override + String translatedFromLanguageText(String languageCode) => 'Traduït de ${_languageNameCa(languageCode)}'; + @override String get createPollPromptLabel => 'Crea una enquesta i deixa que tothom voti!'; @@ -1155,3 +1170,68 @@ class _AccessibilityTranslationsCa extends AccessibilityTranslations { return parts.join(', '); } } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`). Falls back to the uppercased code +// itself for anything unrecognized. +String _languageNameCa(String code) => _languageNamesCa[code] ?? code.toUpperCase(); + +const _languageNamesCa = { + 'af': 'Afrikaans', + 'sq': 'Albanès', + 'am': 'Amhàric', + 'ar': 'Àrab', + 'az': 'Azerbaidjanès', + 'bn': 'Bengalí', + 'bs': 'Bosnià', + 'bg': 'Búlgar', + 'zh': 'Xinès (simplificat)', + 'zh-TW': 'Xinès (tradicional)', + 'hr': 'Croat', + 'cs': 'Txec', + 'da': 'Danès', + 'fa-AF': 'Dari', + 'nl': 'Neerlandès', + 'en': 'Anglès', + 'et': 'Estonià', + 'fi': 'Finès', + 'fr': 'Francès', + 'fr-CA': 'Francès (Canadà)', + 'ka': 'Georgià', + 'de': 'Alemany', + 'el': 'Grec', + 'ht': 'Crioll haitià', + 'ha': 'Haussa', + 'he': 'Hebreu', + 'hi': 'Hindi', + 'hu': 'Hongarès', + 'id': 'Indonesi', + 'it': 'Italià', + 'ja': 'Japonès', + 'ko': 'Coreà', + 'lv': 'Letó', + 'lt': 'Lituà', + 'ms': 'Malai', + 'no': 'Noruec', + 'fa': 'Persa', + 'ps': 'Paixtu', + 'pl': 'Polonès', + 'pt': 'Portuguès', + 'ro': 'Romanès', + 'ru': 'Rus', + 'sr': 'Serbi', + 'sk': 'Eslovac', + 'sl': 'Eslovè', + 'so': 'Somali', + 'es': 'Espanyol', + 'es-MX': 'Espanyol (Mèxic)', + 'sw': 'Swahili', + 'sv': 'Suec', + 'tl': 'Tagal', + 'ta': 'Tàmil', + 'th': 'Tailandès', + 'tr': 'Turc', + 'uk': 'Ucraïnès', + 'ur': 'Urdú', + 'vi': 'Vietnamita', +}; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index d0be1bc668..17a74e894c 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -793,6 +793,21 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String reminderAtText(String time) => 'Heute um $time'; + @override + String get translatedLabel => 'Übersetzt'; + + @override + String get originalLabel => 'Original'; + + @override + String get showOriginalLabel => 'Original anzeigen'; + + @override + String get showTranslationLabel => 'Übersetzung anzeigen'; + + @override + String translatedFromLanguageText(String languageCode) => 'Übersetzt aus ${_languageNameDe(languageCode)}'; + @override String get createPollPromptLabel => 'Erstelle eine Umfrage und lass alle abstimmen!'; @@ -1153,3 +1168,68 @@ class _AccessibilityTranslationsDe extends AccessibilityTranslations { return parts.join(', '); } } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`). Falls back to the uppercased code +// itself for anything unrecognized. +String _languageNameDe(String code) => _languageNamesDe[code] ?? code.toUpperCase(); + +const _languageNamesDe = { + 'af': 'Afrikaans', + 'sq': 'Albanisch', + 'am': 'Amharisch', + 'ar': 'Arabisch', + 'az': 'Aserbaidschanisch', + 'bn': 'Bengalisch', + 'bs': 'Bosnisch', + 'bg': 'Bulgarisch', + 'zh': 'Chinesisch (vereinfacht)', + 'zh-TW': 'Chinesisch (traditionell)', + 'hr': 'Kroatisch', + 'cs': 'Tschechisch', + 'da': 'Dänisch', + 'fa-AF': 'Dari', + 'nl': 'Niederländisch', + 'en': 'Englisch', + 'et': 'Estnisch', + 'fi': 'Finnisch', + 'fr': 'Französisch', + 'fr-CA': 'Französisch (Kanada)', + 'ka': 'Georgisch', + 'de': 'Deutsch', + 'el': 'Griechisch', + 'ht': 'Haitianisches Kreol', + 'ha': 'Haussa', + 'he': 'Hebräisch', + 'hi': 'Hindi', + 'hu': 'Ungarisch', + 'id': 'Indonesisch', + 'it': 'Italienisch', + 'ja': 'Japanisch', + 'ko': 'Koreanisch', + 'lv': 'Lettisch', + 'lt': 'Litauisch', + 'ms': 'Malaiisch', + 'no': 'Norwegisch', + 'fa': 'Persisch', + 'ps': 'Paschtu', + 'pl': 'Polnisch', + 'pt': 'Portugiesisch', + 'ro': 'Rumänisch', + 'ru': 'Russisch', + 'sr': 'Serbisch', + 'sk': 'Slowakisch', + 'sl': 'Slowenisch', + 'so': 'Somali', + 'es': 'Spanisch', + 'es-MX': 'Spanisch (Mexiko)', + 'sw': 'Swahili', + 'sv': 'Schwedisch', + 'tl': 'Tagalog', + 'ta': 'Tamil', + 'th': 'Thailändisch', + 'tr': 'Türkisch', + 'uk': 'Ukrainisch', + 'ur': 'Urdu', + 'vi': 'Vietnamesisch', +}; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index 09caf5330a..b2fd80aaac 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -793,6 +793,21 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String reminderAtText(String time) => 'Today at $time'; + @override + String get translatedLabel => 'Translated'; + + @override + String get originalLabel => 'Original'; + + @override + String get showOriginalLabel => 'Show original'; + + @override + String get showTranslationLabel => 'Show translation'; + + @override + String translatedFromLanguageText(String languageCode) => 'Translated from ${_languageNameEn(languageCode)}'; + @override String get createPollPromptLabel => 'Create a poll and let everyone vote!'; @@ -1152,3 +1167,68 @@ class _AccessibilityTranslationsEn extends AccessibilityTranslations { return parts.join(', '); } } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`). Falls back to the uppercased code +// itself for anything unrecognized. +String _languageNameEn(String code) => _languageNamesEn[code] ?? code.toUpperCase(); + +const _languageNamesEn = { + 'af': 'Afrikaans', + 'sq': 'Albanian', + 'am': 'Amharic', + 'ar': 'Arabic', + 'az': 'Azerbaijani', + 'bn': 'Bengali', + 'bs': 'Bosnian', + 'bg': 'Bulgarian', + 'zh': 'Chinese (Simplified)', + 'zh-TW': 'Chinese (Traditional)', + 'hr': 'Croatian', + 'cs': 'Czech', + 'da': 'Danish', + 'fa-AF': 'Dari', + 'nl': 'Dutch', + 'en': 'English', + 'et': 'Estonian', + 'fi': 'Finnish', + 'fr': 'French', + 'fr-CA': 'French (Canada)', + 'ka': 'Georgian', + 'de': 'German', + 'el': 'Greek', + 'ht': 'Haitian Creole', + 'ha': 'Hausa', + 'he': 'Hebrew', + 'hi': 'Hindi', + 'hu': 'Hungarian', + 'id': 'Indonesian', + 'it': 'Italian', + 'ja': 'Japanese', + 'ko': 'Korean', + 'lv': 'Latvian', + 'lt': 'Lithuanian', + 'ms': 'Malay', + 'no': 'Norwegian', + 'fa': 'Persian', + 'ps': 'Pashto', + 'pl': 'Polish', + 'pt': 'Portuguese', + 'ro': 'Romanian', + 'ru': 'Russian', + 'sr': 'Serbian', + 'sk': 'Slovak', + 'sl': 'Slovenian', + 'so': 'Somali', + 'es': 'Spanish', + 'es-MX': 'Spanish (Mexico)', + 'sw': 'Swahili', + 'sv': 'Swedish', + 'tl': 'Tagalog', + 'ta': 'Tamil', + 'th': 'Thai', + 'tr': 'Turkish', + 'uk': 'Ukrainian', + 'ur': 'Urdu', + 'vi': 'Vietnamese', +}; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index 2f90d3f4ed..c514a65cc6 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -797,6 +797,21 @@ No es posible añadir más de $limit archivos adjuntos @override String reminderAtText(String time) => 'Hoy a las $time'; + @override + String get translatedLabel => 'Traducido'; + + @override + String get originalLabel => 'Original'; + + @override + String get showOriginalLabel => 'Ver original'; + + @override + String get showTranslationLabel => 'Ver traducción'; + + @override + String translatedFromLanguageText(String languageCode) => 'Traducido del ${_languageNameEs(languageCode)}'; + @override String get createPollPromptLabel => '¡Crea una encuesta y deja que todos voten!'; @@ -1157,3 +1172,68 @@ class _AccessibilityTranslationsEs extends AccessibilityTranslations { return parts.join(', '); } } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`). Falls back to the uppercased code +// itself for anything unrecognized. +String _languageNameEs(String code) => _languageNamesEs[code] ?? code.toUpperCase(); + +const _languageNamesEs = { + 'af': 'Afrikáans', + 'sq': 'Albanés', + 'am': 'Amárico', + 'ar': 'Árabe', + 'az': 'Azerbaiyano', + 'bn': 'Bengalí', + 'bs': 'Bosnio', + 'bg': 'Búlgaro', + 'zh': 'Chino (simplificado)', + 'zh-TW': 'Chino (tradicional)', + 'hr': 'Croata', + 'cs': 'Checo', + 'da': 'Danés', + 'fa-AF': 'Dari', + 'nl': 'Neerlandés', + 'en': 'Inglés', + 'et': 'Estonio', + 'fi': 'Finlandés', + 'fr': 'Francés', + 'fr-CA': 'Francés (Canadá)', + 'ka': 'Georgiano', + 'de': 'Alemán', + 'el': 'Griego', + 'ht': 'Criollo haitiano', + 'ha': 'Hausa', + 'he': 'Hebreo', + 'hi': 'Hindi', + 'hu': 'Húngaro', + 'id': 'Indonesio', + 'it': 'Italiano', + 'ja': 'Japonés', + 'ko': 'Coreano', + 'lv': 'Letón', + 'lt': 'Lituano', + 'ms': 'Malayo', + 'no': 'Noruego', + 'fa': 'Persa', + 'ps': 'Pastún', + 'pl': 'Polaco', + 'pt': 'Portugués', + 'ro': 'Rumano', + 'ru': 'Ruso', + 'sr': 'Serbio', + 'sk': 'Eslovaco', + 'sl': 'Esloveno', + 'so': 'Somalí', + 'es': 'Español', + 'es-MX': 'Español (México)', + 'sw': 'Swahili', + 'sv': 'Sueco', + 'tl': 'Tagalo', + 'ta': 'Tamil', + 'th': 'Tailandés', + 'tr': 'Turco', + 'uk': 'Ucraniano', + 'ur': 'Urdu', + 'vi': 'Vietnamita', +}; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 77d17595f4..122e2758f9 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -799,6 +799,21 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String reminderAtText(String time) => "Aujourd'hui à $time"; + @override + String get translatedLabel => 'Traduit'; + + @override + String get originalLabel => 'Original'; + + @override + String get showOriginalLabel => "Afficher l'original"; + + @override + String get showTranslationLabel => 'Afficher la traduction'; + + @override + String translatedFromLanguageText(String languageCode) => 'Traduit de ${_languageNameFr(languageCode)}'; + @override String get createPollPromptLabel => 'Créez un sondage et laissez tout le monde voter !'; @@ -1159,3 +1174,68 @@ class _AccessibilityTranslationsFr extends AccessibilityTranslations { return parts.join(', '); } } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`). Falls back to the uppercased code +// itself for anything unrecognized. +String _languageNameFr(String code) => _languageNamesFr[code] ?? code.toUpperCase(); + +const _languageNamesFr = { + 'af': 'Afrikaans', + 'sq': 'Albanais', + 'am': 'Amharique', + 'ar': 'Arabe', + 'az': 'Azerbaïdjanais', + 'bn': 'Bengali', + 'bs': 'Bosnien', + 'bg': 'Bulgare', + 'zh': 'Chinois (simplifié)', + 'zh-TW': 'Chinois (traditionnel)', + 'hr': 'Croate', + 'cs': 'Tchèque', + 'da': 'Danois', + 'fa-AF': 'Dari', + 'nl': 'Néerlandais', + 'en': 'Anglais', + 'et': 'Estonien', + 'fi': 'Finnois', + 'fr': 'Français', + 'fr-CA': 'Français (Canada)', + 'ka': 'Géorgien', + 'de': 'Allemand', + 'el': 'Grec', + 'ht': 'Créole haïtien', + 'ha': 'Haoussa', + 'he': 'Hébreu', + 'hi': 'Hindi', + 'hu': 'Hongrois', + 'id': 'Indonésien', + 'it': 'Italien', + 'ja': 'Japonais', + 'ko': 'Coréen', + 'lv': 'Letton', + 'lt': 'Lituanien', + 'ms': 'Malais', + 'no': 'Norvégien', + 'fa': 'Persan', + 'ps': 'Pachto', + 'pl': 'Polonais', + 'pt': 'Portugais', + 'ro': 'Roumain', + 'ru': 'Russe', + 'sr': 'Serbe', + 'sk': 'Slovaque', + 'sl': 'Slovène', + 'so': 'Somali', + 'es': 'Espagnol', + 'es-MX': 'Espagnol (Mexique)', + 'sw': 'Swahili', + 'sv': 'Suédois', + 'tl': 'Tagalog', + 'ta': 'Tamoul', + 'th': 'Thaï', + 'tr': 'Turc', + 'uk': 'Ukrainien', + 'ur': 'Ourdou', + 'vi': 'Vietnamien', +}; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index eaaea55d36..33b671432d 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -795,6 +795,21 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String reminderAtText(String time) => 'आज $time पर'; + @override + String get translatedLabel => 'अनुवादित'; + + @override + String get originalLabel => 'मूल'; + + @override + String get showOriginalLabel => 'मूल देखें'; + + @override + String get showTranslationLabel => 'अनुवाद देखें'; + + @override + String translatedFromLanguageText(String languageCode) => '${_languageNameHi(languageCode)} से अनुवादित'; + @override String get createPollPromptLabel => 'पोल बनाएं और सबको वोट करने दें!'; @@ -1153,3 +1168,68 @@ class _AccessibilityTranslationsHi extends AccessibilityTranslations { return parts.join(', '); } } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`). Falls back to the uppercased code +// itself for anything unrecognized. +String _languageNameHi(String code) => _languageNamesHi[code] ?? code.toUpperCase(); + +const _languageNamesHi = { + 'af': 'अफ़्रीकी', + 'sq': 'अल्बानियाई', + 'am': 'अम्हारी', + 'ar': 'अरबी', + 'az': 'अज़रबैजानी', + 'bn': 'बंगाली', + 'bs': 'बोस्नियाई', + 'bg': 'बुल्गारियाई', + 'zh': 'चीनी (सरलीकृत)', + 'zh-TW': 'चीनी (पारंपरिक)', + 'hr': 'क्रोएशियाई', + 'cs': 'चेक', + 'da': 'डैनिश', + 'fa-AF': 'दारी', + 'nl': 'डच', + 'en': 'अंग्रेज़ी', + 'et': 'एस्टोनियाई', + 'fi': 'फ़िनिश', + 'fr': 'फ़्रेंच', + 'fr-CA': 'फ़्रेंच (कनाडा)', + 'ka': 'जॉर्जियाई', + 'de': 'जर्मन', + 'el': 'यूनानी', + 'ht': 'हाईटियन क्रियोल', + 'ha': 'हौसा', + 'he': 'हिब्रू', + 'hi': 'हिन्दी', + 'hu': 'हंगेरियाई', + 'id': 'इंडोनेशियाई', + 'it': 'इटालियन', + 'ja': 'जापानी', + 'ko': 'कोरियाई', + 'lv': 'लातवियाई', + 'lt': 'लिथुआनियाई', + 'ms': 'मलय', + 'no': 'नॉर्वेजियन', + 'fa': 'फ़ारसी', + 'ps': 'पश्तो', + 'pl': 'पोलिश', + 'pt': 'पॉर्चुगीज़', + 'ro': 'रोमानियाई', + 'ru': 'रूसी', + 'sr': 'सर्बियाई', + 'sk': 'स्लोवाक', + 'sl': 'स्लोवेनियाई', + 'so': 'सोमाली', + 'es': 'स्पेनिश', + 'es-MX': 'स्पेनिश (मेक्सिको)', + 'sw': 'स्वाहिली', + 'sv': 'स्वीडिश', + 'tl': 'तागालोग', + 'ta': 'तमिल', + 'th': 'थाई', + 'tr': 'तुर्की', + 'uk': 'यूक्रेनियाई', + 'ur': 'उर्दू', + 'vi': 'वियतनामी', +}; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 1510cbab32..88eba28d75 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -802,6 +802,21 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String reminderAtText(String time) => 'Oggi alle $time'; + @override + String get translatedLabel => 'Tradotto'; + + @override + String get originalLabel => 'Originale'; + + @override + String get showOriginalLabel => 'Mostra originale'; + + @override + String get showTranslationLabel => 'Mostra traduzione'; + + @override + String translatedFromLanguageText(String languageCode) => 'Tradotto da ${_languageNameIt(languageCode)}'; + @override String get createPollPromptLabel => 'Crea un sondaggio e fai votare tutti!'; @@ -1167,3 +1182,68 @@ class _AccessibilityTranslationsIt extends AccessibilityTranslations { return parts.join(', '); } } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`). Falls back to the uppercased code +// itself for anything unrecognized. +String _languageNameIt(String code) => _languageNamesIt[code] ?? code.toUpperCase(); + +const _languageNamesIt = { + 'af': 'Afrikaans', + 'sq': 'Albanese', + 'am': 'Amarico', + 'ar': 'Arabo', + 'az': 'Azero', + 'bn': 'Bengalese', + 'bs': 'Bosniaco', + 'bg': 'Bulgaro', + 'zh': 'Cinese (semplificato)', + 'zh-TW': 'Cinese (tradizionale)', + 'hr': 'Croato', + 'cs': 'Ceco', + 'da': 'Danese', + 'fa-AF': 'Dari', + 'nl': 'Olandese', + 'en': 'Inglese', + 'et': 'Estone', + 'fi': 'Finlandese', + 'fr': 'Francese', + 'fr-CA': 'Francese (Canada)', + 'ka': 'Georgiano', + 'de': 'Tedesco', + 'el': 'Greco', + 'ht': 'Creolo haitiano', + 'ha': 'Hausa', + 'he': 'Ebraico', + 'hi': 'Hindi', + 'hu': 'Ungherese', + 'id': 'Indonesiano', + 'it': 'Italiano', + 'ja': 'Giapponese', + 'ko': 'Coreano', + 'lv': 'Lettone', + 'lt': 'Lituano', + 'ms': 'Malese', + 'no': 'Norvegese', + 'fa': 'Persiano', + 'ps': 'Pashto', + 'pl': 'Polacco', + 'pt': 'Portoghese', + 'ro': 'Romeno', + 'ru': 'Russo', + 'sr': 'Serbo', + 'sk': 'Slovacco', + 'sl': 'Sloveno', + 'so': 'Somalo', + 'es': 'Spagnolo', + 'es-MX': 'Spagnolo (Messico)', + 'sw': 'Swahili', + 'sv': 'Svedese', + 'tl': 'Tagalog', + 'ta': 'Tamil', + 'th': 'Tailandese', + 'tr': 'Turco', + 'uk': 'Ucraino', + 'ur': 'Urdu', + 'vi': 'Vietnamita', +}; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index 3e443c6f05..7e1c1d9add 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -775,6 +775,21 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String reminderAtText(String time) => '今日 $time'; + @override + String get translatedLabel => '翻訳済み'; + + @override + String get originalLabel => '原文'; + + @override + String get showOriginalLabel => '原文を表示'; + + @override + String get showTranslationLabel => '翻訳を表示'; + + @override + String translatedFromLanguageText(String languageCode) => '${_languageNameJa(languageCode)}から翻訳済み'; + @override String get createPollPromptLabel => '投票を作成してみんなに投票してもらおう!'; @@ -1132,3 +1147,68 @@ class _AccessibilityTranslationsJa extends AccessibilityTranslations { return parts.join('、'); } } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`). Falls back to the uppercased code +// itself for anything unrecognized. +String _languageNameJa(String code) => _languageNamesJa[code] ?? code.toUpperCase(); + +const _languageNamesJa = { + 'af': 'アフリカーンス語', + 'sq': 'アルバニア語', + 'am': 'アムハラ語', + 'ar': 'アラビア語', + 'az': 'アゼルバイジャン語', + 'bn': 'ベンガル語', + 'bs': 'ボスニア語', + 'bg': 'ブルガリア語', + 'zh': '中国語(簡体字)', + 'zh-TW': '中国語(繁体字)', + 'hr': 'クロアチア語', + 'cs': 'チェコ語', + 'da': 'デンマーク語', + 'fa-AF': 'ダリー語', + 'nl': 'オランダ語', + 'en': '英語', + 'et': 'エストニア語', + 'fi': 'フィンランド語', + 'fr': 'フランス語', + 'fr-CA': 'フランス語(カナダ)', + 'ka': 'ジョージア語', + 'de': 'ドイツ語', + 'el': 'ギリシャ語', + 'ht': 'ハイチクレオール語', + 'ha': 'ハウサ語', + 'he': 'ヘブライ語', + 'hi': 'ヒンディー語', + 'hu': 'ハンガリー語', + 'id': 'インドネシア語', + 'it': 'イタリア語', + 'ja': '日本語', + 'ko': '韓国語', + 'lv': 'ラトビア語', + 'lt': 'リトアニア語', + 'ms': 'マレー語', + 'no': 'ノルウェー語', + 'fa': 'ペルシャ語', + 'ps': 'パシュト語', + 'pl': 'ポーランド語', + 'pt': 'ポルトガル語', + 'ro': 'ルーマニア語', + 'ru': 'ロシア語', + 'sr': 'セルビア語', + 'sk': 'スロバキア語', + 'sl': 'スロベニア語', + 'so': 'ソマリ語', + 'es': 'スペイン語', + 'es-MX': 'スペイン語(メキシコ)', + 'sw': 'スワヒリ語', + 'sv': 'スウェーデン語', + 'tl': 'タガログ語', + 'ta': 'タミル語', + 'th': 'タイ語', + 'tr': 'トルコ語', + 'uk': 'ウクライナ語', + 'ur': 'ウルドゥー語', + 'vi': 'ベトナム語', +}; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index f22a77c352..377d3748a1 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -778,6 +778,21 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String reminderAtText(String time) => '오늘 $time'; + @override + String get translatedLabel => '번역됨'; + + @override + String get originalLabel => '원본'; + + @override + String get showOriginalLabel => '원본 보기'; + + @override + String get showTranslationLabel => '번역 보기'; + + @override + String translatedFromLanguageText(String languageCode) => '${_languageNameKo(languageCode)}에서 번역됨'; + @override String get createPollPromptLabel => '투표를 만들고 모두에게 투표하게 하세요!'; @@ -1135,3 +1150,68 @@ class _AccessibilityTranslationsKo extends AccessibilityTranslations { return parts.join(', '); } } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`). Falls back to the uppercased code +// itself for anything unrecognized. +String _languageNameKo(String code) => _languageNamesKo[code] ?? code.toUpperCase(); + +const _languageNamesKo = { + 'af': '아프리칸스어', + 'sq': '알바니아어', + 'am': '암하라어', + 'ar': '아랍어', + 'az': '아제르바이잔어', + 'bn': '벵골어', + 'bs': '보스니아어', + 'bg': '불가리아어', + 'zh': '중국어(간체)', + 'zh-TW': '중국어(번체)', + 'hr': '크로아티아어', + 'cs': '체코어', + 'da': '덴마크어', + 'fa-AF': '다리어', + 'nl': '네덜란드어', + 'en': '영어', + 'et': '에스토니아어', + 'fi': '핀란드어', + 'fr': '프랑스어', + 'fr-CA': '프랑스어(캐나다)', + 'ka': '조지아어', + 'de': '독일어', + 'el': '그리스어', + 'ht': '아이티 크리올어', + 'ha': '하우사어', + 'he': '히브리어', + 'hi': '힌디어', + 'hu': '헝가리어', + 'id': '인도네시아어', + 'it': '이탈리아어', + 'ja': '일본어', + 'ko': '한국어', + 'lv': '라트비아어', + 'lt': '리투아니아어', + 'ms': '말레이어', + 'no': '노르웨이어', + 'fa': '페르시아어', + 'ps': '파슈토어', + 'pl': '폴란드어', + 'pt': '포르투갈어', + 'ro': '루마니아어', + 'ru': '러시아어', + 'sr': '세르비아어', + 'sk': '슬로바키아어', + 'sl': '슬로베니아어', + 'so': '소말리어', + 'es': '스페인어', + 'es-MX': '스페인어(멕시코)', + 'sw': '스와힐리어', + 'sv': '스웨덴어', + 'tl': '타갈로그어', + 'ta': '타밀어', + 'th': '태국어', + 'tr': '터키어', + 'uk': '우크라이나어', + 'ur': '우르두어', + 'vi': '베트남어', +}; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 180160c55d..724be8f0ba 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -778,6 +778,21 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String reminderAtText(String time) => 'I dag kl. $time'; + @override + String get translatedLabel => 'Oversatt'; + + @override + String get originalLabel => 'Original'; + + @override + String get showOriginalLabel => 'Vis original'; + + @override + String get showTranslationLabel => 'Vis oversettelse'; + + @override + String translatedFromLanguageText(String languageCode) => 'Oversatt fra ${_languageNameNo(languageCode)}'; + @override String get createPollPromptLabel => 'Lag en avstemning og la alle stemme!'; @@ -1137,3 +1152,68 @@ class _AccessibilityTranslationsNo extends AccessibilityTranslations { return parts.join(', '); } } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`). Falls back to the uppercased code +// itself for anything unrecognized. +String _languageNameNo(String code) => _languageNamesNo[code] ?? code.toUpperCase(); + +const _languageNamesNo = { + 'af': 'Afrikaans', + 'sq': 'Albansk', + 'am': 'Amharisk', + 'ar': 'Arabisk', + 'az': 'Aserbajdsjansk', + 'bn': 'Bengali', + 'bs': 'Bosnisk', + 'bg': 'Bulgarsk', + 'zh': 'Kinesisk (forenklet)', + 'zh-TW': 'Kinesisk (tradisjonell)', + 'hr': 'Kroatisk', + 'cs': 'Tsjekkisk', + 'da': 'Dansk', + 'fa-AF': 'Dari', + 'nl': 'Nederlandsk', + 'en': 'Engelsk', + 'et': 'Estisk', + 'fi': 'Finsk', + 'fr': 'Fransk', + 'fr-CA': 'Fransk (Canada)', + 'ka': 'Georgisk', + 'de': 'Tysk', + 'el': 'Gresk', + 'ht': 'Haitisk kreolsk', + 'ha': 'Hausa', + 'he': 'Hebraisk', + 'hi': 'Hindi', + 'hu': 'Ungarsk', + 'id': 'Indonesisk', + 'it': 'Italiensk', + 'ja': 'Japansk', + 'ko': 'Koreansk', + 'lv': 'Latvisk', + 'lt': 'Litauisk', + 'ms': 'Malayisk', + 'no': 'Norsk', + 'fa': 'Persisk', + 'ps': 'Pashto', + 'pl': 'Polsk', + 'pt': 'Portugisisk', + 'ro': 'Rumensk', + 'ru': 'Russisk', + 'sr': 'Serbisk', + 'sk': 'Slovakisk', + 'sl': 'Slovensk', + 'so': 'Somalisk', + 'es': 'Spansk', + 'es-MX': 'Spansk (Mexico)', + 'sw': 'Swahili', + 'sv': 'Svensk', + 'tl': 'Tagalog', + 'ta': 'Tamilsk', + 'th': 'Thai', + 'tr': 'Tyrkisk', + 'uk': 'Ukrainsk', + 'ur': 'Urdu', + 'vi': 'Vietnamesisk', +}; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index 5909875c1f..dd34d8302a 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -796,6 +796,21 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String reminderAtText(String time) => 'Hoje às $time'; + @override + String get translatedLabel => 'Traduzido'; + + @override + String get originalLabel => 'Original'; + + @override + String get showOriginalLabel => 'Ver original'; + + @override + String get showTranslationLabel => 'Ver tradução'; + + @override + String translatedFromLanguageText(String languageCode) => 'Traduzido de ${_languageNamePt(languageCode)}'; + @override String get createPollPromptLabel => 'Crie uma enquete e deixe todos votarem!'; @@ -1156,3 +1171,68 @@ class _AccessibilityTranslationsPt extends AccessibilityTranslations { return parts.join(', '); } } + +// Display name for each language code Stream Chat's translation API +// supports (`Message.i18n['language']`). Falls back to the uppercased code +// itself for anything unrecognized. +String _languageNamePt(String code) => _languageNamesPt[code] ?? code.toUpperCase(); + +const _languageNamesPt = { + 'af': 'Africâner', + 'sq': 'Albanês', + 'am': 'Amárico', + 'ar': 'Árabe', + 'az': 'Azerbaijano', + 'bn': 'Bengali', + 'bs': 'Bósnio', + 'bg': 'Búlgaro', + 'zh': 'Chinês (simplificado)', + 'zh-TW': 'Chinês (tradicional)', + 'hr': 'Croata', + 'cs': 'Tcheco', + 'da': 'Dinamarquês', + 'fa-AF': 'Dari', + 'nl': 'Holandês', + 'en': 'Inglês', + 'et': 'Estoniano', + 'fi': 'Finlandês', + 'fr': 'Francês', + 'fr-CA': 'Francês (Canadá)', + 'ka': 'Georgiano', + 'de': 'Alemão', + 'el': 'Grego', + 'ht': 'Crioulo haitiano', + 'ha': 'Hauçá', + 'he': 'Hebraico', + 'hi': 'Hindi', + 'hu': 'Húngaro', + 'id': 'Indonésio', + 'it': 'Italiano', + 'ja': 'Japonês', + 'ko': 'Coreano', + 'lv': 'Letão', + 'lt': 'Lituano', + 'ms': 'Malaio', + 'no': 'Norueguês', + 'fa': 'Persa', + 'ps': 'Pachto', + 'pl': 'Polonês', + 'pt': 'Português', + 'ro': 'Romeno', + 'ru': 'Russo', + 'sr': 'Sérvio', + 'sk': 'Eslovaco', + 'sl': 'Esloveno', + 'so': 'Somali', + 'es': 'Espanhol', + 'es-MX': 'Espanhol (México)', + 'sw': 'Swahili', + 'sv': 'Sueco', + 'tl': 'Tagalo', + 'ta': 'Tâmil', + 'th': 'Tailandês', + 'tr': 'Turco', + 'uk': 'Ucraniano', + 'ur': 'Urdu', + 'vi': 'Vietnamita', +}; diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index a763436c90..115f32f03c 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -358,6 +358,14 @@ void main() { expect(localizations.viewLabel, isNotNull); expect(localizations.reminderSetLabel, isNotNull); expect(localizations.reminderAtText('3:00 PM'), isNotNull); + expect(localizations.translatedLabel, isNotNull); + expect(localizations.originalLabel, isNotNull); + expect(localizations.showOriginalLabel, isNotNull); + expect(localizations.showTranslationLabel, isNotNull); + // known language code + expect(localizations.translatedFromLanguageText('es'), isNotNull); + // unknown language code, falls back to the uppercased code itself + expect(localizations.translatedFromLanguageText('xx'), contains('XX')); expect(localizations.createPollPromptLabel, isNotNull); expect(localizations.takePhotoAndShareLabel, isNotNull); expect(localizations.takeVideoAndShareLabel, isNotNull); diff --git a/sample_app/lib/app.dart b/sample_app/lib/app.dart index 166c1e4e67..6f820769bb 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -209,7 +209,13 @@ class _StreamChatSampleAppState extends State builder: (context, cachedChild) { final wrapped = Directionality( textDirection: config.forceRtl ? .rtl : .ltr, - child: cachedChild ?? const SizedBox.shrink(), + // Above the router so any screen — and the + // dialogs it pushes — can fire snackbars via + // `StreamSnackbarMessenger.of(context)`. Inside + // the Directionality, which its Stack needs. + child: StreamSnackbarScope( + child: cachedChild ?? const SizedBox.shrink(), + ), ); // StreamChat stays mounted as long as a client @@ -312,6 +318,10 @@ extension on SampleAppConfigData { enforceUniqueReactions: enforceUniqueReactions, reactionType: reactionType, reactionPosition: reactionPosition, + messageTranslation: StreamMessageTranslationConfiguration( + enabled: enableMessageTranslation, + annotationEnabled: enableMessageTranslation, + ), attachmentBuilders: [ if (enableLocationSharing) LocationAttachmentBuilder( diff --git a/sample_app/lib/config/sample_app_config.dart b/sample_app/lib/config/sample_app_config.dart index 6631482cf1..0ac92b195f 100644 --- a/sample_app/lib/config/sample_app_config.dart +++ b/sample_app/lib/config/sample_app_config.dart @@ -20,6 +20,7 @@ const _kEnableDynamicColor = 'config.enableDynamicColor'; const _kEnableReminderActions = 'config.enableReminderActions'; const _kEnableDeleteForMe = 'config.enableDeleteForMe'; const _kEnableMessageInfo = 'config.enableMessageInfo'; +const _kEnableMessageTranslation = 'config.enableMessageTranslation'; const _kEnableLocationSharing = 'config.enableLocationSharing'; const _kDraftMessagesEnabled = 'config.draftMessagesEnabled'; const _kEnforceUniqueReactions = 'config.enforceUniqueReactions'; @@ -58,6 +59,7 @@ class SampleAppConfigData { bool enableReminderActions = false, bool enableDeleteForMe = false, bool enableMessageInfo = false, + bool enableMessageTranslation = false, bool enableLocationSharing = false, bool draftMessagesEnabled = true, bool enforceUniqueReactions = false, @@ -79,6 +81,7 @@ class SampleAppConfigData { enableReminderActions: enableReminderActions, enableDeleteForMe: enableDeleteForMe, enableMessageInfo: enableMessageInfo, + enableMessageTranslation: enableMessageTranslation, enableLocationSharing: enableLocationSharing, draftMessagesEnabled: draftMessagesEnabled, enforceUniqueReactions: enforceUniqueReactions, @@ -103,6 +106,7 @@ class SampleAppConfigData { required this.enableReminderActions, required this.enableDeleteForMe, required this.enableMessageInfo, + required this.enableMessageTranslation, required this.enableLocationSharing, required this.draftMessagesEnabled, required this.enforceUniqueReactions, @@ -137,6 +141,7 @@ class SampleAppConfigData { enableReminderActions: prefs.getBool(_kEnableReminderActions, defaultValue: false).getValue(), enableDeleteForMe: prefs.getBool(_kEnableDeleteForMe, defaultValue: false).getValue(), enableMessageInfo: prefs.getBool(_kEnableMessageInfo, defaultValue: false).getValue(), + enableMessageTranslation: prefs.getBool(_kEnableMessageTranslation, defaultValue: false).getValue(), enableLocationSharing: prefs.getBool(_kEnableLocationSharing, defaultValue: false).getValue(), draftMessagesEnabled: prefs.getBool(_kDraftMessagesEnabled, defaultValue: false).getValue(), enforceUniqueReactions: prefs.getBool(_kEnforceUniqueReactions, defaultValue: false).getValue(), @@ -207,6 +212,15 @@ class SampleAppConfigData { /// Whether the "Message Info" action appears in the message context menu. final bool enableMessageInfo; + /// Whether message translations are surfaced: the "Translate Message" + /// action in the message context menu, the SDK rendering a message's + /// translation in place of its original text, and the translation + /// annotation on messages that show one. + /// + /// When off, messages always render their original text, even if the + /// backend already translated them. + final bool enableMessageTranslation; + /// Whether location sharing (attachment builder + picker) is enabled. final bool enableLocationSharing; @@ -245,6 +259,7 @@ class SampleAppConfigData { bool? enableReminderActions, bool? enableDeleteForMe, bool? enableMessageInfo, + bool? enableMessageTranslation, bool? enableLocationSharing, bool? draftMessagesEnabled, bool? enforceUniqueReactions, @@ -278,6 +293,7 @@ class SampleAppConfigData { enableReminderActions: enableReminderActions ?? this.enableReminderActions, enableDeleteForMe: enableDeleteForMe ?? this.enableDeleteForMe, enableMessageInfo: enableMessageInfo ?? this.enableMessageInfo, + enableMessageTranslation: enableMessageTranslation ?? this.enableMessageTranslation, enableLocationSharing: enableLocationSharing ?? this.enableLocationSharing, draftMessagesEnabled: draftMessagesEnabled ?? this.draftMessagesEnabled, enforceUniqueReactions: enforceUniqueReactions ?? this.enforceUniqueReactions, @@ -306,6 +322,7 @@ class SampleAppConfigData { prefs.setBool(_kEnableReminderActions, enableReminderActions); prefs.setBool(_kEnableDeleteForMe, enableDeleteForMe); prefs.setBool(_kEnableMessageInfo, enableMessageInfo); + prefs.setBool(_kEnableMessageTranslation, enableMessageTranslation); prefs.setBool(_kEnableLocationSharing, enableLocationSharing); prefs.setBool(_kDraftMessagesEnabled, draftMessagesEnabled); prefs.setBool(_kEnforceUniqueReactions, enforceUniqueReactions); diff --git a/sample_app/lib/config/sample_app_config_screen.dart b/sample_app/lib/config/sample_app_config_screen.dart index abc73375e7..2a70c0c1cd 100644 --- a/sample_app/lib/config/sample_app_config_screen.dart +++ b/sample_app/lib/config/sample_app_config_screen.dart @@ -106,6 +106,13 @@ class SampleAppConfigScreen extends StatelessWidget { value: config.enableMessageInfo, onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableMessageInfo: v)), ), + _SwitchRow( + icon: icons.translate, + title: 'Message Translation', + subtitle: 'Show translations, annotate them, and translate on request', + value: config.enableMessageTranslation, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableMessageTranslation: v)), + ), _SwitchRow( icon: icons.location, title: 'Location Sharing', diff --git a/sample_app/lib/utils/app_config.dart b/sample_app/lib/utils/app_config.dart index 0956c8a58d..a582194aa8 100644 --- a/sample_app/lib/utils/app_config.dart +++ b/sample_app/lib/utils/app_config.dart @@ -2,10 +2,17 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; const kDefaultStreamApiKey = 'kv7mcsxr24p8'; +/// The demo accounts, keyed by their (pre-generated) user token. +/// +/// Each carries a [User.language] roughly matching its name, so signing in as +/// different users demonstrates message translation: the backend only +/// auto-translates for users that have a language set, and the UI renders the +/// translation for the current user's language. final defaultUsers = { 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FsdmF0b3JlIn0.pgiJz7sIc7iP29BHKFwe3nLm5-OaR_1l2P-SlgiC9a8': User( id: 'salvatore', + language: 'it', extraData: const { 'name': 'Salvatore Giordano', 'image': @@ -14,6 +21,7 @@ final defaultUsers = { ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FoaWwifQ.WnIUoB5gR2kcAsFhiDvkiD6zdHXZ-VSU2aQWWkhsvfo': User( id: 'sahil', + language: 'hi', extraData: const { 'name': 'Sahil Kumar', 'image': 'https://avatars.githubusercontent.com/u/25670178?s=400&u=30ded3784d8d2310c5748f263fd5e6433c119aa1&v=4', @@ -21,6 +29,7 @@ final defaultUsers = { ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYmVuIn0.nAz2sNFGQwY7rl2Og2z3TGHUsdpnN53tOsUglJFvLmg': User( id: 'ben', + language: 'en', extraData: const { 'name': 'Ben Golden', 'image': 'https://avatars.githubusercontent.com/u/1581974?s=400&v=4', @@ -28,6 +37,7 @@ final defaultUsers = { ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidGhpZXJyeSJ9.lEq6TrZtHzjoNtf7HHRufUPyGo_pa8vg4_XhEBp4ckY': User( id: 'thierry', + language: 'nl', extraData: const { 'name': 'Thierry Schellenbach', 'image': 'https://avatars.githubusercontent.com/u/265409?s=400&u=2d0e3bb1820db992066196bff7b004f0eee8e28d&v=4', @@ -35,6 +45,7 @@ final defaultUsers = { ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidG9tbWFzbyJ9.GLSI0ESshERMo2WjUpysD709NEtn1zmGimUN2an7g9o': User( id: 'tommaso', + language: 'it', extraData: const { 'name': 'Tommaso Barbugli', 'image': 'https://avatars.githubusercontent.com/u/88735?s=400&v=4', @@ -49,6 +60,7 @@ final defaultUsers = { // ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibmVldmFzaCJ9.3EdHegTxibrz3A9cTiKmpEyawwcCVB8FXnoFzr4eKvw': User( id: 'neevash', + language: 'en', extraData: const { 'name': 'Neevash Ramdial', 'image': 'https://avatars.githubusercontent.com/u/25674767?s=400&u=1d7333baf7dd9d143db8bfcdb31a838b89cfff9c&v=4', @@ -56,12 +68,14 @@ final defaultUsers = { ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MSJ9.fnelU7HcP7QoEEsCGteNlF1fppofzNlrnpDQuIgeKCU': User( id: 'qatest1', + language: 'es', extraData: const { 'name': 'QA test 1', }, ), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MiJ9.vSCqAEbs2WVmMWsOsa7065Fsjq-rsTih6qsHPynl7XM': User( id: 'qatest2', + language: 'ja', extraData: const { 'name': 'QA test 2', }, diff --git a/sample_app/lib/widgets/custom_message_actions.dart b/sample_app/lib/widgets/custom_message_actions.dart index 593c8123c9..91864f0de7 100644 --- a/sample_app/lib/widgets/custom_message_actions.dart +++ b/sample_app/lib/widgets/custom_message_actions.dart @@ -14,21 +14,24 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// → _ReminderActions (remind me, save for later, edit/remove reminder) /// → _DeleteForMeAction (delete message for current user only) /// → _MessageInfoAction (show message delivery info sheet) +/// → _TranslateMessageAction (translate message on request) /// ``` Widget customMessageItemBuilder( BuildContext context, StreamMessageItemProps props, ) { + final message = props.message; + return DefaultStreamMessageItem( props: props.copyWith( actionsBuilder: (context, defaultActions) { - final message = props.message; return StreamContextMenuAction.partitioned( items: [ ...defaultActions, ..._ReminderActions.build(context, message), ..._DeleteForMeAction.build(context, message), ..._MessageInfoAction.build(context, message), + ..._TranslateMessageAction.build(context, message), ], ); }, @@ -201,3 +204,74 @@ abstract final class _MessageInfoAction { ]; } } + +// --------------------------------------------------------------------------- +// Translate action +// --------------------------------------------------------------------------- + +/// Requests an on-request translation for a message and merges the result +/// into the channel's local state. +/// +/// Once merged, the message's `i18n` map carries the translation, and the +/// SDK's own message rendering (`StreamMessageText`, `DefaultStreamMessageHeader`) +/// picks it up automatically — this sample app only needs to fetch it. +/// +/// Only shown for messages from other users — translating your own message +/// isn't a useful action — and always translates directly to the current +/// user's [User.language], with no language picker. Hidden entirely when +/// that isn't set, rather than guessing a target language. +abstract final class _TranslateMessageAction { + static List build( + BuildContext context, + Message message, + ) { + if (!context.sampleAppConfig.enableMessageTranslation) return const []; + if (message.deletedAt != null) return const []; + if (message.text == null || message.text!.isEmpty) return const []; + + final currentUser = StreamChat.of(context).currentUser; + final isSentByCurrentUser = message.user?.id == currentUser?.id; + if (isSentByCurrentUser) return const []; + + // The SDK renders translations for the user's own language, so that is + // what we ask the backend for. Set at login by `AuthController.connect`. + final language = currentUser?.language; + if (language == null || language.isEmpty) return const []; + + // Already covers the message's own language: the server includes a + // self-referential entry keyed by its source language, so this is also + // `true` when the original text is already in the display language — + // either way, there's nothing left to translate. + final alreadyTranslated = message.i18n?['${language}_text'] != null; + + final icons = context.streamIcons; + return [ + StreamContextMenuAction( + label: const Text('Translate Message'), + leading: Icon(icons.translate), + enabled: !alreadyTranslated, + onTap: () => _translateMessage(context, message, language), + ), + ]; + } + + static Future _translateMessage( + BuildContext context, + Message message, + String language, + ) async { + final channel = StreamChannel.of(context).channel; + try { + final response = await channel.translateMessage(message.id, language); + channel.state?.updateMessage(response.message); + } catch (e) { + if (!context.mounted) return; + StreamSnackbarMessenger.of(context).show( + StreamSnackbar( + message: Text('Failed to translate message: $e'), + variant: .error, + ), + ); + } + } +}