From 4a774b492ae5d4035816f4403a2f31f09bf2c15e Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 13:28:16 +0200 Subject: [PATCH 01/18] feat(ui): show a "Translated from ..." annotation on messages with an available translation Automatically detects when Message.i18n already has a translation for the current user's language (either from server-side auto-translation or an earlier on-request translate call) and surfaces it as a message annotation, with a tappable link to toggle back to the original text. - DefaultStreamMessageHeader renders the annotation last in the mixed- annotation priority order, per the design system's Figma spec, and names the message's original language when known. - The show-original toggle lives in a new StreamMessageTranslationStore, scoped per StreamMessageListView, so it survives list virtualization instead of resetting when a message widget scrolls out of view and back. - Gated behind a new translationDisplayEnabled config flag (default true) for apps that want to opt out entirely. - Adds the "Translated"/"Original"/"Show original"/"Show translation"/ "Translated from {language}" strings, plus a curated language-code to display-name table, to all 11 bundled locales. --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../lib/src/localization/translations.dart | 107 ++++++++++++++++++ .../message_list_view/message_list_view.dart | 43 ++++--- .../components/stream_message_content.dart | 9 ++ .../components/stream_message_header.dart | 105 ++++++++++++++++- .../components/stream_message_text.dart | 27 ++++- .../message_widget/stream_message_item.dart | 9 ++ .../stream_message_translation_store.dart | 48 ++++++++ .../lib/src/stream_chat_configuration.dart | 15 +++ .../lib/stream_chat_flutter.dart | 1 + .../lib/src/stream_chat_localizations_ca.dart | 80 +++++++++++++ .../lib/src/stream_chat_localizations_de.dart | 80 +++++++++++++ .../lib/src/stream_chat_localizations_en.dart | 80 +++++++++++++ .../lib/src/stream_chat_localizations_es.dart | 80 +++++++++++++ .../lib/src/stream_chat_localizations_fr.dart | 80 +++++++++++++ .../lib/src/stream_chat_localizations_hi.dart | 80 +++++++++++++ .../lib/src/stream_chat_localizations_it.dart | 80 +++++++++++++ .../lib/src/stream_chat_localizations_ja.dart | 80 +++++++++++++ .../lib/src/stream_chat_localizations_ko.dart | 80 +++++++++++++ .../lib/src/stream_chat_localizations_no.dart | 80 +++++++++++++ .../lib/src/stream_chat_localizations_pt.dart | 80 +++++++++++++ 21 files changed, 1224 insertions(+), 21 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/stream_message_translation_store.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index f1bab37cfe..86ffec5670 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -8,6 +8,7 @@ - Added a `size` (`StreamLoadingSpinnerSize`) parameter to `StreamScrollViewLoadingWidget`. - 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). - 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. +- Added a "Translated"/"Original" message annotation, shown automatically when `Message.i18n` has a translation for the current user's language, with a "Show original"/"Show translation" link to toggle between the two. The toggle state is tracked per `StreamMessageListView` via the new `StreamMessageTranslationStore`, so it survives messages scrolling out of and back into view. `StreamMessageText` gained a `showOriginalText` parameter for direct control, and `StreamChatConfigurationData.translationDisplayEnabled` (default `true`) turns the whole feature off SDK-wide. ⚠️ Deprecated 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 c8ecf79d75..efecf9a08a 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 @@ -295,6 +295,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)); @@ -435,6 +440,7 @@ class _StreamMessageListViewState extends State { debouncedMarkThreadRead.cancel(); _unreadState.dispose(); _highlightState.dispose(); + _translationStore.dispose(); super.dispose(); } @@ -567,21 +573,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: widget.config.paginationLimit, - maximumMessageLimit: widget.config.maximumMessageLimit, - retentionTrimBuffer: widget.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: widget.config.paginationLimit, + maximumMessageLimit: widget.config.maximumMessageLimit, + retentionTrimBuffer: widget.config.retentionTrimBuffer, + messageFilter: widget.messageFilter, + loadingBuilder: defaultLoadingBuilder, + emptyBuilder: defaultEmptyBuilder, + messageListBuilder: defaultMessageListBuilder, + messageListController: _messageListController, + parentMessage: widget.parentMessage, + errorBuilder: defaultErrorBuilder, + ), ), ), ), @@ -1185,7 +1194,11 @@ 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. a + // translation's show-original toggle) stays attached to the + // correct message if the list reorders, 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_widget/components/stream_message_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart index b0c2872c57..6bd5ac3ef5 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 @@ -43,6 +43,7 @@ class StreamMessageContent extends StatefulWidget { this.onReactionTap, this.onQuotedMessageTap, this.reactionSorting, + this.showOriginalText = false, }); /// The message to display. @@ -120,6 +121,13 @@ class StreamMessageContent extends StatefulWidget { /// Passed through to [StreamMessageReactions.sorting]. final Comparator? reactionSorting; + /// Whether [message] should display its original text instead of a + /// translation, when [Message.i18n] has one for the current user's + /// language. + /// + /// Passed through to [StreamMessageText.showOriginalText]. + final bool showOriginalText; + @override State createState() => _StreamMessageContentState(); } @@ -198,6 +206,7 @@ class _StreamMessageContentState extends State { onLinkTap: widget.onLinkTap, onMentionTap: widget.onMentionTap, onAnyMentionTap: widget.onAnyMentionTap, + showOriginalText: widget.showOriginalText, ), ], ), 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..5e2e489158 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 showOriginalText = false, + VoidCallback? onToggleOriginalText, }) : props = .new( message: message, onViewChannelTap: onViewChannelTap, + showOriginalText: showOriginalText, + onToggleOriginalText: onToggleOriginalText, ); /// Creates a message header from pre-built [props]. @@ -54,6 +60,8 @@ class StreamMessageHeaderProps { const StreamMessageHeaderProps({ required this.message, this.onViewChannelTap, + this.showOriginalText = false, + this.onToggleOriginalText, }); /// 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 displayed in its original language, + /// despite a translation being available in [Message.i18n]. + /// + /// Only relevant when a translation exists for the current user's + /// language; ignored otherwise. Toggled via [onToggleOriginalText]. + final bool showOriginalText; + + /// 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? onToggleOriginalText; + /// Returns a copy of this [StreamMessageHeaderProps] with the given fields /// replaced with new values. StreamMessageHeaderProps copyWith({ Message? message, VoidCallback? onViewChannelTap, + bool? showOriginalText, + VoidCallback? onToggleOriginalText, }) { return StreamMessageHeaderProps( message: message ?? this.message, onViewChannelTap: onViewChannelTap ?? this.onViewChannelTap, + showOriginalText: showOriginalText ?? this.showOriginalText, + onToggleOriginalText: onToggleOriginalText ?? this.onToggleOriginalText, ); } } /// 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. Reads "Translated from {language}" when the +/// original language is known, otherwise plain "Translated". Includes a +/// "Show original"/"Show translation" link that invokes +/// [StreamMessageHeaderProps.onToggleOriginalText]. /// /// 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(); + + // `Localizations.localeOf` 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); + final deviceLanguage = Localizations.localeOf(context).languageCode; + _language = _resolveLanguage(streamChat.currentUser?.language, deviceLanguage); + _languageSubscription ??= streamChat.currentUserStream + .map((it) => _resolveLanguage(it?.language, deviceLanguage)) + .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,40 @@ class DefaultStreamMessageHeader extends core.NullableStatelessWidget { ); } + Widget? translatedAnnotation; + final translationDisplayEnabled = StreamChatConfiguration.of(context).translationDisplayEnabled; + if (translationDisplayEnabled) { + // 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.showOriginalText) { + true => translations.originalLabel, + false => switch (message.i18n?['language']) { + null || '' => translations.translatedLabel, + final sourceLanguage => translations.translatedFromLanguageText(sourceLanguage), + }, + }; + final trailing = props.showOriginalText ? translations.showTranslationLabel : translations.showOriginalLabel; + + translatedAnnotation = core.StreamMessageAnnotation( + onTap: props.onToggleOriginalText, + 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; @@ -172,3 +266,10 @@ class DefaultStreamMessageHeader extends core.NullableStatelessWidget { ); } } + +// Stream's API defaults `User.language` to `''` rather than omitting it, so +// an empty string must fall back the same way a missing value does. +String _resolveLanguage(String? language, String fallback) => switch (language) { + null || '' => fallback, + final language => language, +}; 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 de532bb054..8f14048f8f 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 @@ -2,6 +2,7 @@ import 'package:flutter/material.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'; @@ -12,7 +13,10 @@ 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 [showOriginalText] to display the original text instead, +/// even when a translation is available. Automatic translation can be +/// turned off SDK-wide via +/// [StreamChatConfigurationData.translationDisplayEnabled]. /// /// The widget rebuilds automatically when the current user's language /// changes, ensuring the displayed text stays in sync. @@ -30,11 +34,16 @@ class StreamMessageText extends StatelessWidget { this.onLinkTap, this.onMentionTap, this.onAnyMentionTap, + this.showOriginalText = false, }); /// The message whose text to display. final Message message; + /// Whether to display [message]'s original text instead of a translation, + /// when [Message.i18n] has one for the current user's language. + final bool showOriginalText; + /// Called when a link in the rendered markdown is tapped. /// /// If null, tapping a link has no effect. @@ -62,12 +71,15 @@ class StreamMessageText extends StatelessWidget { @override Widget build(BuildContext context) { final streamChat = StreamChat.of(context); + final deviceLanguage = Localizations.localeOf(context).languageCode; + final translationDisplayEnabled = StreamChatConfiguration.of(context).translationDisplayEnabled; return BetterStreamBuilder( - initialData: streamChat.currentUser?.language ?? 'en', - stream: streamChat.currentUserStream.map((it) => it?.language ?? 'en'), + initialData: _resolveLanguage(streamChat.currentUser?.language, deviceLanguage), + stream: streamChat.currentUserStream.map((it) => _resolveLanguage(it?.language, deviceLanguage)), builder: (context, language) { - final messageText = message.translate(language).replaceMentions().text?.replaceAll('\n', '\n\n').trim(); + final translated = (showOriginalText || !translationDisplayEnabled) ? message : message.translate(language); + final messageText = translated.replaceMentions().text?.replaceAll('\n', '\n\n').trim(); if (messageText == null || messageText.trim().isEmpty) return const Empty(); @@ -82,3 +94,10 @@ class StreamMessageText extends StatelessWidget { ); } } + +// Stream's API defaults `User.language` to `''` rather than omitting it, so +// an empty string must fall back the same way a missing value does. +String _resolveLanguage(String? language, String fallback) => switch (language) { + null || '' => fallback, + final language => language, +}; 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 0c80b56e1b..e59cf50b51 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 @@ -454,6 +454,9 @@ class DefaultStreamMessageItem extends StatelessWidget { Widget build(BuildContext context) { final message = props.message; + final translationStore = StreamMessageTranslations.of(context); + final showOriginalText = translationStore?.isShowingOriginalText(message.id) ?? false; + final placement = StreamMessageLayout.of(context); final theme = core.StreamMessageItemTheme.of(context); final defaults = _StreamMessageItemDefaults( @@ -492,6 +495,11 @@ class DefaultStreamMessageItem extends StatelessWidget { final onTap? => () => onTap(message), _ => () => _onViewThread(context, message), }, + showOriginalText: showOriginalText, + onToggleOriginalText: switch (translationStore) { + final store? => () => store.toggleOriginalText(message.id), + null => null, + }, ), ); @@ -527,6 +535,7 @@ class DefaultStreamMessageItem extends StatelessWidget { attachmentBuilders: props.attachmentBuilders, reactionSorting: props.reactionSorting, onQuotedMessageTap: props.onQuotedMessageTap, + showOriginalText: showOriginalText, onLinkTap: (_, href, __) { if (href == null) return; if (props.onMessageLinkTap case final onTap?) return onTap(message, href); 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..a0d935016b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_translation_store.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; + +/// Tracks, for messages within a single message list, which ones are +/// currently showing their original text instead of a translation. +/// +/// 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 ChangeNotifier { + final _originalTextMessageIds = {}; + + /// Whether [messageId] is currently showing its original text instead of + /// a translation. + bool isShowingOriginalText(String messageId) => _originalTextMessageIds.contains(messageId); + + /// Switches [messageId] between showing its original text and its + /// translation. + void toggleOriginalText(String messageId) { + if (!_originalTextMessageIds.remove(messageId)) { + _originalTextMessageIds.add(messageId); + } + notifyListeners(); + } +} + +/// 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 f8958c6ffa..e132feff6d 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -165,6 +165,7 @@ class StreamChatConfigurationData { List? attachmentBuilders, StreamReactionsType? reactionType, StreamReactionsPosition? reactionPosition, + bool translationDisplayEnabled = true, }) { return StreamChatConfigurationData._( reactionIconResolver: reactionIconResolver ?? const DefaultReactionIconResolver(), @@ -175,6 +176,7 @@ class StreamChatConfigurationData { attachmentBuilders: attachmentBuilders, reactionType: reactionType, reactionPosition: reactionPosition, + translationDisplayEnabled: translationDisplayEnabled, ); } @@ -185,6 +187,7 @@ class StreamChatConfigurationData { required this.messagePreviewFormatter, required this.imageCDN, required this.attachmentBuilders, + required this.translationDisplayEnabled, this.reactionType, this.reactionPosition, }); @@ -200,6 +203,7 @@ class StreamChatConfigurationData { List? attachmentBuilders, StreamReactionsType? reactionType, StreamReactionsPosition? reactionPosition, + bool? translationDisplayEnabled, }) { return StreamChatConfigurationData( reactionIconResolver: reactionIconResolver ?? this.reactionIconResolver, @@ -210,6 +214,7 @@ class StreamChatConfigurationData { attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, reactionType: reactionType ?? this.reactionType, reactionPosition: reactionPosition ?? this.reactionPosition, + translationDisplayEnabled: translationDisplayEnabled ?? this.translationDisplayEnabled, ); } @@ -258,4 +263,14 @@ class StreamChatConfigurationData { /// When null, the widget resolves its own default /// ([StreamReactionsPosition.header]). final StreamReactionsPosition? reactionPosition; + + /// Whether a message automatically displays as its translation — and + /// shows a "Translated" annotation with a link to see the original — + /// when [Message.i18n] has one for the current user's language. + /// + /// Set to `false` to always show a message's original text regardless of + /// available translations. + /// + /// Defaults to `true`. + final bool translationDisplayEnabled; } diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 6a1c377bcd..1abb3ed776 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -98,6 +98,7 @@ 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_message_item.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_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..1812907e05 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 de ${_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', +}; From c7f514ab4dd647f8e57b5d15719317625d444ede Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 13:28:23 +0200 Subject: [PATCH 02/18] fix(ui): fall back to device locale when a user's language is empty in message previews Stream's API defaults User.language to '' rather than omitting it, so the previous `language ?? currentUser?.language ?? 'en'` fallback never reached its 'en' default and previews silently rendered untranslated. Falls back to the device locale instead, matching the behavior already used elsewhere for message text and headers. --- .../lib/src/channel/stream_message_preview_text.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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..d22c8de7a2 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 @@ -34,7 +34,15 @@ class StreamMessagePreviewText extends StatelessWidget { @override Widget build(BuildContext context) { final currentUser = StreamChat.maybeOf(context)?.currentUser; - final translationLanguage = language ?? currentUser?.language ?? 'en'; + // Stream's API defaults `User.language` to `''` rather than omitting + // it, so an empty string must fall back the same way a missing value + // does. + final translationLanguage = + language ?? + switch (currentUser?.language) { + null || '' => Localizations.localeOf(context).languageCode, + final userLanguage => userLanguage, + }; final translatedMessage = message.translate(translationLanguage); final previewMessage = translatedMessage.replaceMentions(linkify: false); From 2cce32321eb1b5503cc20fb9a76b95e61ee754d7 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 13:28:34 +0200 Subject: [PATCH 03/18] feat(samples): add an on-request "Translate" message action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Translate action to the message long-press menu, gated behind a new "Message Translation" toggle in the sample app's configuration screen (disabled by default). Calls channel.translateMessage and merges the result into local channel state, so the SDK's own translation annotation and message rendering pick it up automatically. Only shown for received messages — translating your own message isn't useful — and always translates directly to the user's language (falling back to the device locale), with no language picker. Disabled once the message is already translated to that language, or the original text is already in it. --- sample_app/lib/config/sample_app_config.dart | 11 +++ .../lib/config/sample_app_config_screen.dart | 7 ++ .../lib/widgets/custom_message_actions.dart | 84 ++++++++++++++++++- 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/sample_app/lib/config/sample_app_config.dart b/sample_app/lib/config/sample_app_config.dart index 05fd984366..365dae7494 100644 --- a/sample_app/lib/config/sample_app_config.dart +++ b/sample_app/lib/config/sample_app_config.dart @@ -13,6 +13,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'; @@ -44,6 +45,7 @@ class SampleAppConfigData { bool enableReminderActions = false, bool enableDeleteForMe = false, bool enableMessageInfo = false, + bool enableMessageTranslation = false, bool enableLocationSharing = false, bool draftMessagesEnabled = true, bool enforceUniqueReactions = false, @@ -58,6 +60,7 @@ class SampleAppConfigData { enableReminderActions: enableReminderActions, enableDeleteForMe: enableDeleteForMe, enableMessageInfo: enableMessageInfo, + enableMessageTranslation: enableMessageTranslation, enableLocationSharing: enableLocationSharing, draftMessagesEnabled: draftMessagesEnabled, enforceUniqueReactions: enforceUniqueReactions, @@ -75,6 +78,7 @@ class SampleAppConfigData { required this.enableReminderActions, required this.enableDeleteForMe, required this.enableMessageInfo, + required this.enableMessageTranslation, required this.enableLocationSharing, required this.draftMessagesEnabled, required this.enforceUniqueReactions, @@ -93,6 +97,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(), @@ -125,6 +130,9 @@ class SampleAppConfigData { /// Whether the "Message Info" action appears in the message context menu. final bool enableMessageInfo; + /// Whether the "Translate" action appears in the message context menu. + final bool enableMessageTranslation; + /// Whether location sharing (attachment builder + picker) is enabled. final bool enableLocationSharing; @@ -156,6 +164,7 @@ class SampleAppConfigData { bool? enableReminderActions, bool? enableDeleteForMe, bool? enableMessageInfo, + bool? enableMessageTranslation, bool? enableLocationSharing, bool? draftMessagesEnabled, bool? enforceUniqueReactions, @@ -170,6 +179,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, @@ -191,6 +201,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 9a9f3d7b51..c7d47aca75 100644 --- a/sample_app/lib/config/sample_app_config_screen.dart +++ b/sample_app/lib/config/sample_app_config_screen.dart @@ -102,6 +102,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: 'Translate a message 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/widgets/custom_message_actions.dart b/sample_app/lib/widgets/custom_message_actions.dart index 593c8123c9..4f8714b7e8 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) +/// → _TranslateAction (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), + ..._TranslateAction.build(context, message), ], ); }, @@ -201,3 +204,82 @@ 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 +/// [BuildContext.translationUserLanguage], with no language picker. +abstract final class _TranslateAction { + 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 []; + + final userLanguage = context.translationUserLanguage; + + // 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 user's language — + // either way, there's nothing left to translate. + final alreadyTranslated = message.i18n?['${userLanguage}_text'] != null; + + final icons = context.streamIcons; + return [ + StreamContextMenuAction( + label: const Text('Translate'), + leading: Icon(icons.translate), + enabled: !alreadyTranslated, + onTap: () => _translate(context, message, userLanguage), + ), + ]; + } + + static Future _translate( + 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; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to translate message: $e')), + ); + } + } +} + +/// Convenient access to the language translations should target for the +/// current user. +extension _TranslationUserLanguageExtension on BuildContext { + /// The language translations should target for the current user: + /// [User.language] when set to a non-empty value, otherwise the app's + /// locale. + String get translationUserLanguage { + final currentUser = StreamChat.of(this).currentUser; + return switch (currentUser?.language) { + null || '' => Localizations.localeOf(this).languageCode, + final language => language, + }; + } +} From 23dd3eb9772a357f04c2973fa3dd62d5dde2847b Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 13:53:14 +0200 Subject: [PATCH 04/18] fix(ui): respect translationDisplayEnabled in message previews StreamMessagePreviewText translated unconditionally, unlike StreamMessageText which already gates translation behind translationDisplayEnabled. This left the channel list, thread list, search results, quoted messages, and the reply-preview header showing translated text even when the flag was disabled SDK-wide. --- .../channel/stream_message_preview_text.dart | 9 +++++++-- .../stream_message_preview_text_test.dart | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) 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 d22c8de7a2..e91046cd7a 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 +/// [StreamChatConfigurationData.translationDisplayEnabled] — matching the +/// same opt-out [StreamMessageText] respects for the full message bubble. class StreamMessagePreviewText extends StatelessWidget { /// Creates a new instance of [StreamMessagePreviewText]. const StreamMessagePreviewText({ @@ -43,10 +48,10 @@ class StreamMessagePreviewText extends StatelessWidget { null || '' => Localizations.localeOf(context).languageCode, final userLanguage => userLanguage, }; - final translatedMessage = message.translate(translationLanguage); + final config = StreamChatConfiguration.of(context); + final translatedMessage = config.translationDisplayEnabled ? 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/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..b2429ca2e2 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,26 @@ void main() { expect(find.text('Hello, world!'), findsOneWidget); }); + + testWidgets('shows original text when translationDisplayEnabled is false', (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(translationDisplayEnabled: false), + ); + + expect(find.text('Hello, world!'), findsOneWidget); + expect(find.text('Bonjour, monde!'), findsNothing); + }); }); group('Custom MessagePreviewFormatter', () { From ea36a36151c836c4c2b8089f97c2de4514fc0932 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 14:00:20 +0200 Subject: [PATCH 05/18] docs(ui): correct stale rationale for the message-item ValueKey The comment attributed the key to protecting a translation show-original toggle, which moved into StreamMessageTranslationStore (keyed externally by message id) and no longer needs it. The key is still required though: other per-message StatefulWidgets in this subtree (content, attachments, leading) would otherwise have their State reused for the wrong message when pagination prepends older messages and shifts every already-rendered item's index. --- .../lib/src/message_list_view/message_list_view.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 efecf9a08a..d7a962b97a 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 @@ -1194,10 +1194,11 @@ class _StreamMessageListViewState extends State { child: Builder( builder: (context) => switch (widget.messageBuilder) { final builder? => builder.call(context, message, messageItemProps), - // Keyed by message id so per-message local state (e.g. a - // translation's show-original toggle) stays attached to the - // correct message if the list reorders, rather than leaking onto - // whatever message next occupies the same position. + // 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), }, ), From b861fb40d02635f5e50067a631b1354cbb2c9396 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 15:18:13 +0200 Subject: [PATCH 06/18] fix(ui): keep the message-actions preview in sync with the translation toggle The long-press message-actions modal re-renders the message in a fresh StreamMessageItem on top of the scrim, outside the StreamMessageListView subtree that owns the StreamMessageTranslationStore. Without re-providing that scope, the preview always showed the translation regardless of the list's toggle state, and its "Show original"/"Show translation" link had no effect. Captures the ambient store (if any) before opening the dialog and re-provides it around the nested StreamMessageItem, matching how StreamChatConfiguration and StreamChannel are already re-provided for the same modal. --- .../message_widget/stream_message_item.dart | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) 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 e59cf50b51..c0e22f5c03 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 @@ -853,6 +853,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, @@ -869,12 +884,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, ), ), ), From 3961cd48aa6684b7265c1a1a6b2490502e48fe18 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 15:18:24 +0200 Subject: [PATCH 07/18] fix(ui): resolve thread list preview language when it's empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamThreadListTile passed currentUser?.language straight through to StreamMessagePreviewText's language parameter, bypassing that widget's own null-or-empty-string fallback to the device locale. Stream's API defaults User.language to '' rather than omitting it, so thread previews silently stopped translating for exactly the case the preview-text fix already handled elsewhere. Also extracts the "null or empty string falls back to the device locale" logic into a single UserX.languageOrDeviceLocale extension. It was previously duplicated as two identical _resolveLanguage private functions (stream_message_header.dart, stream_message_text.dart) plus two more inlined switch expressions (stream_message_preview_text.dart, the sample app's custom_message_actions.dart) — all doing the same thing. --- .../channel/stream_message_preview_text.dart | 10 +------ .../components/stream_message_header.dart | 27 +++++++------------ .../components/stream_message_text.dart | 12 ++------- .../stream_thread_list_tile.dart | 2 +- .../lib/src/utils/extensions.dart | 14 ++++++++++ .../lib/widgets/custom_message_actions.dart | 8 +----- 6 files changed, 28 insertions(+), 45 deletions(-) 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 e91046cd7a..26356c3690 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 @@ -39,15 +39,7 @@ class StreamMessagePreviewText extends StatelessWidget { @override Widget build(BuildContext context) { final currentUser = StreamChat.maybeOf(context)?.currentUser; - // Stream's API defaults `User.language` to `''` rather than omitting - // it, so an empty string must fall back the same way a missing value - // does. - final translationLanguage = - language ?? - switch (currentUser?.language) { - null || '' => Localizations.localeOf(context).languageCode, - final userLanguage => userLanguage, - }; + final translationLanguage = language ?? currentUser.languageOrDeviceLocale(context); final config = StreamChatConfiguration.of(context); final translatedMessage = config.translationDisplayEnabled ? message.translate(translationLanguage) : message; final previewMessage = translatedMessage.replaceMentions(linkify: false); 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 5e2e489158..adb9d9eb1a 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 @@ -140,18 +140,16 @@ class _DefaultStreamMessageHeaderState extends core.NullableState _resolveLanguage(it?.language, deviceLanguage)) - .listen((language) { - if (language == _language) return; - setState(() => _language = language); - }); + _language = streamChat.currentUser.languageOrDeviceLocale(context); + final languageStream = streamChat.currentUserStream.map((it) => it.languageOrDeviceLocale(context)); + _languageSubscription ??= languageStream.listen((language) { + if (language == _language) return; + setState(() => _language = language); + }); } @override @@ -266,10 +264,3 @@ class _DefaultStreamMessageHeaderState extends core.NullableState switch (language) { - null || '' => fallback, - final language => language, -}; 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 8f14048f8f..62dd745a0d 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 @@ -71,12 +71,11 @@ class StreamMessageText extends StatelessWidget { @override Widget build(BuildContext context) { final streamChat = StreamChat.of(context); - final deviceLanguage = Localizations.localeOf(context).languageCode; final translationDisplayEnabled = StreamChatConfiguration.of(context).translationDisplayEnabled; return BetterStreamBuilder( - initialData: _resolveLanguage(streamChat.currentUser?.language, deviceLanguage), - stream: streamChat.currentUserStream.map((it) => _resolveLanguage(it?.language, deviceLanguage)), + initialData: streamChat.currentUser.languageOrDeviceLocale(context), + stream: streamChat.currentUserStream.map((it) => it.languageOrDeviceLocale(context)), builder: (context, language) { final translated = (showOriginalText || !translationDisplayEnabled) ? message : message.translate(language); final messageText = translated.replaceMentions().text?.replaceAll('\n', '\n\n').trim(); @@ -94,10 +93,3 @@ class StreamMessageText extends StatelessWidget { ); } } - -// Stream's API defaults `User.language` to `''` rather than omitting it, so -// an empty string must fall back the same way a missing value does. -String _resolveLanguage(String? language, String fallback) => switch (language) { - null || '' => fallback, - final language => language, -}; diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart index e4c60d6cc8..5aa34c8873 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -97,7 +97,7 @@ class _DefaultStreamThreadListTile extends StatelessWidget { final parentMessage = thread.parentMessage; final latestReply = thread.latestReplies.lastOrNull; final channel = thread.channel; - final language = currentUser?.language; + final language = currentUser.languageOrDeviceLocale(context); final unreadMessageCount = thread.read?.firstWhereOrNull((read) => read.user.id == currentUser?.id)?.unreadMessages; final latestActivityAt = thread.lastMessageAt ?? latestReply?.createdAt ?? thread.updatedAt; final avatarUser = parentMessage?.user ?? latestReply?.user ?? thread.createdBy; diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 31c92963a8..712de3f107 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -312,6 +312,20 @@ extension IconButtonX on IconButton { } } +/// Extensions on a nullable [User]. +extension UserX on User? { + /// The language to use when translating content for this user: + /// [User.language] when set to a non-empty value, otherwise the device's + /// locale. + /// + /// Stream's API defaults [User.language] to `''` rather than omitting it, + /// so an empty string is treated the same as a missing value. + String languageOrDeviceLocale(BuildContext context) => switch (this?.language) { + null || '' => Localizations.localeOf(context).languageCode, + final language => language, + }; +} + /// Extensions on List extension UserListX on List { /// It does an search on a list of [User] and returns users with diff --git a/sample_app/lib/widgets/custom_message_actions.dart b/sample_app/lib/widgets/custom_message_actions.dart index 4f8714b7e8..2c0ce97469 100644 --- a/sample_app/lib/widgets/custom_message_actions.dart +++ b/sample_app/lib/widgets/custom_message_actions.dart @@ -275,11 +275,5 @@ extension _TranslationUserLanguageExtension on BuildContext { /// The language translations should target for the current user: /// [User.language] when set to a non-empty value, otherwise the app's /// locale. - String get translationUserLanguage { - final currentUser = StreamChat.of(this).currentUser; - return switch (currentUser?.language) { - null || '' => Localizations.localeOf(this).languageCode, - final language => language, - }; - } + String get translationUserLanguage => StreamChat.of(this).currentUser.languageOrDeviceLocale(this); } From 5d72c450232c8bbea9599b305879f18ed9ba860f Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 16:01:02 +0200 Subject: [PATCH 08/18] add missing translations in localizations example --- .../example/lib/add_new_lang.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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!'; From 855ba685de36e369fcb1b2de748d700974c944a1 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 16:15:15 +0200 Subject: [PATCH 09/18] updated Spanish localization --- .../lib/src/stream_chat_localizations_es.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1812907e05..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 @@ -810,7 +810,7 @@ No es posible añadir más de $limit archivos adjuntos String get showTranslationLabel => 'Ver traducción'; @override - String translatedFromLanguageText(String languageCode) => 'Traducido de ${_languageNameEs(languageCode)}'; + String translatedFromLanguageText(String languageCode) => 'Traducido del ${_languageNameEs(languageCode)}'; @override String get createPollPromptLabel => '¡Crea una encuesta y deja que todos voten!'; From 970c5a947583e7c9202d164fcea45c4833bc62ff Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 6 Aug 2026 16:58:35 +0200 Subject: [PATCH 10/18] remove device fallback on user language --- .../channel/stream_message_preview_text.dart | 2 +- .../components/stream_message_header.dart | 60 ++++++++++--------- .../components/stream_message_text.dart | 15 +++-- .../stream_thread_list_tile.dart | 2 +- .../lib/src/utils/extensions.dart | 25 +++----- .../lib/widgets/custom_message_actions.dart | 17 ++---- 6 files changed, 58 insertions(+), 63 deletions(-) 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 26356c3690..cc0f85e284 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 @@ -39,7 +39,7 @@ class StreamMessagePreviewText extends StatelessWidget { @override Widget build(BuildContext context) { final currentUser = StreamChat.maybeOf(context)?.currentUser; - final translationLanguage = language ?? currentUser.languageOrDeviceLocale(context); + final translationLanguage = language ?? currentUser?.language; final config = StreamChatConfiguration.of(context); final translatedMessage = config.translationDisplayEnabled ? message.translate(translationLanguage) : message; final previewMessage = translatedMessage.replaceMentions(linkify: false); 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 adb9d9eb1a..9878b39eb0 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 @@ -133,19 +133,19 @@ class DefaultStreamMessageHeader extends core.NullableStatefulWidget { } class _DefaultStreamMessageHeaderState extends core.NullableState { - late String _language; - StreamSubscription? _languageSubscription; + late String? _language; + StreamSubscription? _languageSubscription; @override void didChangeDependencies() { super.didChangeDependencies(); - // `Localizations.localeOf` (via `languageOrDeviceLocale`) registers an - // inherited-widget dependency, so this (and the `currentUserStream` - // subscription it seeds) must happen here rather than in `initState`. + // `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.languageOrDeviceLocale(context); - final languageStream = streamChat.currentUserStream.map((it) => it.languageOrDeviceLocale(context)); + _language = streamChat.currentUser?.language; + final languageStream = streamChat.currentUserStream.map((it) => it?.language); _languageSubscription ??= languageStream.listen((language) { if (language == _language) return; setState(() => _language = language); @@ -221,29 +221,33 @@ class _DefaultStreamMessageHeaderState extends core.NullableState translations.originalLabel, - false => switch (message.i18n?['language']) { - null || '' => translations.translatedLabel, - final sourceLanguage => translations.translatedFromLanguageText(sourceLanguage), - }, - }; - final trailing = props.showOriginalText ? translations.showTranslationLabel : translations.showOriginalLabel; + 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.showOriginalText) { + true => translations.originalLabel, + false => switch (message.i18n?['language']) { + null || '' => translations.translatedLabel, + final sourceLanguage => translations.translatedFromLanguageText(sourceLanguage), + }, + }; + final trailing = props.showOriginalText ? translations.showTranslationLabel : translations.showOriginalLabel; - translatedAnnotation = core.StreamMessageAnnotation( - onTap: props.onToggleOriginalText, - leading: Icon(icons.translate), - label: Text('$label ·'), - trailing: Text(trailing), - style: .from(trailingTextColor: linkColor), - ); + translatedAnnotation = core.StreamMessageAnnotation( + onTap: props.onToggleOriginalText, + leading: Icon(icons.translate), + label: Text('$label ·'), + trailing: Text(trailing), + style: .from(trailingTextColor: linkColor), + ); + } } } 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 62dd745a0d..474bda8e0a 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 @@ -73,11 +73,16 @@ class StreamMessageText extends StatelessWidget { final streamChat = StreamChat.of(context); final translationDisplayEnabled = StreamChatConfiguration.of(context).translationDisplayEnabled; - return BetterStreamBuilder( - initialData: streamChat.currentUser.languageOrDeviceLocale(context), - stream: streamChat.currentUserStream.map((it) => it.languageOrDeviceLocale(context)), - builder: (context, language) { - final translated = (showOriginalText || !translationDisplayEnabled) ? message : message.translate(language); + // `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 = (showOriginalText || !translationDisplayEnabled) + ? message + : message.translate(data.language); 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/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart index 5aa34c8873..e4c60d6cc8 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -97,7 +97,7 @@ class _DefaultStreamThreadListTile extends StatelessWidget { final parentMessage = thread.parentMessage; final latestReply = thread.latestReplies.lastOrNull; final channel = thread.channel; - final language = currentUser.languageOrDeviceLocale(context); + final language = currentUser?.language; final unreadMessageCount = thread.read?.firstWhereOrNull((read) => read.user.id == currentUser?.id)?.unreadMessages; final latestActivityAt = thread.lastMessageAt ?? latestReply?.createdAt ?? thread.updatedAt; final avatarUser = parentMessage?.user ?? latestReply?.user ?? thread.createdBy; diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 712de3f107..dfaa6bfb39 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -312,20 +312,6 @@ extension IconButtonX on IconButton { } } -/// Extensions on a nullable [User]. -extension UserX on User? { - /// The language to use when translating content for this user: - /// [User.language] when set to a non-empty value, otherwise the device's - /// locale. - /// - /// Stream's API defaults [User.language] to `''` rather than omitting it, - /// so an empty string is treated the same as a missing value. - String languageOrDeviceLocale(BuildContext context) => switch (this?.language) { - null || '' => Localizations.localeOf(context).languageCode, - final language => language, - }; -} - /// Extensions on List extension UserListX on List { /// It does an search on a list of [User] and returns users with @@ -473,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/sample_app/lib/widgets/custom_message_actions.dart b/sample_app/lib/widgets/custom_message_actions.dart index 2c0ce97469..65067616fa 100644 --- a/sample_app/lib/widgets/custom_message_actions.dart +++ b/sample_app/lib/widgets/custom_message_actions.dart @@ -217,8 +217,9 @@ abstract final class _MessageInfoAction { /// 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 -/// [BuildContext.translationUserLanguage], with no language picker. +/// 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 server-side, rather than guessing a target language. abstract final class _TranslateAction { static List build( BuildContext context, @@ -232,7 +233,8 @@ abstract final class _TranslateAction { final isSentByCurrentUser = message.user?.id == currentUser?.id; if (isSentByCurrentUser) return const []; - final userLanguage = context.translationUserLanguage; + final userLanguage = currentUser?.language; + if (userLanguage == null || userLanguage.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 @@ -268,12 +270,3 @@ abstract final class _TranslateAction { } } } - -/// Convenient access to the language translations should target for the -/// current user. -extension _TranslationUserLanguageExtension on BuildContext { - /// The language translations should target for the current user: - /// [User.language] when set to a non-empty value, otherwise the app's - /// locale. - String get translationUserLanguage => StreamChat.of(this).currentUser.languageOrDeviceLocale(this); -} From 10651b5596b71d1bebda53ba4608f5c65a3e9ffc Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 20 Aug 2026 10:23:20 +0200 Subject: [PATCH 11/18] Added config for enabling translations --- packages/stream_chat_flutter/CHANGELOG.md | 4 +- .../channel/stream_message_preview_text.dart | 11 +- .../lib/src/message_list_view/mlv_utils.dart | 3 +- .../components/stream_message_content.dart | 13 +- .../components/stream_message_header.dart | 62 ++--- .../components/stream_message_text.dart | 23 +- .../message_widget/stream_message_item.dart | 9 +- .../stream_message_translation_store.dart | 26 ++- .../lib/src/stream_chat_configuration.dart | 82 ++++++- .../lib/stream_chat_flutter.dart | 3 + .../stream_message_preview_text_test.dart | 23 +- .../message_translation_annotation_test.dart | 220 ++++++++++++++++++ .../stream_message_content_test.dart | 1 - .../test/src/utils/extension_test.dart | 47 ++++ sample_app/lib/app.dart | 12 +- sample_app/lib/config/sample_app_config.dart | 8 +- .../lib/config/sample_app_config_screen.dart | 2 +- sample_app/lib/utils/app_config.dart | 14 ++ .../lib/widgets/custom_message_actions.dart | 31 +-- 19 files changed, 496 insertions(+), 98 deletions(-) create mode 100644 packages/stream_chat_flutter/test/src/message_widget/components/message_translation_annotation_test.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 86ffec5670..c1e04ea7df 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -8,7 +8,8 @@ - Added a `size` (`StreamLoadingSpinnerSize`) parameter to `StreamScrollViewLoadingWidget`. - 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). - 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. -- Added a "Translated"/"Original" message annotation, shown automatically when `Message.i18n` has a translation for the current user's language, with a "Show original"/"Show translation" link to toggle between the two. The toggle state is tracked per `StreamMessageListView` via the new `StreamMessageTranslationStore`, so it survives messages scrolling out of and back into view. `StreamMessageText` gained a `showOriginalText` parameter for direct control, and `StreamChatConfigurationData.translationDisplayEnabled` (default `true`) turns the whole feature off SDK-wide. +- Exported this package's `StreamMessageContent` and `StreamMessageText`, which were previously unreachable from `package:stream_chat_flutter/stream_chat_flutter.dart` — the barrel hid `stream_core_flutter`'s `StreamMessageContent` without exporting a replacement, and its `StreamMessageText` (a lower-level markdown renderer taking a raw `String`) resolved instead of the chat-specialized one. `StreamMessageText` from the barrel now means the chat version, which takes a `Message`; the core widget and its `emojiCount` helper are still available as `StreamMessageText` from `package:stream_core_flutter/chat.dart`, alongside the already-exported `DefaultStreamMessageText` and `StreamMessageTextProps`. +- Added `StreamChatConfigurationData.messageTranslation`, a `StreamMessageTranslationConfiguration` that collects every message-translation setting: `enabled` (default `true`) controls whether messages display a translation from `Message.i18n` at all, `annotationEnabled` (default `false`) opts into a "Translated"/"Original" annotation with a "Show original"/"Show translation" link that switches between the two. The defaults preserve the SDK's existing behaviour — translations are shown, silently — so the annotation is opt-in. Its toggle state is tracked per `StreamMessageListView` via the new `StreamMessageTranslationStore`, so it survives messages scrolling out of and back into view, and can be driven directly through `showTranslatedText`/`onToggleTranslatedText` on `StreamMessageHeaderProps`, `StreamMessageContent`, and `StreamMessageText`. ⚠️ Deprecated @@ -19,6 +20,7 @@ 🐞 Fixed +- Fixed message text and previews translating to English for users with no `User.language` set. They now show the original text, matching the backend, which only auto-translates for users that have a language. - 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. - Fixed `StreamTypingIndicator` briefly showing typing users from a different context (main channel vs. thread) on its first frame. 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 cc0f85e284..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 @@ -5,8 +5,8 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// The preview is translated into the current user's language when /// [Message.i18n] has one, unless disabled SDK-wide via -/// [StreamChatConfigurationData.translationDisplayEnabled] — matching the -/// same opt-out [StreamMessageText] respects for the full message bubble. +/// [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({ @@ -25,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. @@ -39,9 +41,10 @@ class StreamMessagePreviewText extends StatelessWidget { @override Widget build(BuildContext context) { final currentUser = StreamChat.maybeOf(context)?.currentUser; - final translationLanguage = language ?? currentUser?.language; final config = StreamChatConfiguration.of(context); - final translatedMessage = config.translationDisplayEnabled ? message.translate(translationLanguage) : message; + final translationConfig = config.messageTranslation; + final translationLanguage = language ?? currentUser?.language; + final translatedMessage = translationConfig.enabled ? message.translate(translationLanguage) : message; final previewMessage = translatedMessage.replaceMentions(linkify: false); final formatter = config.messagePreviewFormatter; 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 5977cbefe5..a6091cc3bb 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. @@ -157,7 +158,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 6bd5ac3ef5..0028bb4d87 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 @@ -43,7 +43,7 @@ class StreamMessageContent extends StatefulWidget { this.onReactionTap, this.onQuotedMessageTap, this.reactionSorting, - this.showOriginalText = false, + this.showTranslatedText = true, }); /// The message to display. @@ -121,12 +121,11 @@ class StreamMessageContent extends StatefulWidget { /// Passed through to [StreamMessageReactions.sorting]. final Comparator? reactionSorting; - /// Whether [message] should display its original text instead of a - /// translation, when [Message.i18n] has one for the current user's - /// language. + /// Whether [message] should display its translation when [Message.i18n] + /// has one for the current user's language. /// - /// Passed through to [StreamMessageText.showOriginalText]. - final bool showOriginalText; + /// Passed through to [StreamMessageText.showTranslatedText]. + final bool showTranslatedText; @override State createState() => _StreamMessageContentState(); @@ -206,7 +205,7 @@ class _StreamMessageContentState extends State { onLinkTap: widget.onLinkTap, onMentionTap: widget.onMentionTap, onAnyMentionTap: widget.onAnyMentionTap, - showOriginalText: widget.showOriginalText, + 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 9878b39eb0..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 @@ -26,13 +26,13 @@ class StreamMessageHeader extends core.NullableStatelessWidget { super.key, required Message message, VoidCallback? onViewChannelTap, - bool showOriginalText = false, - VoidCallback? onToggleOriginalText, + bool showTranslatedText = true, + VoidCallback? onToggleTranslatedText, }) : props = .new( message: message, onViewChannelTap: onViewChannelTap, - showOriginalText: showOriginalText, - onToggleOriginalText: onToggleOriginalText, + showTranslatedText: showTranslatedText, + onToggleTranslatedText: onToggleTranslatedText, ); /// Creates a message header from pre-built [props]. @@ -60,8 +60,8 @@ class StreamMessageHeaderProps { const StreamMessageHeaderProps({ required this.message, this.onViewChannelTap, - this.showOriginalText = false, - this.onToggleOriginalText, + this.showTranslatedText = true, + this.onToggleTranslatedText, }); /// The message whose annotations to display. @@ -70,33 +70,33 @@ class StreamMessageHeaderProps { /// Called when the "View" link in the show-in-channel annotation is tapped. final VoidCallback? onViewChannelTap; - /// Whether [message] is currently displayed in its original language, - /// despite a translation being available in [Message.i18n]. + /// 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 [onToggleOriginalText]. - final bool showOriginalText; + /// 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? onToggleOriginalText; + final VoidCallback? onToggleTranslatedText; /// Returns a copy of this [StreamMessageHeaderProps] with the given fields /// replaced with new values. StreamMessageHeaderProps copyWith({ Message? message, VoidCallback? onViewChannelTap, - bool? showOriginalText, - VoidCallback? onToggleOriginalText, + bool? showTranslatedText, + VoidCallback? onToggleTranslatedText, }) { return StreamMessageHeaderProps( message: message ?? this.message, onViewChannelTap: onViewChannelTap ?? this.onViewChannelTap, - showOriginalText: showOriginalText ?? this.showOriginalText, - onToggleOriginalText: onToggleOriginalText ?? this.onToggleOriginalText, + showTranslatedText: showTranslatedText ?? this.showTranslatedText, + onToggleTranslatedText: onToggleTranslatedText ?? this.onToggleTranslatedText, ); } } @@ -114,10 +114,12 @@ class StreamMessageHeaderProps { /// 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. Reads "Translated from {language}" when the -/// original language is known, otherwise plain "Translated". Includes a -/// "Show original"/"Show translation" link that invokes -/// [StreamMessageHeaderProps.onToggleOriginalText]. +/// 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. @@ -220,10 +222,12 @@ class _DefaultStreamMessageHeaderState extends core.NullableState translations.originalLabel, - false => switch (message.i18n?['language']) { + final label = switch (props.showTranslatedText) { + false => translations.originalLabel, + true => switch (message.i18n?['language']) { null || '' => translations.translatedLabel, final sourceLanguage => translations.translatedFromLanguageText(sourceLanguage), }, }; - final trailing = props.showOriginalText ? translations.showTranslationLabel : translations.showOriginalLabel; + final trailing = props.showTranslatedText + ? translations.showOriginalLabel + : translations.showTranslationLabel; translatedAnnotation = core.StreamMessageAnnotation( - onTap: props.onToggleOriginalText, + onTap: props.onToggleTranslatedText, leading: Icon(icons.translate), label: Text('$label ·'), trailing: Text(trailing), 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 474bda8e0a..1debce96cb 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 @@ -13,10 +13,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. Pass [showOriginalText] to display the original text instead, +/// markdown. Pass `showTranslatedText: false` to display the original text /// even when a translation is available. Automatic translation can be -/// turned off SDK-wide via -/// [StreamChatConfigurationData.translationDisplayEnabled]. +/// 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. @@ -34,15 +33,17 @@ class StreamMessageText extends StatelessWidget { this.onLinkTap, this.onMentionTap, this.onAnyMentionTap, - this.showOriginalText = false, + this.showTranslatedText = true, }); /// The message whose text to display. final Message message; - /// Whether to display [message]'s original text instead of a translation, - /// when [Message.i18n] has one for the current user's language. - final bool showOriginalText; + /// 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. /// @@ -71,7 +72,7 @@ class StreamMessageText extends StatelessWidget { @override Widget build(BuildContext context) { final streamChat = StreamChat.of(context); - final translationDisplayEnabled = StreamChatConfiguration.of(context).translationDisplayEnabled; + final translationConfig = StreamChatConfiguration.of(context).messageTranslation; // `BetterStreamBuilder`'s type parameter can't be nullable, so the // current user's (possibly unset) language is wrapped in a record — @@ -80,9 +81,9 @@ class StreamMessageText extends StatelessWidget { initialData: (language: streamChat.currentUser?.language), stream: streamChat.currentUserStream.map((it) => (language: it?.language)), builder: (context, data) { - final translated = (showOriginalText || !translationDisplayEnabled) - ? message - : message.translate(data.language); + 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 c0e22f5c03..2cc46fbd94 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; @@ -455,7 +454,7 @@ class DefaultStreamMessageItem extends StatelessWidget { final message = props.message; final translationStore = StreamMessageTranslations.of(context); - final showOriginalText = translationStore?.isShowingOriginalText(message.id) ?? false; + final showsOriginalText = translationStore?.isShowingOriginalText(message.id) ?? false; final placement = StreamMessageLayout.of(context); final theme = core.StreamMessageItemTheme.of(context); @@ -495,8 +494,8 @@ class DefaultStreamMessageItem extends StatelessWidget { final onTap? => () => onTap(message), _ => () => _onViewThread(context, message), }, - showOriginalText: showOriginalText, - onToggleOriginalText: switch (translationStore) { + showTranslatedText: !showsOriginalText, + onToggleTranslatedText: switch (translationStore) { final store? => () => store.toggleOriginalText(message.id), null => null, }, @@ -535,7 +534,7 @@ class DefaultStreamMessageItem extends StatelessWidget { attachmentBuilders: props.attachmentBuilders, reactionSorting: props.reactionSorting, onQuotedMessageTap: props.onQuotedMessageTap, - showOriginalText: showOriginalText, + showTranslatedText: !showsOriginalText, onLinkTap: (_, href, __) { if (href == null) return; if (props.onMessageLinkTap case final onTap?) return onTap(message, href); 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 index a0d935016b..34e2d7715c 100644 --- 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 @@ -1,7 +1,12 @@ import 'package:flutter/widgets.dart'; -/// Tracks, for messages within a single message list, which ones are -/// currently showing their original text instead of a translation. +/// Tracks, for messages within a single message list, which ones the user +/// has switched back to their original text. +/// +/// Showing the translation is the default, so [value] holds only the +/// exceptions — the ids of messages switched to their original text. It is +/// empty while every message shows its translation. This 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 @@ -9,20 +14,21 @@ import 'package:flutter/widgets.dart'; /// 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 ChangeNotifier { - final _originalTextMessageIds = {}; +class StreamMessageTranslationStore extends ValueNotifier> { + /// Creates a store in which every message shows its translation. + StreamMessageTranslationStore() : super(const {}); /// Whether [messageId] is currently showing its original text instead of - /// a translation. - bool isShowingOriginalText(String messageId) => _originalTextMessageIds.contains(messageId); + /// its translation. + bool isShowingOriginalText(String messageId) => value.contains(messageId); /// Switches [messageId] between showing its original text and its /// translation. void toggleOriginalText(String messageId) { - if (!_originalTextMessageIds.remove(messageId)) { - _originalTextMessageIds.add(messageId); - } - notifyListeners(); + value = switch (value.contains(messageId)) { + true => {...value}..remove(messageId), + false => {...value, messageId}, + }; } } 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 e132feff6d..045161cbc6 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -165,7 +165,7 @@ class StreamChatConfigurationData { List? attachmentBuilders, StreamReactionsType? reactionType, StreamReactionsPosition? reactionPosition, - bool translationDisplayEnabled = true, + StreamMessageTranslationConfiguration messageTranslation = const StreamMessageTranslationConfiguration(), }) { return StreamChatConfigurationData._( reactionIconResolver: reactionIconResolver ?? const DefaultReactionIconResolver(), @@ -176,7 +176,7 @@ class StreamChatConfigurationData { attachmentBuilders: attachmentBuilders, reactionType: reactionType, reactionPosition: reactionPosition, - translationDisplayEnabled: translationDisplayEnabled, + messageTranslation: messageTranslation, ); } @@ -187,7 +187,7 @@ class StreamChatConfigurationData { required this.messagePreviewFormatter, required this.imageCDN, required this.attachmentBuilders, - required this.translationDisplayEnabled, + required this.messageTranslation, this.reactionType, this.reactionPosition, }); @@ -203,7 +203,7 @@ class StreamChatConfigurationData { List? attachmentBuilders, StreamReactionsType? reactionType, StreamReactionsPosition? reactionPosition, - bool? translationDisplayEnabled, + StreamMessageTranslationConfiguration? messageTranslation, }) { return StreamChatConfigurationData( reactionIconResolver: reactionIconResolver ?? this.reactionIconResolver, @@ -214,7 +214,7 @@ class StreamChatConfigurationData { attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, reactionType: reactionType ?? this.reactionType, reactionPosition: reactionPosition ?? this.reactionPosition, - translationDisplayEnabled: translationDisplayEnabled ?? this.translationDisplayEnabled, + messageTranslation: messageTranslation ?? this.messageTranslation, ); } @@ -264,13 +264,71 @@ class StreamChatConfigurationData { /// ([StreamReactionsPosition.header]). final StreamReactionsPosition? reactionPosition; - /// Whether a message automatically displays as its translation — and - /// shows a "Translated" annotation with a link to see the original — - /// when [Message.i18n] has one for the current user's language. + /// How messages that carry a translation in [Message.i18n] are displayed. /// - /// Set to `false` to always show a message's original text regardless of - /// available translations. + /// Defaults to [StreamMessageTranslationConfiguration]'s own defaults: + /// translations are displayed automatically, without an annotation. + final StreamMessageTranslationConfiguration messageTranslation; +} + +/// {@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]. /// - /// Defaults to `true`. - final bool translationDisplayEnabled; + /// 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/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 1abb3ed776..5b809d8548 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -14,6 +14,7 @@ export 'package:stream_core_flutter/chat.dart' // additional features and customization options. StreamMessageContent, StreamMessageContentProps, + StreamMessageText, StreamMessageComposerAttachment, StreamMessageComposerAttachmentProps; @@ -94,9 +95,11 @@ 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/components/stream_message_text.dart'; export 'src/message_widget/stream_message_item.dart'; export 'src/message_widget/stream_message_translation_store.dart'; export 'src/message_widget/stream_moderated_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 b2429ca2e2..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 @@ -852,7 +852,7 @@ void main() { expect(find.text('Hello, world!'), findsOneWidget); }); - testWidgets('shows original text when translationDisplayEnabled is false', (tester) async { + 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'), @@ -865,9 +865,28 @@ void main() { tester, message, language: 'fr', - configData: StreamChatConfigurationData(translationDisplayEnabled: false), + 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); }); 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..0ebedf8e41 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/components/message_translation_annotation_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +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('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); + }); + }); +} 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/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/sample_app/lib/app.dart b/sample_app/lib/app.dart index 0ccefffdfd..6ffc6eb846 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -203,7 +203,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 @@ -276,6 +282,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 365dae7494..5bc82a0870 100644 --- a/sample_app/lib/config/sample_app_config.dart +++ b/sample_app/lib/config/sample_app_config.dart @@ -130,7 +130,13 @@ class SampleAppConfigData { /// Whether the "Message Info" action appears in the message context menu. final bool enableMessageInfo; - /// Whether the "Translate" action appears in the message context menu. + /// 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. diff --git a/sample_app/lib/config/sample_app_config_screen.dart b/sample_app/lib/config/sample_app_config_screen.dart index c7d47aca75..f93ce764a8 100644 --- a/sample_app/lib/config/sample_app_config_screen.dart +++ b/sample_app/lib/config/sample_app_config_screen.dart @@ -105,7 +105,7 @@ class SampleAppConfigScreen extends StatelessWidget { _SwitchRow( icon: icons.translate, title: 'Message Translation', - subtitle: 'Translate a message on request', + subtitle: 'Show translations, annotate them, and translate on request', value: config.enableMessageTranslation, onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableMessageTranslation: v)), ), 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 65067616fa..91864f0de7 100644 --- a/sample_app/lib/widgets/custom_message_actions.dart +++ b/sample_app/lib/widgets/custom_message_actions.dart @@ -14,7 +14,7 @@ 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) -/// → _TranslateAction (translate message on request) +/// → _TranslateMessageAction (translate message on request) /// ``` Widget customMessageItemBuilder( BuildContext context, @@ -31,7 +31,7 @@ Widget customMessageItemBuilder( ..._ReminderActions.build(context, message), ..._DeleteForMeAction.build(context, message), ..._MessageInfoAction.build(context, message), - ..._TranslateAction.build(context, message), + ..._TranslateMessageAction.build(context, message), ], ); }, @@ -219,8 +219,8 @@ abstract final class _MessageInfoAction { /// 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 server-side, rather than guessing a target language. -abstract final class _TranslateAction { +/// that isn't set, rather than guessing a target language. +abstract final class _TranslateMessageAction { static List build( BuildContext context, Message message, @@ -233,27 +233,29 @@ abstract final class _TranslateAction { final isSentByCurrentUser = message.user?.id == currentUser?.id; if (isSentByCurrentUser) return const []; - final userLanguage = currentUser?.language; - if (userLanguage == null || userLanguage.isEmpty) 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 user's language — + // `true` when the original text is already in the display language — // either way, there's nothing left to translate. - final alreadyTranslated = message.i18n?['${userLanguage}_text'] != null; + final alreadyTranslated = message.i18n?['${language}_text'] != null; final icons = context.streamIcons; return [ StreamContextMenuAction( - label: const Text('Translate'), + label: const Text('Translate Message'), leading: Icon(icons.translate), enabled: !alreadyTranslated, - onTap: () => _translate(context, message, userLanguage), + onTap: () => _translateMessage(context, message, language), ), ]; } - static Future _translate( + static Future _translateMessage( BuildContext context, Message message, String language, @@ -264,8 +266,11 @@ abstract final class _TranslateAction { channel.state?.updateMessage(response.message); } catch (e) { if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to translate message: $e')), + StreamSnackbarMessenger.of(context).show( + StreamSnackbar( + message: Text('Failed to translate message: $e'), + variant: .error, + ), ); } } From 20af936721183ac2da0c32f3e381dc15f41f18fe Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 20 Aug 2026 10:31:29 +0200 Subject: [PATCH 12/18] improve value notifier --- .../stream_message_translation_store.dart | 72 ++++++++++++++++--- 1 file changed, 61 insertions(+), 11 deletions(-) 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 index 34e2d7715c..da79d97283 100644 --- 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 @@ -1,12 +1,58 @@ +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]. +@immutable +class StreamMessageTranslationState { + /// Creates a state in which every message shows its translation. + 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); + } + + /// Copies the state, replacing the provided fields. + 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. /// -/// Showing the translation is the default, so [value] holds only the -/// exceptions — the ids of messages switched to their original text. It is -/// empty while every message shows its translation. This mirrors -/// `MessageOriginalTranslationsStore` in the Swift SDK. +/// 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 @@ -14,21 +60,25 @@ import 'package:flutter/widgets.dart'; /// 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> { +class StreamMessageTranslationStore extends ValueNotifier { /// Creates a store in which every message shows its translation. - StreamMessageTranslationStore() : super(const {}); + StreamMessageTranslationStore() : super(const StreamMessageTranslationState()); /// Whether [messageId] is currently showing its original text instead of /// its translation. - bool isShowingOriginalText(String messageId) => value.contains(messageId); + bool isShowingOriginalText(String messageId) => value.isShowingOriginalText(messageId); /// Switches [messageId] between showing its original text and its /// translation. void toggleOriginalText(String messageId) { - value = switch (value.contains(messageId)) { - true => {...value}..remove(messageId), - false => {...value, messageId}, - }; + final showingOriginalText = value.messagesShowingOriginalText; + + value = value.copyWith( + messagesShowingOriginalText: switch (showingOriginalText.contains(messageId)) { + true => {...showingOriginalText}..remove(messageId), + false => {...showingOriginalText, messageId}, + }, + ); } } From 5e7d0b7df96e4335d28883b66bdbdae4a4206347 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 20 Aug 2026 10:45:06 +0200 Subject: [PATCH 13/18] Added tests for translation store --- ...eam_message_translation_configuration.dart | 63 +++++ .../lib/src/stream_chat_configuration.dart | 62 ----- .../lib/stream_chat_flutter.dart | 1 + ...stream_message_translation_store_test.dart | 233 ++++++++++++++++++ 4 files changed, 297 insertions(+), 62 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/stream_message_translation_configuration.dart create mode 100644 packages/stream_chat_flutter/test/src/message_widget/stream_message_translation_store_test.dart 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/stream_chat_configuration.dart b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart index 045161cbc6..742fbc38df 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -270,65 +270,3 @@ class StreamChatConfigurationData { /// translations are displayed automatically, without an annotation. final StreamMessageTranslationConfiguration messageTranslation; } - -/// {@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/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 5b809d8548..0ca274c3ed 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -101,6 +101,7 @@ export 'src/message_widget/components/stream_message_header.dart'; export 'src/message_widget/components/stream_message_leading.dart'; export 'src/message_widget/components/stream_message_text.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'; 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..709807022a --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_translation_store_test.dart @@ -0,0 +1,233 @@ +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', () { + test('shows the translation for every message by default', () { + const state = StreamMessageTranslationState(); + + expect(state.messagesShowingOriginalText, isEmpty); + expect(state.isShowingOriginalText('message-1'), isFalse); + }); + + test('isShowingOriginalText only matches the tracked ids', () { + const state = StreamMessageTranslationState(messagesShowingOriginalText: {'message-1'}); + + expect(state.isShowingOriginalText('message-1'), isTrue); + expect(state.isShowingOriginalText('message-2'), isFalse); + }); + + test('copyWith replaces the tracked ids', () { + const state = StreamMessageTranslationState(messagesShowingOriginalText: {'message-1'}); + + final copy = state.copyWith(messagesShowingOriginalText: const {'message-2'}); + + expect(copy.messagesShowingOriginalText, const {'message-2'}); + }); + + test('copyWith keeps the tracked ids when they are not provided', () { + const state = StreamMessageTranslationState(messagesShowingOriginalText: {'message-1'}); + + expect(state.copyWith().messagesShowingOriginalText, const {'message-1'}); + }); + + test('compares equal by the tracked ids, not by set identity', () { + const state = StreamMessageTranslationState(messagesShowingOriginalText: {'message-1', 'message-2'}); + final reorderedIds = {'message-2', 'message-1'}; + final equal = StreamMessageTranslationState(messagesShowingOriginalText: reorderedIds); + const different = StreamMessageTranslationState(messagesShowingOriginalText: {'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, const StreamMessageTranslationState()); + 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'); + + var notifications = 0; + store + ..addListener(() => notifications++) + ..value = const StreamMessageTranslationState(messagesShowingOriginalText: {'message-1'}); + + 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); + }); + }); +} From 95c0f10b619967f8162be9fb9963c3485a3ddc10 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 20 Aug 2026 11:08:08 +0200 Subject: [PATCH 14/18] update changelog and barrel file --- packages/stream_chat_flutter/CHANGELOG.md | 4 ++-- packages/stream_chat_flutter/lib/stream_chat_flutter.dart | 2 -- .../components/message_translation_annotation_test.dart | 3 ++- packages/stream_chat_localizations/CHANGELOG.md | 4 ++++ 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 15d4c421bf..0f7493a929 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -40,8 +40,8 @@ - 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` and `StreamMessageText`, which were previously unreachable from `package:stream_chat_flutter/stream_chat_flutter.dart` — the barrel hid `stream_core_flutter`'s `StreamMessageContent` without exporting a replacement, and its `StreamMessageText` (a lower-level markdown renderer taking a raw `String`) resolved instead of the chat-specialized one. `StreamMessageText` from the barrel now means the chat version, which takes a `Message`; the core widget and its `emojiCount` helper are still available as `StreamMessageText` from `package:stream_core_flutter/chat.dart`, alongside the already-exported `DefaultStreamMessageText` and `StreamMessageTextProps`. -- Added `StreamChatConfigurationData.messageTranslation`, a `StreamMessageTranslationConfiguration` that collects every message-translation setting: `enabled` (default `true`) controls whether messages display a translation from `Message.i18n` at all, `annotationEnabled` (default `false`) opts into a "Translated"/"Original" annotation with a "Show original"/"Show translation" link that switches between the two. The defaults preserve the SDK's existing behaviour — translations are shown, silently — so the annotation is opt-in. Its toggle state is tracked per `StreamMessageListView` via the new `StreamMessageTranslationStore`, so it survives messages scrolling out of and back into view, and can be driven directly through `showTranslatedText`/`onToggleTranslatedText` on `StreamMessageHeaderProps`, `StreamMessageContent`, and `StreamMessageText`. +- Exported this package's `StreamMessageContent`, which was previously unreachable from `package:stream_chat_flutter/stream_chat_flutter.dart` — the barrel hid `stream_core_flutter`'s `StreamMessageContent` without exporting a replacement. +- Added `StreamChatConfigurationData.messageTranslation`, a `StreamMessageTranslationConfiguration` that collects every message-translation setting: `enabled` (default `true`) controls whether messages display a translation from `Message.i18n` at all, `annotationEnabled` (default `false`) opts into a "Translated"/"Original" annotation with a "Show original"/"Show translation" link that switches between the two. The defaults preserve the SDK's existing behaviour — translations are shown, silently — so the annotation is opt-in. Its toggle state is tracked per `StreamMessageListView` via the new `StreamMessageTranslationStore`, so it survives messages scrolling out of and back into view, and can be driven directly through `showTranslatedText`/`onToggleTranslatedText` on `StreamMessageHeaderProps` and `StreamMessageContent`. ⚠️ Deprecated diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 4ddf829fc0..838aacc718 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -14,7 +14,6 @@ export 'package:stream_core_flutter/chat.dart' // additional features and customization options. StreamMessageContent, StreamMessageContentProps, - StreamMessageText, StreamMessageComposerAttachment, StreamMessageComposerAttachmentProps; @@ -99,7 +98,6 @@ 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/components/stream_message_text.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'; 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 index 0ebedf8e41..5c382ba4a3 100644 --- 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 @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.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'; 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`. From ab9b1753de266bf1cb7fd2626bd460e76f7e945e Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 20 Aug 2026 11:58:14 +0200 Subject: [PATCH 15/18] Add more tests --- .../message_translation_annotation_test.dart | 92 +++++++++++++++++++ .../stream_message_item_test.dart | 74 +++++++++++++++ ...stream_message_translation_store_test.dart | 29 ++++++ .../test/translations_test.dart | 8 ++ 4 files changed, 203 insertions(+) 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 index 5c382ba4a3..74dd0e6377 100644 --- 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 @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -129,6 +131,69 @@ void main() { 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 @@ -218,4 +283,31 @@ void main() { 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_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 index 709807022a..60095e93da 100644 --- 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 @@ -230,4 +230,33 @@ void main() { 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_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); From c5f7e057b26a9a30c997bd3948686318a93b021f Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Thu, 20 Aug 2026 12:14:27 +0200 Subject: [PATCH 16/18] Make translation store safe for modification --- packages/stream_chat_flutter/CHANGELOG.md | 7 +-- .../stream_message_translation_store.dart | 25 ++++++----- ...stream_message_translation_store_test.dart | 43 +++++++++++-------- 3 files changed, 42 insertions(+), 33 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 0f7493a929..e17dc92264 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -40,8 +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` — the barrel hid `stream_core_flutter`'s `StreamMessageContent` without exporting a replacement. -- Added `StreamChatConfigurationData.messageTranslation`, a `StreamMessageTranslationConfiguration` that collects every message-translation setting: `enabled` (default `true`) controls whether messages display a translation from `Message.i18n` at all, `annotationEnabled` (default `false`) opts into a "Translated"/"Original" annotation with a "Show original"/"Show translation" link that switches between the two. The defaults preserve the SDK's existing behaviour — translations are shown, silently — so the annotation is opt-in. Its toggle state is tracked per `StreamMessageListView` via the new `StreamMessageTranslationStore`, so it survives messages scrolling out of and back into view, and can be driven directly through `showTranslatedText`/`onToggleTranslatedText` on `StreamMessageHeaderProps` and `StreamMessageContent`. +- 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 @@ -52,7 +53,7 @@ 🐞 Fixed -- Fixed message text and previews translating to English for users with no `User.language` set. They now show the original text, matching the backend, which only auto-translates for users that have a language. +- 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/message_widget/stream_message_translation_store.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_translation_store.dart index da79d97283..556d736ef3 100644 --- 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 @@ -6,10 +6,12 @@ import 'package:flutter/widgets.dart'; /// /// 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 { - /// Creates a state in which every message shows its translation. - const StreamMessageTranslationState({ + const StreamMessageTranslationState._({ this.messagesShowingOriginalText = const {}, }); @@ -25,11 +27,10 @@ class StreamMessageTranslationState { return messagesShowingOriginalText.contains(messageId); } - /// Copies the state, replacing the provided fields. - StreamMessageTranslationState copyWith({ + StreamMessageTranslationState _copyWith({ Set? messagesShowingOriginalText, }) { - return StreamMessageTranslationState( + return StreamMessageTranslationState._( messagesShowingOriginalText: messagesShowingOriginalText ?? this.messagesShowingOriginalText, ); } @@ -62,7 +63,7 @@ class StreamMessageTranslationState { /// the list's render window. class StreamMessageTranslationStore extends ValueNotifier { /// Creates a store in which every message shows its translation. - StreamMessageTranslationStore() : super(const StreamMessageTranslationState()); + StreamMessageTranslationStore() : super(const StreamMessageTranslationState._()); /// Whether [messageId] is currently showing its original text instead of /// its translation. @@ -73,11 +74,13 @@ class StreamMessageTranslationStore extends ValueNotifier {...showingOriginalText}..remove(messageId), - false => {...showingOriginalText, messageId}, - }, + value = value._copyWith( + messagesShowingOriginalText: Set.unmodifiable( + switch (showingOriginalText.contains(messageId)) { + true => {...showingOriginalText}..remove(messageId), + false => {...showingOriginalText, messageId}, + }, + ), ); } } 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 index 60095e93da..ed5dc845e5 100644 --- 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 @@ -4,39 +4,39 @@ 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', () { - const state = StreamMessageTranslationState(); + final state = stateShowingOriginalText([]); expect(state.messagesShowingOriginalText, isEmpty); expect(state.isShowingOriginalText('message-1'), isFalse); }); test('isShowingOriginalText only matches the tracked ids', () { - const state = StreamMessageTranslationState(messagesShowingOriginalText: {'message-1'}); + final state = stateShowingOriginalText(['message-1']); expect(state.isShowingOriginalText('message-1'), isTrue); expect(state.isShowingOriginalText('message-2'), isFalse); }); - test('copyWith replaces the tracked ids', () { - const state = StreamMessageTranslationState(messagesShowingOriginalText: {'message-1'}); - - final copy = state.copyWith(messagesShowingOriginalText: const {'message-2'}); + test('exposes the tracked ids as an unmodifiable set', () { + final state = stateShowingOriginalText(['message-1']); - expect(copy.messagesShowingOriginalText, const {'message-2'}); - }); - - test('copyWith keeps the tracked ids when they are not provided', () { - const state = StreamMessageTranslationState(messagesShowingOriginalText: {'message-1'}); - - expect(state.copyWith().messagesShowingOriginalText, const {'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', () { - const state = StreamMessageTranslationState(messagesShowingOriginalText: {'message-1', 'message-2'}); - final reorderedIds = {'message-2', 'message-1'}; - final equal = StreamMessageTranslationState(messagesShowingOriginalText: reorderedIds); - const different = StreamMessageTranslationState(messagesShowingOriginalText: {'message-1'}); + 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); @@ -51,7 +51,7 @@ void main() { tearDown(() => store.dispose()); test('shows the translation for every message by default', () { - expect(store.value, const StreamMessageTranslationState()); + expect(store.value.messagesShowingOriginalText, isEmpty); expect(store.isShowingOriginalText('message-1'), isFalse); }); @@ -93,10 +93,15 @@ void main() { 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 = const StreamMessageTranslationState(messagesShowingOriginalText: {'message-1'}); + ..value = equivalent.value; expect(notifications, 0); }); From c244dce704668edca658f4f86a7528486a04dc01 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 21 Aug 2026 17:07:38 +0200 Subject: [PATCH 17/18] refactor(ui): split StreamMessageTranslations.of into of and maybeOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.of` now returns a non-nullable store and throws a descriptive FlutterError when the scope is missing, matching `StreamChat.of`. The nullable lookup moves to `maybeOf`. The two read sites in `DefaultStreamMessageItem` use `maybeOf`: the scope is genuinely optional there. `StreamMessageListView` provides one for every message it hosts, and without it a standalone `StreamMessageItem` still renders — only the translation toggle is unavailable, which is the correct standalone behaviour. Co-Authored-By: Claude Opus 5 --- .../message_widget/stream_message_item.dart | 4 +- .../stream_message_translation_store.dart | 50 ++++++++++++++++++- ...stream_message_translation_store_test.dart | 26 ++++++++-- 3 files changed, 73 insertions(+), 7 deletions(-) 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 195165bd4a..284726ab1b 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 @@ -466,7 +466,7 @@ class DefaultStreamMessageItem extends StatelessWidget { Widget build(BuildContext context) { final message = props.message; - final translationStore = StreamMessageTranslations.of(context); + final translationStore = StreamMessageTranslations.maybeOf(context); final showsOriginalText = translationStore?.isShowingOriginalText(message.id) ?? false; final placement = StreamMessageLayout.of(context); @@ -873,7 +873,7 @@ class DefaultStreamMessageItem extends StatelessWidget { // 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 translationStore = StreamMessageTranslations.maybeOf(context); final messageItem = StreamMessageItem( key: const Key('MessageItem'), 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 index 556d736ef3..74f2502018 100644 --- 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 @@ -99,8 +99,54 @@ class StreamMessageTranslations extends InheritedNotifier[ + ErrorSummary( + 'StreamMessageTranslations.of() called with a context that does not ' + 'contain a StreamMessageTranslations.', + ), + ErrorDescription( + 'No StreamMessageTranslations ancestor could be found starting from ' + 'the context that was passed to StreamMessageTranslations.of(). ' + 'StreamMessageListView provides one for the messages it hosts, so ' + 'this usually happens when a message widget is built outside of a ' + 'message list.', + ), + ErrorHint( + 'To fix this, wrap the subtree in a StreamMessageTranslations with a ' + 'store you own:\n\n' + ' StreamMessageTranslations(\n' + ' store: _translationStore,\n' + ' child: StreamMessageItem(message: message),\n' + ' )', + ), + context.describeElement('The context used was'), + ]); + } + + /// Returns the [StreamMessageTranslationStore] from the nearest ancestor + /// [StreamMessageTranslations] that encloses the given context. + /// + /// Returns null if no such ancestor exists. + /// + /// See also: + /// * [of], which throws if no [StreamMessageTranslations] is found. + static StreamMessageTranslationStore? maybeOf(BuildContext context) { final widget = context.dependOnInheritedWidgetOfExactType(); return widget?.notifier; } 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 index ed5dc845e5..e872d3b5d1 100644 --- 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 @@ -131,13 +131,13 @@ void main() { expect(found, same(store)); }); - testWidgets('returns null without an ancestor scope', (tester) async { + testWidgets('maybeOf returns null without an ancestor scope', (tester) async { StreamMessageTranslationStore? found; await tester.pumpWidget( Builder( builder: (context) { - found = StreamMessageTranslations.of(context); + found = StreamMessageTranslations.maybeOf(context); return const SizedBox.shrink(); }, ), @@ -146,6 +146,26 @@ void main() { expect(found, isNull); }); + testWidgets('of throws without an ancestor scope', (tester) async { + await tester.pumpWidget( + Builder( + builder: (context) { + StreamMessageTranslations.of(context); + return const SizedBox.shrink(); + }, + ), + ); + + expect( + tester.takeException(), + isA().having( + (it) => it.message, + 'message', + contains('StreamMessageTranslations.of() called with a context that does not contain'), + ), + ); + }); + testWidgets('a nested scope shadows the outer store, the way an open thread does', (tester) async { final threadStore = StreamMessageTranslationStore(); addTearDown(threadStore.dispose); @@ -185,7 +205,7 @@ void main() { false => const SizedBox.shrink(), true => Builder( builder: (context) { - final store = StreamMessageTranslations.of(context)!; + final store = StreamMessageTranslations.of(context); showsOriginalText = store.isShowingOriginalText('message-1'); return const SizedBox.shrink(); }, From 403eee5d106bfdb87af171ee2f6832cb8f9e2cf1 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Fri, 21 Aug 2026 17:58:13 +0200 Subject: [PATCH 18/18] move translation store ownership --- packages/stream_chat_flutter/CHANGELOG.md | 2 +- .../message_list_view/message_list_view.dart | 37 +++++------- .../message_widget/stream_message_item.dart | 19 ++---- .../stream_message_translation_store.dart | 32 +++++++--- .../lib/src/stream_chat.dart | 17 ++++++ ...stream_message_translation_store_test.dart | 60 ++++++++++++++++++- 6 files changed, 116 insertions(+), 51 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index e17dc92264..0960c701ad 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -42,7 +42,7 @@ - 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`. +- Added `StreamMessageTranslationStore`, tracking the annotation's toggle state by message id. `StreamChat` owns one and provides it through a `StreamMessageTranslations` scope, so a channel list and its open thread agree on which text a message shows; nest another `StreamMessageTranslations` to isolate a subtree, and call `clear()` to switch everything back to its translation. Also drivable via `showTranslatedText` on `StreamMessageHeaderProps`, `StreamMessageContent`, and `StreamMessageText`, and `onToggleTranslatedText` on `StreamMessageHeaderProps`. ⚠️ Deprecated 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 f4d0536506..54e06404de 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,11 +312,6 @@ 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)); @@ -484,7 +479,6 @@ class _StreamMessageListViewState extends State { debouncedMarkThreadRead.cancel(); _unreadState.dispose(); _highlightState.dispose(); - _translationStore.dispose(); super.dispose(); } @@ -622,24 +616,21 @@ 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 StreamMessageTranslations( - store: _translationStore, + return Portal( + labels: const [kPortalMessageListViewLabel], child: Portal( - 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, - ), + 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, ), ), ), 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 284726ab1b..6b7a9d61a7 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 @@ -466,8 +466,8 @@ class DefaultStreamMessageItem extends StatelessWidget { Widget build(BuildContext context) { final message = props.message; - final translationStore = StreamMessageTranslations.maybeOf(context); - final showsOriginalText = translationStore?.isShowingOriginalText(message.id) ?? false; + final translationStore = StreamMessageTranslations.of(context); + final showsOriginalText = translationStore.isShowingOriginalText(message.id); final placement = StreamMessageLayout.of(context); final theme = core.StreamMessageItemTheme.of(context); @@ -508,10 +508,7 @@ class DefaultStreamMessageItem extends StatelessWidget { _ => () => _onViewThread(context, message), }, showTranslatedText: !showsOriginalText, - onToggleTranslatedText: switch (translationStore) { - final store? => () => store.toggleOriginalText(message.id), - null => null, - }, + onToggleTranslatedText: () => translationStore.toggleOriginalText(message.id), ), ); @@ -871,20 +868,12 @@ 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.maybeOf(context); - - final messageItem = StreamMessageItem( + final messageWidget = 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, 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 index 74f2502018..bd7eec3232 100644 --- 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 @@ -50,17 +50,22 @@ class StreamMessageTranslationState { 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. +/// Tracks which messages the user has switched back to their original text. /// -/// Mirrors `MessageOriginalTranslationsStore` in the Swift SDK. +/// Mirrors `MessageOriginalTranslationsStore` in the Swift and Android SDKs, +/// which likewise key this state by message id above the individual list. /// -/// 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. +/// [StreamChat] owns one store for the whole app and provides it through a +/// [StreamMessageTranslations] scope, so every message list shares it. That +/// matters because the same message can render in more than one list at +/// once — a thread's parent message also appears in the channel list — and +/// both should agree on which text it shows. Holding the state here rather +/// than on the message widget is also what makes a toggle survive message +/// items being disposed and recreated as they scroll out of and back into a +/// list's render window. +/// +/// Nest another [StreamMessageTranslations] to give a subtree its own +/// isolated toggle state. class StreamMessageTranslationStore extends ValueNotifier { /// Creates a store in which every message shows its translation. StreamMessageTranslationStore() : super(const StreamMessageTranslationState._()); @@ -83,6 +88,15 @@ class StreamMessageTranslationStore extends ValueNotifier { /// Gets configuration options from widget StreamChatConfigurationData get configData => widget.configData ?? StreamChatConfigurationData(); + /// Tracks which messages the user has switched back to their original text. + /// + /// Owned here so the toggle is shared by every message list in the app — a + /// channel and its open thread render the same parent message, and both + /// should agree on which text it shows. Matches the Swift and Android SDKs, + /// which key this state by message id above the individual list. + late final StreamMessageTranslationStore _translationStore; + @override void initState() { super.initState(); + _translationStore = StreamMessageTranslationStore(); + } + + @override + void dispose() { + _translationStore.dispose(); + super.dispose(); } @override @@ -202,6 +217,8 @@ class StreamChatState extends State { ), ); + child = StreamMessageTranslations(store: _translationStore, child: child); + final theme = widget.themeData ?? StreamChatThemeData(); child = StreamChatTheme(data: theme, child: child); 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 index e872d3b5d1..b70d90e9fd 100644 --- 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 @@ -1,7 +1,10 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../mocks.dart'; + void main() { group('StreamMessageTranslationState', () { /// Returns the state of a store that has toggled [messageIds], in order. @@ -105,6 +108,25 @@ void main() { expect(notifications, 0); }); + + test('clear switches every message back to its translation', () { + store + ..toggleOriginalText('message-1') + ..toggleOriginalText('message-2') + ..clear(); + + expect(store.isShowingOriginalText('message-1'), isFalse); + expect(store.isShowingOriginalText('message-2'), isFalse); + }); + + test('clear does not notify listeners when nothing is toggled', () { + var notifications = 0; + store + ..addListener(() => notifications++) + ..clear(); + + expect(notifications, 0); + }); }); group('StreamMessageTranslations', () { @@ -166,7 +188,7 @@ void main() { ); }); - testWidgets('a nested scope shadows the outer store, the way an open thread does', (tester) async { + testWidgets('a nested scope shadows the outer store, isolating that subtree', (tester) async { final threadStore = StreamMessageTranslationStore(); addTearDown(threadStore.dispose); @@ -189,7 +211,7 @@ void main() { expect(found, same(threadStore)); - // Toggling in the thread leaves the channel list's own state alone. + // Toggling inside the nested scope leaves the outer store alone. threadStore.toggleOriginalText('message-1'); expect(store.isShowingOriginalText('message-1'), isFalse); @@ -231,6 +253,38 @@ void main() { expect(showsOriginalText, isTrue); }); + testWidgets('StreamChat provides a store, so no explicit scope is needed', (tester) async { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'current-user')); + + StreamMessageTranslationStore? found; + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + child: Builder( + builder: (context) { + found = StreamMessageTranslations.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + expect(found, isNotNull); + expect(tester.takeException(), isNull); + + // The same store is handed to every subtree under StreamChat, which is + // what lets a channel list and its open thread agree on a toggle. + found!.toggleOriginalText('message-1'); + expect(found!.isShowingOriginalText('message-1'), isTrue); + }); + testWidgets('rebuilds its descendants when a message is toggled', (tester) async { var builds = 0;