diff --git a/melos.yaml b/melos.yaml index 4172629cd9..d44ae5d286 100644 --- a/melos.yaml +++ b/melos.yaml @@ -78,6 +78,7 @@ command: latlong2: ^0.9.1 logging: ^1.3.0 lottie: ^3.3.3 + markdown: ^7.3.0 marionette_flutter: ^0.6.0 media_kit: ^1.2.6 media_kit_libs_video: ^1.0.6 diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 7a1c74ed54..686c162742 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -3,11 +3,15 @@ ✅ Added - Added `onReactionLongPress` to `StreamMessageItem` and `StreamMessageListView`, reporting the long-pressed message's `BuildContext` and a `ReactionLongPressDetails` with the `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction). +- Added `StreamMessageItem.semanticsLabel`, which replaces the announcement composed for a message row. Pass an empty string to leave the row unlabeled, in which case the bubble and footer announce their own parts. +- Added `StreamQuotedMessage.replyMessage`, the message doing the quoting, which lets a quoted preview announce who replied to whom. +- Added `StreamMessageRowLabelScope`, which marks a subtree whose metadata is already spoken by a composed row label. `StreamMessageFooter` and the message bubble stay out of the semantics tree inside one and announce themselves outside one. ⚠️ Changed - Long-pressing a reaction chip no longer opens the message actions modal; the chips always claim the long press. Left unset, `onReactionLongPress` defaults to opening the `ReactionDetailSheet`. - Tapping or long-pressing a reaction chip now opens the `ReactionDetailSheet` pre-filtered to that reaction; it previously opened unfiltered. Clustered and overflow chips map to no single reaction, so they still open unfiltered. +- A deleted message now renders the timestamp and delivery status below the placeholder, matching the design, and no longer shows the "Edited" marker — there is no text left to have been edited. 🔄 Changed @@ -15,9 +19,11 @@ 🐞 Fixed +- Improved the screen-reader experience in the message list. Each message is announced as a single phrase naming the sender and the direction ("You said, …" / " said, …") together with the body, the time, the edited marker and the delivery status — including upload progress and a failure to send — while the attachments, reaction chips, quoted message and replies row stay reachable one level deeper. The body is announced as the text the bubble renders, so markdown link and emphasis syntax is no longer read aloud. Quoted messages say who replied to whom, attachment tiles announce their type and position in a gallery, date dividers announce the date they show — as a header — instead of a time they never showed, and a deleted message names who deleted it and keeps its timestamp and delivery status. A custom `messageBuilder` replaces the default layout and is responsible for its own label. - Fixed a crash on web when the message list rebuilt while messages were selectable, for example after opening the attachment picker. - Fixed the browser's native context menu reappearing over the message context menu on web after scrolling messages out of view or deleting one. - Fixed the SDK re-enabling the browser's native context menu on web in apps that had disabled it themselves. +- Fixed the attachment upload progress on an outgoing message counting its link preview, which inflated the total against an attachment the sender never picked. ## 10.3.0 diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart index be73716b3d..c284ea077e 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart @@ -13,6 +13,33 @@ part 'video_attachment_builder.dart'; part 'voice_recording_attachment_playlist_builder.dart'; part 'poll_attachment_builder.dart'; +// Screen-reader label for a media attachment tile. +// +// Image, video and giphy tiles render no text of their own, so without this +// they are focusable — they open a preview on tap — but announce nothing. +// +// [index] (0-based) and [total] are announced only for a gallery, where +// otherwise identical tiles need telling apart. The type label already rides +// on the message row's own phrase ("Han Solo said, 2 photos, ..."), so the +// tiles deliberately repeat the type rather than inventing a second summary. +String _mediaAttachmentSemanticsLabel( + BuildContext context, + Attachment attachment, { + int? index, + int? total, +}) { + final a11y = context.translations.accessibility; + + final typeLabel = switch (attachment.type) { + AttachmentType.video => a11y.videoAttachmentLabel(title: attachment.title), + AttachmentType.giphy => a11y.gifAttachmentLabel, + _ => a11y.imageAttachmentLabel(title: attachment.title), + }; + + if (index == null || total == null || total < 2) return typeLabel; + return '$typeLabel, ${a11y.attachmentPositionLabel(index: index + 1, total: total)}'; +} + /// {@template streamAttachmentWidgetTapCallback} /// Signature for a function that's called when the user taps on an attachment. /// {@endtemplate} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart index 7177fb8687..c6e815ad0c 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart @@ -100,25 +100,33 @@ class GalleryAttachmentBuilder extends StreamAttachmentWidgetBuilder { style: .from(padding: .zero), child: InkWell( onTap: onTap, - child: Stack( - fit: .expand, - alignment: .center, - children: [ - StreamMediaAttachmentThumbnail( - media: attachment, - fit: BoxFit.cover, - ), - if (attachment.type == .video && attachment.uploadState.isSuccess) ...[ - const Center(child: StreamVideoPlayIndicator(size: .lg)), - ] else ...[ - Positioned.fill( - child: StreamAttachmentUploadStateBuilder( - message: message, - attachment: attachment, - ), + child: Semantics( + label: _mediaAttachmentSemanticsLabel( + context, + attachment, + index: index, + total: galleryAttachments.length, + ), + child: Stack( + fit: .expand, + alignment: .center, + children: [ + StreamMediaAttachmentThumbnail( + media: attachment, + fit: BoxFit.cover, ), + if (attachment.type == .video && attachment.uploadState.isSuccess) ...[ + const Center(child: StreamVideoPlayIndicator(size: .lg)), + ] else ...[ + Positioned.fill( + child: StreamAttachmentUploadStateBuilder( + message: message, + attachment: attachment, + ), + ), + ], ], - ], + ), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart index 8eed7cc485..3c961a45d3 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart @@ -53,10 +53,13 @@ class GiphyAttachmentBuilder extends StreamAttachmentWidgetBuilder { style: style, child: InkWell( onTap: onTap, - child: StreamGiphyAttachment( - message: message, - constraints: constraints, - giphy: giphy, + child: Semantics( + label: _mediaAttachmentSemanticsLabel(context, giphy), + child: StreamGiphyAttachment( + message: message, + constraints: constraints, + giphy: giphy, + ), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart index 70cf7279df..cd65ec949e 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart @@ -53,10 +53,15 @@ class ImageAttachmentBuilder extends StreamAttachmentWidgetBuilder { style: style, child: InkWell( onTap: onTap, - child: StreamImageAttachment( - message: message, - constraints: constraints, - image: image, + // Annotates the InkWell's node so the tile announces what it is; the + // thumbnail itself renders no text. + child: Semantics( + label: _mediaAttachmentSemanticsLabel(context, image), + child: StreamImageAttachment( + message: message, + constraints: constraints, + image: image, + ), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart index 23d9e81062..1f1b7d1037 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart @@ -55,10 +55,13 @@ class VideoAttachmentBuilder extends StreamAttachmentWidgetBuilder { style: style, child: InkWell( onTap: onTap, - child: StreamVideoAttachment( - message: message, - constraints: constraints, - video: video, + child: Semantics( + label: _mediaAttachmentSemanticsLabel(context, video), + child: StreamVideoAttachment( + message: message, + constraints: constraints, + video: video, + ), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart index 1702470dc4..20719e76ba 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart @@ -289,15 +289,20 @@ class DefaultStreamGalleryAttachment extends StatelessWidget { children: children, overlayBuilder: (context, remaining) { return IgnorePointer( - child: Material( - clipBehavior: .hardEdge, - color: colorScheme.backgroundOverlayDark, - shape: RoundedSuperellipseBorder(borderRadius: .all(radius.md)), - child: Center( - child: Text( - '+$remaining', - style: textTheme.headingLg.copyWith( - color: colorScheme.textOnAccent, + // Each tile announces its position ("4 of 6"), which already tells a + // screen-reader user that the gallery holds more than it shows. The + // badge would only add a second stop reading "plus 2". + child: ExcludeSemantics( + child: Material( + clipBehavior: .hardEdge, + color: colorScheme.backgroundOverlayDark, + shape: RoundedSuperellipseBorder(borderRadius: .all(radius.md)), + child: Center( + child: Text( + '+$remaining', + style: textTheme.headingLg.copyWith( + color: colorScheme.textOnAccent, + ), ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart index 412aa2da68..7dfdf5ee00 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../stream_chat_flutter.dart'; +import '../localization/translations.dart'; import '../misc/empty_widget.dart'; /// {@template streamSendingIndicator} @@ -38,14 +39,22 @@ class StreamSendingIndicator extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; - final a11y = context.translations.accessibility; + + // Resolved once and reused by every branch below, so the icon a reader + // sees and the label a screen reader hears can never describe different + // states. + final semanticLabel = context.translations.messageDeliveryStatusLabel( + message, + isMessageRead: isMessageRead, + isMessageDelivered: isMessageDelivered, + ); if (isMessageRead) { return Icon( context.streamIcons.checks, size: size, color: color ?? colorScheme.accentPrimary, - semanticLabel: a11y.messageReadStatusLabel, + semanticLabel: semanticLabel, ); } @@ -54,7 +63,7 @@ class StreamSendingIndicator extends StatelessWidget { context.streamIcons.checks, size: size, color: color ?? colorScheme.textSecondary, - semanticLabel: a11y.messageDeliveredStatusLabel, + semanticLabel: semanticLabel, ); } @@ -63,7 +72,7 @@ class StreamSendingIndicator extends StatelessWidget { context.streamIcons.checkmark, size: size, color: color ?? colorScheme.textSecondary, - semanticLabel: a11y.messageSentStatusLabel, + semanticLabel: semanticLabel, ); } @@ -72,10 +81,66 @@ class StreamSendingIndicator extends StatelessWidget { context.streamIcons.clock, size: size, color: color ?? colorScheme.textSecondary, - semanticLabel: a11y.messageSendingStatusLabel, + semanticLabel: semanticLabel, ); } return const Empty(); } } + +/// The status labels a message announces, shared by the widgets that render +/// that status and by the composed message row announcement. +/// +/// [StreamSendingIndicator] and [StreamMessageSendingStatus] render the status +/// visually, while [StreamMessageItem] speaks it as part of the row label. +/// Both read the state through these two members, so a change to what counts +/// as sent, delivered or read lands in one place instead of drifting between +/// the icon and the announcement. +extension StreamMessageStatusLabels on Translations { + /// How many of [message]'s attachments have finished uploading, or null once + /// they all have. + /// + /// While attachments upload, the footer shows this progress in place of a + /// delivery tick, so the announcement carries the same progress rather than + /// flattening it to "Sending". + String? attachmentUploadProgressLabel(Message message) { + if (!message.state.isOutgoing) return null; + + // A url preview is generated rather than uploaded, so counting it would + // report progress against an attachment the sender never picked. + final attachments = message.attachments.where((it) => it.type != AttachmentType.urlPreview).toList(); + if (attachments.isEmpty) return null; + + final uploaded = attachments.where((it) => it.uploadState.isSuccess).length; + if (uploaded >= attachments.length) return null; + + return attachmentsUploadProgressText( + completed: uploaded, + total: attachments.length, + ); + } + + /// The delivery status announced for [message], or null when it has none. + /// + /// A failed send is shown as a badge on the bubble rather than a footer tick, + /// and the badge is a bare icon with no text of its own, so the failure is + /// reported here instead. + String? messageDeliveryStatusLabel( + Message message, { + required bool isMessageRead, + required bool isMessageDelivered, + }) { + final a11y = accessibility; + + if (message.state.isFailed || message.isBouncedWithError) { + return a11y.messageFailedStatusLabel; + } + + if (isMessageRead) return a11y.messageReadStatusLabel; + if (isMessageDelivered) return a11y.messageDeliveredStatusLabel; + if (message.state.isCompleted) return a11y.messageSentStatusLabel; + if (message.state.isOutgoing) return a11y.messageSendingStatusLabel; + return null; + } +} diff --git a/packages/stream_chat_flutter/lib/src/localization/accessibility_translations.dart b/packages/stream_chat_flutter/lib/src/localization/accessibility_translations.dart index 069f08f330..faec123fee 100644 --- a/packages/stream_chat_flutter/lib/src/localization/accessibility_translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/accessibility_translations.dart @@ -120,6 +120,54 @@ abstract class AccessibilityTranslations { /// `title` when set. String imageAttachmentLabel({String? title}); + /// The screen-reader label for a quoted-message preview where the current + /// user replied to their own message, e.g. `"You replied to your message"`. + /// + /// Combined with the quoted body as `"You replied to your message, see you + /// tomorrow"`. + /// + /// The four reply labels are split by who replied rather than composed from + /// a word for "you", because a locale that inflects its verb for person + /// cannot conjugate a name it is handed at runtime: German needs "Du hast + /// geantwortet" against "Han Solo hat geantwortet", and substituting "Du" + /// into the third-person form yields "Du hat geantwortet". + String outgoingReplyToOwnMessageLabel(); + + /// The screen-reader label for a quoted-message preview where the current + /// user replied to [authorName]'s message, e.g. `"You replied to Leia's + /// message"`. + /// + /// See [outgoingReplyToOwnMessageLabel] for why the direction is part of the + /// method rather than a substituted name. [authorName] is part of the string + /// so every locale can form the possessive itself. + String outgoingReplyToMessageLabel({required String authorName}); + + /// The screen-reader label for a quoted-message preview where [replierName] + /// replied to the current user's message, e.g. `"Han Solo replied to your + /// message"`. + /// + /// [replierName] is the author of the message doing the quoting. + String incomingReplyToOwnMessageLabel({required String replierName}); + + /// The screen-reader label for a quoted-message preview where [replierName] + /// replied to [authorName]'s message, e.g. `"Han Solo replied to Leia's + /// message"`. + /// + /// Both names are part of the string so every locale can form the possessive + /// itself. + String incomingReplyToMessageLabel({ + required String replierName, + required String authorName, + }); + + /// The screen-reader position of one attachment within a gallery, e.g. + /// `"2 of 5"`. + /// + /// Combined with the type label as `"Photo, 2 of 5"`. [index] is 1-based. + /// Only announced for galleries, where otherwise identical tiles need + /// telling apart. + String attachmentPositionLabel({required int index, required int total}); + /// The tooltip for the play button on a sent voice-recording /// attachment. String get voiceRecordingPlayTooltip; @@ -172,6 +220,40 @@ abstract class AccessibilityTranslations { /// when [senderName] is null. String incomingMessagePreviewLabel({String? senderName}); + /// The screen-reader announcement for a message in the message list that the + /// current user sent, e.g. `"You said, are we still meeting tomorrow"`. + /// + /// [body] is the already-composed message body — the message text, or a type + /// label such as `"2 Photos"` for an attachment-only message. The sender and + /// the body form a single translation unit so every locale can order and + /// inflect the whole clause; some phrase it as "my message" rather than + /// "you said". + String outgoingMessageLabel({required String body}); + + /// The screen-reader announcement for a message in the message list received + /// from [senderName], e.g. `"Han Solo said, are we still meeting tomorrow"`. + /// + /// [senderName] is the sender's resolved, non-empty display name — callers + /// omit the announcement entirely when there is no name to announce, so + /// implementations can interpolate it directly. See [outgoingMessageLabel] + /// for [body]. + String incomingMessageLabel({required String senderName, required String body}); + + /// The screen-reader announcement for a message the current user deleted, + /// e.g. `"You, Message deleted"`. + /// + /// [body] is the composed deleted-message placeholder. Phrased without + /// "said" — the sender did not author the placeholder, they deleted what + /// they had authored. See [outgoingMessageLabel] for why the body is part + /// of the string. + String outgoingDeletedMessageLabel({required String body}); + + /// The screen-reader announcement for a message [senderName] deleted, e.g. + /// `"Han Solo, Message deleted"`. + /// + /// See [outgoingDeletedMessageLabel] for [body] and the phrasing. + String incomingDeletedMessageLabel({required String senderName, required String body}); + /// The screen-reader type label for a poll last-message preview, e.g. /// `"Poll"`. /// @@ -200,6 +282,14 @@ abstract class AccessibilityTranslations { /// message has been read by the recipient, e.g. `"Read"`. String get messageReadStatusLabel; + /// The screen-reader label for a message that could not be sent, e.g. + /// `"Message failed to send"`. + /// + /// Announced in place of a delivery status. The failure is shown as a badge + /// on the bubble, which carries no text of its own, so this is the only way + /// a screen reader learns the message did not go out. + String get messageFailedStatusLabel; + /// The screen-reader phrasing for a batch of unread messages, e.g. /// `"9 unread messages"` / `"1 unread message"`. /// @@ -422,6 +512,34 @@ class DefaultAccessibilityTranslations extends AccessibilityTranslations { return 'Photo, $title'; } + @override + String outgoingReplyToOwnMessageLabel() { + return 'You replied to your message'; + } + + @override + String outgoingReplyToMessageLabel({required String authorName}) { + return "You replied to $authorName's message"; + } + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) { + return '$replierName replied to your message'; + } + + @override + String incomingReplyToMessageLabel({ + required String replierName, + required String authorName, + }) { + return "$replierName replied to $authorName's message"; + } + + @override + String attachmentPositionLabel({required int index, required int total}) { + return '$index of $total'; + } + @override String get voiceRecordingPlayTooltip => 'Play'; @@ -470,6 +588,22 @@ class DefaultAccessibilityTranslations extends AccessibilityTranslations { return senderName ?? 'Message'; } + @override + String outgoingMessageLabel({required String body}) => 'You said, $body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) { + return '$senderName said, $body'; + } + + @override + String outgoingDeletedMessageLabel({required String body}) => 'You, $body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) { + return '$senderName, $body'; + } + @override String get pollPreviewLabel => 'Poll'; @@ -488,6 +622,9 @@ class DefaultAccessibilityTranslations extends AccessibilityTranslations { @override String get messageReadStatusLabel => 'Read'; + @override + String get messageFailedStatusLabel => 'Message failed to send'; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( 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 6adce0d484..a22bbb3e68 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 @@ -5,6 +5,7 @@ import 'package:stream_core_flutter/chat.dart' as core; import '../../attachment/builder/attachment_widget_builder.dart'; import '../stream_message_attachments.dart'; +import '../stream_message_item.dart'; import '../stream_quoted_message.dart'; import 'stream_message_deleted.dart'; import 'stream_message_reactions.dart'; @@ -173,7 +174,25 @@ class _StreamMessageContentState extends State { final spacing = context.streamSpacing; final crossAxisAlignment = core.StreamMessageLayout.crossAxisAlignmentOf(context); - if (widget.message.isDeleted) return const StreamMessageDeleted(); + // Only a row that speaks its own composed label has already said what the + // bubble contains; without one the bubble is all a reader has. + final announcedByRow = StreamMessageRowLabelScope.isAnnouncedIn(context); + Widget hideFromRow(Widget child) { + if (!announcedByRow) return child; + return ExcludeSemantics(child: child); + } + + // A deleted message keeps its metadata: the design shows the timestamp and + // the delivery status below the placeholder, same as any other message. + if (widget.message.isDeleted) { + return core.StreamMessageContent( + header: widget.header, + footer: widget.footer, + // The composed row label already speaks the placeholder, so announcing + // it here as well would repeat it. + child: hideFromRow(const StreamMessageDeleted()), + ); + } return core.StreamMessageContent( header: widget.header, @@ -199,6 +218,7 @@ class _StreamMessageContentState extends State { if (widget.message.quotedMessage case final quotedMessage?) StreamQuotedMessage( quotedMessage: quotedMessage, + replyMessage: widget.message, onTap: switch (widget.onQuotedMessageTap) { final onTap? => () => onTap(quotedMessage), _ => null, @@ -210,12 +230,29 @@ class _StreamMessageContentState extends State { attachmentBuilders: widget.attachmentBuilders, ), if (widget.message.text case final text? when text.isNotEmpty) - StreamMessageText( - message: widget.message, - onLinkTap: widget.onLinkTap, - onMentionTap: widget.onMentionTap, - onAnyMentionTap: widget.onAnyMentionTap, - showTranslatedText: widget.showTranslatedText, + // The composed row label speaks the message text, so + // the rendered markdown stays out of the semantics tree + // and the row is announced as one phrase. + // + // This deliberately costs the inline link and mention + // spans their own semantics nodes, so a screen reader + // can read a link but not focus or activate it. The + // alternative — a focus stop per span, each repeating + // text the row just spoke — makes every message far + // more tedious to move through than it makes the rare + // link easier to reach. The SwiftUI and React Native + // SDKs collapse plain text the same way, and reserve + // per-child focus for polls, quotes and attachments, + // which is what `explicitChildNodes` keeps reachable + // here too. + hideFromRow( + StreamMessageText( + message: widget.message, + onLinkTap: widget.onLinkTap, + onMentionTap: widget.onMentionTap, + onAnyMentionTap: widget.onAnyMentionTap, + showTranslatedText: widget.showTranslatedText, + ), ), ], ), diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart index 9d2d7b7be6..698919df9e 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart @@ -66,6 +66,14 @@ class StreamMessageFooterProps { /// * **Sending status** — for the current user's own messages. /// * **Timestamp** — always shown, formatted as a short time string. /// * **Edited label** — when the message text has been updated. +/// +/// Inside a [StreamMessageRowLabelScope] none of the four contributes to the +/// semantics tree: [DefaultStreamMessageItem] speaks them all as part of its +/// composed row label, so announcing them here as well would cost four extra +/// focus stops per message that repeat what the row already said. Outside one +/// — [StreamGiphyEphemeralMessage], or a custom layout that uses this footer +/// without a row-level label — they announce themselves, since nothing else +/// would. class DefaultStreamMessageFooter extends StatelessWidget { /// Creates a default message footer with the given [props]. const DefaultStreamMessageFooter({super.key, required this.props}); @@ -79,27 +87,43 @@ class DefaultStreamMessageFooter extends StatelessWidget { final currentUser = StreamChat.of(context).currentUser; final channelKind = core.StreamMessageLayout.channelKindOf(context); + // Inside a row that already speaks this metadata every part here would be + // a focus stop repeating what the row just said; outside one, dropping + // them would leave the metadata unannounced altogether. + final announcedByRow = StreamMessageRowLabelScope.isAnnouncedIn(context); + Widget hideFromRow(Widget child) { + if (!announcedByRow) return child; + return ExcludeSemantics(child: child); + } + Widget? usernameWidget; if (message.user case final user? when channelKind == .group && user.id != currentUser?.id) { - usernameWidget = Text(user.name, maxLines: 1, overflow: .ellipsis); + usernameWidget = hideFromRow( + Text(user.name, maxLines: 1, overflow: .ellipsis), + ); } Widget? statusWidget; if (message.user case final user? when user.id == currentUser?.id) { - statusWidget = StreamMessageSendingStatus(message: message); + statusWidget = hideFromRow( + StreamMessageSendingStatus(message: message), + ); } - final Widget timestampWidget; - if (message.createdAt case final createdAt) { - timestampWidget = StreamTimestamp( - date: createdAt.toLocal(), + final timestampWidget = hideFromRow( + StreamTimestamp( + date: message.createdAt.toLocal(), formatter: (context, date) => Jiffy.parseFromDateTime(date).jm, - ); - } + ), + ); Widget? editedWidget; - if (message.messageTextUpdatedAt != null) { - editedWidget = Text(context.translations.editedMessageLabel); + // A deleted message has no text left to have been edited, so the marker + // would describe history the reader can no longer see. + if (message.messageTextUpdatedAt != null && !message.isDeleted) { + editedWidget = hideFromRow( + Text(context.translations.editedMessageLabel), + ); } return core.StreamMessageMetadata( diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_leading.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_leading.dart index 34b06cc24e..13b91f0691 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_leading.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_leading.dart @@ -95,7 +95,17 @@ class DefaultStreamMessageLeading extends core.NullableStatelessWidget { final theme = core.StreamMessageItemTheme.of(context); final avatarSize = theme.avatarSize ?? StreamAvatarSize.md; - Widget avatar = StreamUserAvatar(user: user, showOnlineIndicator: false); + Widget avatar = StreamUserAvatar( + user: user, + showOnlineIndicator: false, + // A tappable avatar is its own focus stop and needs words; an untappable + // one stays silent, because the composed row label already names the + // sender. + semanticsLabel: switch (props.onTap) { + null => null, + _ => user.name, + }, + ); if (props.onTap case final onTap?) { avatar = GestureDetector(behavior: .opaque, onTap: onTap, child: avatar); } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_sending_status.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_sending_status.dart index 16685a2d03..64289cfa57 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_sending_status.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_sending_status.dart @@ -29,24 +29,10 @@ class StreamMessageSendingStatus extends StatelessWidget { @override Widget build(BuildContext context) { - final attachments = message.attachments; - - final hasNonUrlAttachments = attachments.any((it) => it.type != AttachmentType.urlPreview); - - if (hasNonUrlAttachments && message.state.isOutgoing) { - final attachments = message.attachments; - - final totalAttachments = attachments.length; - final uploadedCount = attachments.where((it) => it.uploadState.isSuccess).length; - - if (uploadedCount < totalAttachments) { - return Text( - context.translations.attachmentsUploadProgressText( - total: totalAttachments, - completed: uploadedCount, - ), - ); - } + // Shared with the row announcement, so the progress a reader sees and the + // progress a screen reader hears are the same number. + if (context.translations.attachmentUploadProgressLabel(message) case final label?) { + return Text(label); } final channel = StreamChannel.maybeOf(context)?.channel; 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 7f8460a44b..c8fb6d261f 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 @@ -94,6 +94,7 @@ class StreamMessageItem extends StatelessWidget { void Function(BuildContext, Message)? onBouncedErrorMessageActions, void Function(Message)? onEditMessageTap, List? attachmentBuilders, + String? semanticsLabel, }) : assert( onReactionsTap == null || onReactionTap == null, 'Only one of onReactionsTap or onReactionTap can be provided. ' @@ -125,6 +126,7 @@ class StreamMessageItem extends StatelessWidget { onBouncedErrorMessageActions: onBouncedErrorMessageActions, onEditMessageTap: onEditMessageTap, attachmentBuilders: attachmentBuilders, + semanticsLabel: semanticsLabel, ); /// Creates a chat message widget from pre-built [props]. @@ -183,6 +185,7 @@ class StreamMessageItemProps { this.onBouncedErrorMessageActions, this.onEditMessageTap, this.attachmentBuilders, + this.semanticsLabel, }) : assert( onReactionsTap == null || onReactionTap == null, 'Only one of onReactionsTap or onReactionTap can be provided. ' @@ -381,6 +384,18 @@ class StreamMessageItemProps { /// priority for attachment types they can handle. final List? attachmentBuilders; + /// Screen-reader label for the whole message row. + /// + /// When null (the default), a label is composed from the message: who sent + /// it ("You said" / " said"), the message body, and when it was sent — + /// so a screen reader announces the row as a single phrase instead of one + /// fragment at a time. + /// + /// Set this to replace that composition, for example when a custom + /// attachment builder renders content the default composition cannot + /// describe. An empty string leaves the row unlabeled. + final String? semanticsLabel; + /// Returns a copy of this [StreamMessageItemProps] with the given fields /// replaced with new values. StreamMessageItemProps copyWith({ @@ -410,6 +425,7 @@ class StreamMessageItemProps { void Function(BuildContext, Message)? onBouncedErrorMessageActions, void Function(Message)? onEditMessageTap, List? attachmentBuilders, + String? semanticsLabel, }) { return StreamMessageItemProps( message: message ?? this.message, @@ -437,6 +453,7 @@ class StreamMessageItemProps { onBouncedErrorMessageActions: onBouncedErrorMessageActions ?? this.onBouncedErrorMessageActions, onEditMessageTap: onEditMessageTap ?? this.onEditMessageTap, attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, + semanticsLabel: semanticsLabel ?? this.semanticsLabel, ); } } @@ -476,7 +493,7 @@ class DefaultStreamMessageItem extends StatelessWidget { final defaults = _StreamMessageItemDefaults( context, isPinned: message.pinned, - isEdited: message.messageTextUpdatedAt != null, + isEdited: message.messageTextUpdatedAt != null && !message.isDeleted, isBouncedWithError: message.isBouncedWithError, state: message.state, ); @@ -491,6 +508,7 @@ class DefaultStreamMessageItem extends StatelessWidget { final effectiveErrorBadgeVisibility = resolve((theme) => theme?.errorBadgeVisibility); final effectiveMetadataVisibility = resolve((theme) => theme?.metadataVisibility); final effectiveRepliesVisibility = resolve((theme) => theme?.repliesVisibility); + final effectiveSemanticsLabel = props.semanticsLabel ?? _defaultSemanticsLabel(context, message); final leadingWidget = effectiveAvatarVisibility.apply( StreamMessageLeading( @@ -620,23 +638,28 @@ class DefaultStreamMessageItem extends StatelessWidget { child: MouseRegion(child: child), ); }, - child: Align( - alignment: StreamMessageLayout.alignmentDirectionalOf(context), - child: Padding( - padding: effectivePadding, - child: core.StreamRow( - mainAxisSize: .min, - spacing: effectiveSpacing, - crossAxisAlignment: .end, - children: [ - ?leadingWidget, - Flexible( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: props.maxWidth), - child: contentWidget, + child: _MessageRowSemantics( + message: message, + label: effectiveSemanticsLabel, + showsMetadata: footerWidget != null, + child: Align( + alignment: StreamMessageLayout.alignmentDirectionalOf(context), + child: Padding( + padding: effectivePadding, + child: core.StreamRow( + mainAxisSize: .min, + spacing: effectiveSpacing, + crossAxisAlignment: .end, + children: [ + ?leadingWidget, + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: props.maxWidth), + child: contentWidget, + ), ), - ), - ], + ], + ), ), ), ), @@ -654,6 +677,112 @@ class DefaultStreamMessageItem extends StatelessWidget { return result; } + // Composes the screen-reader announcement for the whole row: who sent the + // message, what it says, and when it was sent. The fragments this label + // speaks are kept out of the semantics tree where they are rendered — see + // [StreamMessageContent] and [DefaultStreamMessageFooter] — so the row is + // announced once instead of once per fragment. + String _defaultSemanticsLabel(BuildContext context, Message message) { + final translations = context.translations; + final currentUser = StreamChat.maybeOf(context)?.currentUser; + + final parts = [ + _senderAwareBody(context, message, currentUser), + translations.accessibility.formatRecentDateTime(message.createdAt.toLocal()), + // Mirrors the footer, which drops the marker on a deleted message. + if (message.messageTextUpdatedAt != null && !message.isDeleted) translations.editedMessageLabel, + ]; + + return parts.where((it) => it.isNotEmpty).join(', '); + } + + // "You said, ..." for the current user's own messages, " said, ..." for + // everyone else's — the direction a sighted reader gets from the row's + // alignment and bubble color. + // + // A deleted message drops the "said": the sender did not author the + // placeholder, they deleted what they had authored, and "You said, Message + // deleted" reads as if they had spoken those words. + // + // Falls back to the bare body when there is no sender to name, and for + // system messages, which describe a channel event rather than something a + // sender authored. + String _senderAwareBody(BuildContext context, Message message, User? currentUser) { + final body = _bodySemanticsLabel(context, message, currentUser); + if (message.isSystem) return body; + + final sender = message.user; + if (sender == null) return body; + + final a11y = context.translations.accessibility; + final isDeleted = message.isDeleted; + + if (sender.id == currentUser?.id) { + return switch (isDeleted) { + true => a11y.outgoingDeletedMessageLabel(body: body), + false => a11y.outgoingMessageLabel(body: body), + }; + } + + // Announce the full sender name — screen readers are not width-bound like + // the visual footer, and a surname disambiguates when several members share + // a first name. + // + // With no name there is nothing to attribute, so the body stands alone — + // the message text, or "Message deleted" for a deleted message, which is + // already what the formatter returns for one. [User.name] falls back to + // the user id, so this is close to unreachable. + final senderName = sender.name.trim(); + if (senderName.isEmpty) return body; + + return switch (isDeleted) { + true => a11y.incomingDeletedMessageLabel(senderName: senderName, body: body), + false => a11y.incomingMessageLabel(senderName: senderName, body: body), + }; + } + + // The message body — text, attachments, poll, location, or the deleted + // placeholder — from the formatter that already composes those labels for + // the channel list. Omitting `channel` asks for the body without the + // formatter's own speaker prefix; the row composes its own above. + String _bodySemanticsLabel(BuildContext context, Message message, User? currentUser) { + final formatter = StreamChatConfiguration.of(context).messagePreviewFormatter; + + // Mirror what the bubble renders — mentions resolved to display names, and + // the translation only when one is actually shown — so the announcement + // matches the visible text instead of raw `@id` tokens or a translation the + // reader has toggled away. Mirrors [StreamMessageText]. + final translationEnabled = StreamChatConfiguration.of(context).messageTranslation.enabled; + final showsOriginalText = StreamMessageTranslations.isShowingOriginalTextOf(context, message.id); + + // No default language: `translate` returns the message unchanged when the + // reader has none set, which is what should be announced. + final shown = switch (translationEnabled && !showsOriginalText) { + true => message.translate(currentUser?.language), + false => message, + }; + + final withMentions = shown.replaceMentions(linkify: false); + + // The bubble renders the text as markdown. Announcing the source would + // spell out the bracket and paren syntax and read whole URLs aloud, so it + // is resolved to the text that is actually on screen. + final announced = switch (withMentions.text) { + final text? when text.isNotEmpty => withMentions.copyWith(text: text.markdownToPlainText), + _ => withMentions, + }; + + return switch (formatter) { + final AccessibleMessagePreviewFormatter it => it.formatMessageSemanticsLabel( + context, + announced, + currentUser: currentUser, + ), + _ => + formatter.formatMessage(context, announced, currentUser: currentUser).toPlainText(includePlaceholders: false), + }; + } + // Builds the action list for a bounced (moderation-error) message. List _buildBouncedErrorMessageActions({ required BuildContext context, @@ -856,7 +985,7 @@ class DefaultStreamMessageItem extends StatelessWidget { final defaults = _StreamMessageItemDefaults( context, isPinned: message.pinned, - isEdited: message.messageTextUpdatedAt != null, + isEdited: message.messageTextUpdatedAt != null && !message.isDeleted, state: message.state, ); @@ -1178,6 +1307,140 @@ class _StreamMessageItemDefaults extends core.StreamMessageItemThemeData { ); } +/// Annotates a message row with its composed screen-reader label. +/// +/// The label is applied with `container: false` so it merges into the row's own +/// tappable node instead of adding a second, wordless stop on top of it. Where +/// nothing inside the row contributes a node — desktop and web, or a row whose +/// tap and long-press callbacks are both null — this annotation forms that +/// single node itself. +/// +/// `explicitChildNodes` keeps the parts a screen reader must still be able to +/// reach on their own — the attachments, the reaction chips, the quoted +/// message, the replies row — as separate nodes rather than folding them into +/// the row phrase. +/// +/// For the current user's own messages the delivery status is appended to the +/// label, tracking [ChannelClientState.readStream] so it stays in step with +/// the icon in the footer. The icon itself is excluded from the semantics tree +/// (see [DefaultStreamMessageFooter]), so the status is announced as part of +/// the row instead of costing a focus stop of its own. +class _MessageRowSemantics extends StatelessWidget { + const _MessageRowSemantics({ + required this.message, + required this.label, + required this.showsMetadata, + required this.child, + }); + + final Message message; + final String? label; + + // Whether the row renders its metadata footer. A stacked message hides it, + // and so has no delivery status on screen to announce. + final bool showsMetadata; + + final Widget child; + + @override + Widget build(BuildContext context) { + final label = this.label; + final currentUser = StreamChat.maybeOf(context)?.currentUser; + + // Compared as nullables, an authorless message read by nobody signed in + // would match on `null == null` and claim a delivery status it never had. + final isOwnMessage = switch ((message.user, currentUser)) { + (final sender?, final reader?) => sender.id == reader.id, + _ => false, + }; + + // Only the sender sees a delivery status, only a row that renders the + // footer shows one, and a row with no label of its own has nothing to + // append it to. An empty label is the documented way to leave a row + // unlabeled, so it counts as having none — otherwise the row would + // announce a bare ", Sent". + if (!isOwnMessage || !showsMetadata || label == null || label.isEmpty) { + return _annotate(label, child); + } + + final channel = StreamChannel.maybeOf(context)?.channel; + + return BetterStreamBuilder>( + stream: channel?.state?.readStream, + initialData: channel?.state?.read, + // Read state is null until the channel is watched, and a channel can be + // rendered before then. Without this the row itself — not just the + // status it would have carried — would drop out of the tree. + noDataBuilder: (_) => _annotate(label, child), + builder: (context, data) => _annotate( + [ + label, + ?_statusLabel( + context, + isMessageRead: data.readsOf(message: message).isNotEmpty, + isMessageDelivered: data.deliveriesOf(message: message).isNotEmpty, + ), + ].join(', '), + child, + ), + ); + } + + // Applies [label] to the row, and tells the fragments below that the row + // speaks for them. A row with no label of its own makes no such claim, so + // its fragments keep announcing themselves. + Widget _annotate(String? label, Widget child) { + final annotated = Semantics(label: label, explicitChildNodes: true, child: child); + if (label == null || label.isEmpty) return annotated; + return StreamMessageRowLabelScope(child: annotated); + } + + // Mirrors what the message shows for the same state — the footer's + // [StreamMessageSendingStatus], or the error badge on the bubble — so the + // announcement and the visible state never disagree. + String? _statusLabel( + BuildContext context, { + required bool isMessageRead, + required bool isMessageDelivered, + }) { + final translations = context.translations; + + return translations.attachmentUploadProgressLabel(message) ?? + translations.messageDeliveryStatusLabel( + message, + isMessageRead: isMessageRead, + isMessageDelivered: isMessageDelivered, + ); + } +} + +/// Marks a subtree whose metadata is already spoken by a composed row label. +/// +/// [StreamMessageItem] announces the whole message row as a single phrase — +/// sender, body, timestamp, edited marker and delivery status. The widgets +/// that render those fragments consult this scope to decide whether to stay in +/// the semantics tree: inside a row that already speaks them they step out, so +/// the row is announced once instead of once per fragment, and outside one — +/// [StreamGiphyEphemeralMessage], or any custom layout that reuses these +/// components — they keep announcing themselves. +/// +/// See also: +/// +/// * [DefaultStreamMessageFooter], which excludes its metadata inside this +/// scope and exposes it outside one. +class StreamMessageRowLabelScope extends InheritedWidget { + /// Marks [child] as announced by an enclosing composed row label. + const StreamMessageRowLabelScope({super.key, required super.child}); + + /// Whether [context] sits inside a row that speaks its own composed label. + static bool isAnnouncedIn(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType() != null; + } + + @override + bool updateShouldNotify(StreamMessageRowLabelScope oldWidget) => false; +} + StreamMention? _buildMention(Message message, core.StreamMentionType type, String id) { return switch (type) { .user => message.mentionedUsers.firstWhereOrNull((u) => u.id == id)?.let((user) => StreamUserMention(user: user)), diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart index a4eb64397a..e9515af6ff 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart @@ -5,7 +5,9 @@ import 'package:stream_core_flutter/chat.dart'; import '../attachment/thumbnail/media_attachment_thumbnail.dart'; import '../channel/stream_message_preview_text.dart'; import '../components/stream_chat_component_builders.dart'; +import '../stream_chat.dart'; import '../theme/quoted_message_theme.dart'; +import '../utils/extensions.dart'; /// A preview of a quoted message rendered above a reply. /// @@ -39,10 +41,12 @@ class StreamQuotedMessage extends StatelessWidget { StreamQuotedMessage({ super.key, required Message quotedMessage, + Message? replyMessage, BoxConstraints? constraints, VoidCallback? onTap, }) : props = .new( quotedMessage: quotedMessage, + replyMessage: replyMessage, constraints: constraints, onTap: onTap, ); @@ -71,6 +75,7 @@ class StreamQuotedMessageProps { /// Creates properties for a quoted-message preview. const StreamQuotedMessageProps({ required this.quotedMessage, + this.replyMessage, this.constraints, this.onTap, }); @@ -78,6 +83,17 @@ class StreamQuotedMessageProps { /// The message being quoted. final Message quotedMessage; + /// The message doing the quoting. + /// + /// Only used to describe the preview to a screen reader — who replied, and + /// whether they replied to the current user's message + /// ("Han Solo replied to your message") or to someone else's + /// ("You replied to Leia's message"). + /// + /// When null, the preview falls back to announcing the quoted author's name + /// on its own, which says nothing about the reply relationship. + final Message? replyMessage; + /// The constraints to use when displaying the preview. final BoxConstraints? constraints; @@ -112,6 +128,59 @@ class DefaultStreamQuotedMessage extends StatelessWidget { /// The properties that configure this widget. final StreamQuotedMessageProps props; + // Describes the reply relationship the preview stands for: who replied, and + // whose message they replied to. Returns null when there is no replying + // message to attribute, leaving the author's name to be announced as shown. + // + // The current user replying is a separate label rather than their name + // swapped for the word "you", so a locale can conjugate the verb for the + // person doing the replying. + String? _semanticsLabel(BuildContext context) { + final replyMessage = props.replyMessage; + if (replyMessage == null) return null; + + final replier = replyMessage.user; + if (replier == null) return null; + + final quotedAuthor = props.quotedMessage.user; + if (quotedAuthor == null) return null; + + final a11y = context.translations.accessibility; + final currentUser = StreamChat.maybeOf(context)?.currentUser; + + // Compared as nullables, an authorless message read by nobody signed in + // would match on `null == null` and be announced as the reader's own. + bool isCurrentUser(User user) => switch (currentUser) { + final reader? => user.id == reader.id, + _ => false, + }; + + final repliedToOwnMessage = isCurrentUser(quotedAuthor); + + if (isCurrentUser(replier)) { + if (repliedToOwnMessage) return a11y.outgoingReplyToOwnMessageLabel(); + + final authorName = quotedAuthor.name.trim(); + if (authorName.isEmpty) return null; + + return a11y.outgoingReplyToMessageLabel(authorName: authorName); + } + + // With no name there is nothing to attribute the reply to, so the preview + // falls back to announcing the author's name as shown. + final replierName = replier.name.trim(); + if (replierName.isEmpty) return null; + + if (repliedToOwnMessage) { + return a11y.incomingReplyToOwnMessageLabel(replierName: replierName); + } + + final authorName = quotedAuthor.name.trim(); + if (authorName.isEmpty) return null; + + return a11y.incomingReplyToMessageLabel(replierName: replierName, authorName: authorName); + } + @override Widget build(BuildContext context) { final quotedMessage = props.quotedMessage; @@ -143,7 +212,14 @@ class DefaultStreamQuotedMessage extends StatelessWidget { style: effectiveTitleTextStyle, maxLines: 1, overflow: TextOverflow.ellipsis, - child: Text(quotedMessage.user?.name ?? ''), + // The visible title is just the quoted author's name, which leaves a + // screen reader to guess at the relationship. Announcing who replied to + // whom instead turns the preview into a sentence; it merges with the + // body preview below into one phrase. + child: Text( + quotedMessage.user?.name ?? '', + semanticsLabel: _semanticsLabel(context), + ), ); final effectiveSubtitle = DefaultTextStyle.merge( diff --git a/packages/stream_chat_flutter/lib/src/misc/date_divider.dart b/packages/stream_chat_flutter/lib/src/misc/date_divider.dart index 20ce413507..4ebd9c06d4 100644 --- a/packages/stream_chat_flutter/lib/src/misc/date_divider.dart +++ b/packages/stream_chat_flutter/lib/src/misc/date_divider.dart @@ -120,39 +120,56 @@ class StreamDateDivider extends StatelessWidget { final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundSurfaceSubtle; final effectiveBorderRadius = borderRadius ?? BorderRadius.all(radius.max); - return Center( - child: Container( - margin: effectiveMargin, - decoration: BoxDecoration( - color: effectiveBackgroundColor, - borderRadius: effectiveBorderRadius, - ), - child: Padding( - padding: effectiveContentPadding, - child: StreamTimestamp( - date: dateTime.toLocal(), - style: effectiveTextStyle, - formatter: (context, date) { - if (formatter case final formatter?) { - final timestamp = formatter.call(context, date); - if (uppercase) return timestamp.toUpperCase(); - return timestamp; - } - - final timestamp = switch (date) { - _ when date.isToday => context.translations.todayLabel, - _ when date.isYesterday => context.translations.yesterdayLabel, - _ when date.isWithinLastWeek => Jiffy.parseFromDateTime(date).EEEE, - _ when date.isInSameYear => Jiffy.parseFromDateTime(date).MMMd, - _ => Jiffy.parseFromDateTime(date).yMMMd, - }; - - if (uppercase) return timestamp.toUpperCase(); - return timestamp; - }, + final localDate = dateTime.toLocal(); + + return MergeSemantics( + // A date divider separates the list by day, so it doubles as a landmark: + // marking it a header lets a screen reader jump from day to day instead + // of swiping through every message in between. + child: Semantics( + header: true, + child: Center( + child: Container( + margin: effectiveMargin, + decoration: BoxDecoration( + color: effectiveBackgroundColor, + borderRadius: effectiveBorderRadius, + ), + child: Padding( + padding: effectiveContentPadding, + child: StreamTimestamp( + date: localDate, + style: effectiveTextStyle, + // The divider shows a date, never a clock time. Left to its + // default, [StreamTimestamp] would announce + // `formatRecentDateTime`'s "Yesterday at 1:06 PM" and invent a + // time that is nowhere on screen. Announce the date as shown — + // but never uppercased, which some screen readers spell out. + semanticsLabel: _formatDate(context, localDate), + formatter: (context, date) { + final timestamp = _formatDate(context, date); + if (uppercase) return timestamp.toUpperCase(); + return timestamp; + }, + ), + ), ), ), ), ); } + + // The visible date label: the caller's [formatter] when given, otherwise a + // relative-day phrasing that degrades to an absolute date. + String _formatDate(BuildContext context, DateTime date) { + if (formatter case final formatter?) return formatter.call(context, date); + + return switch (date) { + _ when date.isToday => context.translations.todayLabel, + _ when date.isYesterday => context.translations.yesterdayLabel, + _ when date.isWithinLastWeek => Jiffy.parseFromDateTime(date).EEEE, + _ when date.isInSameYear => Jiffy.parseFromDateTime(date).MMMd, + _ => Jiffy.parseFromDateTime(date).yMMMd, + }; + } } diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index a3cc755e77..a4b94e4d54 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:image_size_getter/file_input.dart'; // For compatibility with flutter web. import 'package:image_size_getter/image_size_getter.dart' hide Size; +import 'package:markdown/markdown.dart' as md; import '../../stream_chat_flutter.dart'; import '../audio/audio_playlist_state.dart'; @@ -47,6 +48,30 @@ extension StringExtension on String { @Deprecated('Use sentenceCase instead') String capitalize() => sentenceCase; + /// Returns this markdown source as the plain text it renders as. + /// + /// Link and image syntax collapses to the text a reader sees, emphasis and + /// heading markers drop away, and the remaining blocks are joined by a + /// newline. `check [our docs](https://getstream.io)` becomes + /// `check our docs`. + /// + /// Announcing the source instead would spell out bracket and paren syntax + /// and read whole URLs aloud, so screen-reader labels derived from message + /// text pass through here first. Parsed with the same extension set that + /// `MarkdownBody` defaults to, so the result matches what was rendered. + String get markdownToPlainText { + if (trim().isEmpty) return this; + + final document = md.Document( + extensionSet: md.ExtensionSet.gitHubFlavored, + encodeHtml: false, + ); + + final blocks = document.parse(this).map((it) => it.textContent.trim()).where((it) => it.isNotEmpty); + + return blocks.join('\n'); + } + /// Returns the string in sentence case. /// /// Example: 'hello WORLD' -> 'Hello world' diff --git a/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart b/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart index 14b3d0bd12..80bcc9dedd 100644 --- a/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart +++ b/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart @@ -133,6 +133,10 @@ abstract interface class AccessibleMessagePreviewFormatter implements MessagePre /// [showCaption] mirrors [formatMessage]: when `true` (the default), /// attachment and location labels include the message text as a caption; /// when `false` they fall back to a type-only label. + /// + /// Omitting [channel] returns the body on its own, without a speaker prefix — + /// callers that compose their own prefix rely on this, so implementations + /// must honour it. String formatMessageSemanticsLabel( BuildContext context, Message message, { diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index bba0f2a0f3..8815b3d2af 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: jiffy: ^6.4.5 just_audio: ^0.10.5 lottie: ^3.3.3 + markdown: ^7.3.0 meta: ^1.9.1 path_provider: ^2.1.5 photo_manager: ^3.9.0 diff --git a/packages/stream_chat_flutter/test/src/attachment/builder/attachment_semantics_test.dart b/packages/stream_chat_flutter/test/src/attachment/builder/attachment_semantics_test.dart new file mode 100644 index 0000000000..140ebb2846 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/builder/attachment_semantics_test.dart @@ -0,0 +1,143 @@ +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('media attachment semantics', () { + final currentUser = OwnUser(id: 'current-user'); + final otherUser = User(id: 'other-user', name: 'Han Solo'); + + Widget buildScene(Message message) { + 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); + when(() => channelState.readStream).thenAnswer((_) => Stream.value(const [])); + + return MaterialApp( + home: StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamMessageLayout( + data: const StreamMessageLayoutData(), + child: StreamMessageItem(message: message), + ), + ), + ), + ), + ); + } + + Message message(List attachments, {String? text}) { + return Message( + id: 'test-message', + text: text, + createdAt: DateTime(2026, 8, 26, 15), + user: otherUser, + state: MessageState.sent, + attachments: attachments, + ); + } + + Attachment image({String? title}) { + return Attachment( + type: AttachmentType.image, + title: title, + imageUrl: 'https://example.com/image.png', + ); + } + + List labelsOf(WidgetTester tester) { + return tester.semantics.simulatedAccessibilityTraversal().map((it) => it.label).toList(); + } + + testWidgets('a single image tile announces its type', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(buildScene(message([image()], text: 'look'))); + await tester.pumpAndSettle(); + + // Without a label the tile is still focusable — it opens a preview on + // tap — but announces nothing at all. + final a11y = DefaultTranslations.instance.accessibility; + expect(labelsOf(tester), contains(a11y.imageAttachmentLabel())); + + handle.dispose(); + }); + + testWidgets('a titled image tile announces its title', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(buildScene(message([image(title: 'sunset.png')]))); + await tester.pumpAndSettle(); + + final a11y = DefaultTranslations.instance.accessibility; + expect(labelsOf(tester), contains(a11y.imageAttachmentLabel(title: 'sunset.png'))); + + handle.dispose(); + }); + + testWidgets('gallery tiles announce their position', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene(message([image(), image(title: 'sunset.png'), image()])), + ); + await tester.pumpAndSettle(); + + // Otherwise identical thumbnails need telling apart. + expect( + labelsOf(tester), + containsAllInOrder([ + 'Photo, 1 of 3', + 'Photo, sunset.png, 2 of 3', + 'Photo, 3 of 3', + ]), + ); + + handle.dispose(); + }); + + testWidgets('the row summary is announced before its tiles', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(buildScene(message([image(), image()]))); + await tester.pumpAndSettle(); + + final labels = labelsOf(tester); + expect(labels.first, startsWith('Han Solo said,')); + expect(labels.skip(1), everyElement(startsWith('Photo,'))); + + handle.dispose(); + }); + + testWidgets('an over-full gallery does not announce the overflow badge', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene(message(List.generate(6, (_) => image()))), + ); + await tester.pumpAndSettle(); + + final labels = labelsOf(tester); + // Only four tiles are rendered; their "of 6" already says the gallery + // holds more, so the "+2" badge would be a second, cryptic stop. + expect(labels.where((it) => it.contains('of 6')), hasLength(4)); + expect(labels, isNot(contains('+2'))); + + handle.dispose(); + }); + }); +} 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 9a1c88030a..a2629a043d 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,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_deleted.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; // A deterministic attachment renderer so the post-frame width measurement @@ -61,6 +62,8 @@ Future pumpContent( WidgetTester tester, { required Message message, required Key layoutKey, + Widget? header, + Widget? footer, }) { return tester.pumpWidget( MaterialApp( @@ -76,7 +79,11 @@ Future pumpContent( body: Center( child: SizedBox( width: 300, - child: StreamMessageContent(message: message), + child: StreamMessageContent( + message: message, + header: header, + footer: footer, + ), ), ), ), @@ -170,4 +177,29 @@ void main() { ); }, ); + + // The design shows a deleted message with its timestamp and delivery status + // below the placeholder, same as any other message. The deleted branch used + // to return the bare bubble and drop both slots. + testWidgets('keeps the header and footer slots for a deleted message', (tester) async { + final message = Message( + id: 'deleted-message', + type: MessageType.deleted, + state: MessageState.softDeleted, + user: User(id: 'u1', name: 'Alice'), + ); + + await pumpContent( + tester, + message: message, + layoutKey: GlobalKey<_LayoutHolderState>(), + header: const Text('HEADER'), + footer: const Text('FOOTER'), + ); + await tester.pumpAndSettle(); + + expect(find.byType(StreamMessageDeleted), findsOneWidget); + expect(find.text('FOOTER'), findsOneWidget); + expect(find.text('HEADER'), findsOneWidget); + }); } diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_semantics_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_semantics_test.dart new file mode 100644 index 0000000000..dbf1ae333f --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_semantics_test.dart @@ -0,0 +1,681 @@ +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'; + +// Returns sentinels that no hardcoded English label could produce, so the +// assertions prove the row label was composed through +// `translations.accessibility` rather than matching an inlined string. +class _FakeAccessibilityTranslations extends DefaultAccessibilityTranslations { + const _FakeAccessibilityTranslations(); + + @override + String outgoingMessageLabel({required String body}) => 'OUT:$body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => 'IN:$senderName:$body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => 'OUT-DEL:$body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => 'IN-DEL:$senderName:$body'; + + @override + String formatRecentDateTime(DateTime date) => 'AT-TIME'; +} + +class _FakeLocalizations implements StreamChatLocalizations { + @override + AccessibilityTranslations get accessibility => const _FakeAccessibilityTranslations(); + + @override + String threadReplyCountText(int count) => count == 1 ? 'singular:$count' : 'plural:$count'; + + // Strings the row composes verbatim; only the labels under test are faked. + @override + String get messageDeletedLabel => DefaultTranslations.instance.messageDeletedLabel; + + @override + String get editedMessageLabel => 'EDITED'; + + @override + String photosAttachmentCountText(int count) => DefaultTranslations.instance.photosAttachmentCountText(count); + + @override + String attachmentsUploadProgressText({required int completed, required int total}) => 'UP:$completed/$total'; + + // Anything else throws instead of resolving to null, so an unstubbed lookup + // fails the test loudly rather than rendering an empty label. + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeLocalizationsDelegate extends LocalizationsDelegate { + const _FakeLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) async => _FakeLocalizations(); + + @override + bool shouldReload(_FakeLocalizationsDelegate old) => false; +} + +// A deterministic attachment renderer, so an attachment-only message lays out +// without loading assets. Renders no semantics of its own, keeping the +// assertions about the row label unambiguous. +class _FixedSizeAttachmentBuilder extends StreamAttachmentWidgetBuilder { + const _FixedSizeAttachmentBuilder(); + + @override + bool canHandle(Message message, Map> attachments) { + return attachments.isNotEmpty; + } + + @override + Widget? build( + BuildContext context, + Message message, + Map> attachments, + ) { + return const SizedBox(width: 200, height: 50); + } +} + +void main() { + group('StreamMessageItem sender and direction announcement', () { + // A language is what makes a translated message resolve to the reader's + // own language, in the bubble and in the announcement alike. + final currentUser = OwnUser(id: 'current-user', name: 'Luke Skywalker', language: 'en'); + final otherUser = User(id: 'other-user', name: 'Han Solo'); + + Widget buildScene( + Message message, { + String? semanticsLabel, + OwnUser? reader, + bool signedIn = true, + StreamMessageAlignment alignment = StreamMessageAlignment.start, + StreamMessageStackPosition stackPosition = StreamMessageStackPosition.single, + // Set to true to render a channel that has not been watched yet, whose + // read state is therefore unavailable. + bool unwatchedChannel = false, + // Set to false to resolve the shipped English strings instead of the + // sentinels, pinning what a user actually hears. + bool fakeTranslations = true, + }) { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + final effectiveReader = switch (signedIn) { + true => reader ?? currentUser, + false => null, + }; + when(() => clientState.currentUser).thenReturn(effectiveReader); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(effectiveReader)); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(unwatchedChannel ? null : channelState); + when(() => channelState.readStream).thenAnswer((_) => Stream.value(const [])); + + return MaterialApp( + localizationsDelegates: switch (fakeTranslations) { + true => const [_FakeLocalizationsDelegate()], + false => const >[], + }, + home: StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + child: StreamChannel( + channel: channel, + showLoading: false, + child: Scaffold( + body: StreamMessageLayout( + data: StreamMessageLayoutData(alignment: alignment, stackPosition: stackPosition), + child: StreamMessageItem( + message: message, + semanticsLabel: semanticsLabel, + attachmentBuilders: const [_FixedSizeAttachmentBuilder()], + ), + ), + ), + ), + ), + ); + } + + Message message({ + User? user, + String? text = 'Are we still meeting tomorrow', + int replyCount = 0, + List attachments = const [], + List mentionedUsers = const [], + MessageState state = MessageState.sent, + DateTime? messageTextUpdatedAt, + String type = MessageType.regular, + }) { + return Message( + id: 'test-message', + type: type, + text: text, + createdAt: DateTime(2026, 8, 26, 15), + user: user ?? otherUser, + state: state, + replyCount: replyCount, + attachments: attachments, + mentionedUsers: mentionedUsers, + messageTextUpdatedAt: messageTextUpdatedAt, + ); + } + + // What a screen reader walking the row would read out, in order. + List labelsOf(WidgetTester tester) { + return tester.semantics.simulatedAccessibilityTraversal().map((it) => it.label).toList(); + } + + testWidgets('own message announces the outgoing label', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message(user: currentUser), + alignment: StreamMessageAlignment.end, + ), + ); + await tester.pumpAndSettle(); + + expect(labelsOf(tester), contains('OUT:Are we still meeting tomorrow, AT-TIME, Sent')); + + handle.dispose(); + }); + + testWidgets('incoming message announces the sender name', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(buildScene(message())); + await tester.pumpAndSettle(); + + expect(labelsOf(tester), contains('IN:Han Solo:Are we still meeting tomorrow, AT-TIME')); + + handle.dispose(); + }); + + testWidgets('announces the sender name exactly once', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(buildScene(message())); + await tester.pumpAndSettle(); + + // The footer renders the author name visually; announcing it there as + // well would repeat what the row label already said. + final withSenderName = labelsOf(tester).where((it) => it.contains('Han Solo')); + expect(withSenderName, hasLength(1)); + + handle.dispose(); + }); + + testWidgets('does not announce the message text as a separate stop', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(buildScene(message())); + await tester.pumpAndSettle(); + + expect(find.bySemanticsLabel('Are we still meeting tomorrow'), findsNothing); + + handle.dispose(); + }); + + testWidgets('announces the edited marker as part of the row label', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(buildScene(message(messageTextUpdatedAt: DateTime(2026, 8, 26, 16)))); + await tester.pumpAndSettle(); + + expect( + labelsOf(tester), + contains('IN:Han Solo:Are we still meeting tomorrow, AT-TIME, EDITED'), + ); + + handle.dispose(); + }); + + testWidgets('a deleted message shows and announces no edited marker', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message( + user: currentUser, + type: MessageType.deleted, + state: MessageState.softDeleted, + messageTextUpdatedAt: DateTime(2026, 8, 26, 16), + ), + alignment: StreamMessageAlignment.end, + ), + ); + await tester.pumpAndSettle(); + + // There is no text left to have been edited, so the marker would + // describe history the reader can no longer see. + expect(find.text('EDITED'), findsNothing); + expect(labelsOf(tester), isNot(contains(contains('EDITED')))); + + handle.dispose(); + }); + + testWidgets('announces the translation the bubble shows, and the original when toggled', (tester) async { + final handle = tester.ensureSemantics(); + + final translated = Message( + id: 'translated-message', + text: 'hallo', + createdAt: DateTime(2026, 8, 26, 15), + user: otherUser, + state: MessageState.sent, + i18n: const {'en_text': 'hello', 'language': 'de'}, + ); + + await tester.pumpWidget(buildScene(translated)); + await tester.pumpAndSettle(); + + expect(labelsOf(tester), contains('IN:Han Solo:hello, AT-TIME')); + + // Toggling back to the original has to move the announcement with it, + // or the phrase describes text that is no longer on screen. + StreamMessageTranslations.toggleOriginalText( + tester.element(find.byType(DefaultStreamMessageItem)), + translated.id, + ); + await tester.pumpAndSettle(); + + expect(labelsOf(tester), contains('IN:Han Solo:hallo, AT-TIME')); + + handle.dispose(); + }); + + testWidgets('announces the original text to a reader with no language set', (tester) async { + final handle = tester.ensureSemantics(); + + // The bubble does not translate for a reader with no language, so the + // announcement must not either. + await tester.pumpWidget( + buildScene( + message(text: 'hallo').copyWith(i18n: const {'en_text': 'hello', 'language': 'de'}), + reader: OwnUser(id: 'current-user', name: 'Luke Skywalker'), + ), + ); + await tester.pumpAndSettle(); + + expect(labelsOf(tester), contains('IN:Han Solo:hallo, AT-TIME')); + + handle.dispose(); + }); + + testWidgets('announces mentions by display name, not by id', (tester) async { + final handle = tester.ensureSemantics(); + + final leia = User(id: 'leia-id', name: 'Leia Organa'); + await tester.pumpWidget( + buildScene(message(text: 'Hey @leia-id', mentionedUsers: [leia])), + ); + await tester.pumpAndSettle(); + + expect(labelsOf(tester), contains('IN:Han Solo:Hey @Leia Organa, AT-TIME')); + + handle.dispose(); + }); + + testWidgets('attachment-only message announces the attachment type label', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message( + text: null, + attachments: [Attachment(type: AttachmentType.image, imageUrl: 'https://x.com/a.png')], + ), + ), + ); + await tester.pumpAndSettle(); + + expect(labelsOf(tester), contains('IN:Han Solo:Photo, AT-TIME')); + + handle.dispose(); + }); + + testWidgets('own deleted message announces that you deleted it', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message( + user: currentUser, + type: MessageType.deleted, + state: MessageState.softDeleted, + ), + alignment: StreamMessageAlignment.end, + ), + ); + await tester.pumpAndSettle(); + + final labels = labelsOf(tester); + // A deleted message keeps its footer, so the time and the delivery + // status are on screen and belong in the announcement. + expect(labels, contains('OUT-DEL:Message deleted, AT-TIME, Sent')); + // The placeholder inside the bubble would otherwise repeat it. + expect(labels.where((it) => it.contains('Message deleted')), hasLength(1)); + // A deleted message is not something the sender said. + expect(labels.where((it) => it.startsWith('OUT:')), isEmpty); + + handle.dispose(); + }); + + testWidgets('incoming deleted message announces who deleted it', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message(type: MessageType.deleted, state: MessageState.softDeleted), + ), + ); + await tester.pumpAndSettle(); + + final labels = labelsOf(tester); + expect(labels, contains('IN-DEL:Han Solo:Message deleted, AT-TIME')); + expect(labels.where((it) => it.startsWith('IN:')), isEmpty); + + handle.dispose(); + }); + + testWidgets('semanticsLabel replaces the composed label', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(buildScene(message(), semanticsLabel: 'CUSTOM')); + await tester.pumpAndSettle(); + + final labels = labelsOf(tester); + expect(labels, contains('CUSTOM')); + expect(labels.where((it) => it.startsWith('IN:')), isEmpty); + + handle.dispose(); + }); + + testWidgets('announces one labeled row per platform, in the shipped phrasing', (tester) async { + final handle = tester.ensureSemantics(); + + // Resolves the real strings rather than the sentinels, so this also pins + // what a user actually hears — and it gives the desktop context menu the + // action labels it builds itself. + await tester.pumpWidget(buildScene(message(), fakeTranslations: false)); + await tester.pumpAndSettle(); + + // On mobile the label merges into the row's tappable node; on desktop and + // web nothing inside the row contributes one, so the annotation forms + // that node itself. Either way the row is announced exactly once. + final announced = labelsOf(tester).where((it) => it.startsWith('Han Solo said, ')); + expect(announced, hasLength(1)); + expect(announced.single, startsWith('Han Solo said, Are we still meeting tomorrow, ')); + + handle.dispose(); + }, variant: TargetPlatformVariant.all()); + + testWidgets('composes the shipped English phrasing for a deleted message', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message( + user: currentUser, + type: MessageType.deleted, + state: MessageState.softDeleted, + ), + alignment: StreamMessageAlignment.end, + fakeTranslations: false, + ), + ); + await tester.pumpAndSettle(); + + final announced = labelsOf(tester).singleWhere((it) => it.contains('Message deleted')); + expect(announced, startsWith('You, Message deleted, ')); + + handle.dispose(); + }); + + testWidgets('renders an own message before the channel has been watched', (tester) async { + // The row label tracks the read state to announce a delivery status, and + // that state is null until the channel is watched. Reading it must not + // cost the row the message it was wrapping. + await tester.pumpWidget( + buildScene( + message(user: currentUser), + alignment: StreamMessageAlignment.end, + unwatchedChannel: true, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Are we still meeting tomorrow'), findsOneWidget); + }); + + testWidgets('announces an own message without a status before the channel has been watched', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message(user: currentUser), + alignment: StreamMessageAlignment.end, + unwatchedChannel: true, + ), + ); + await tester.pumpAndSettle(); + + // No read state means no status to mirror, so the row announces what it + // does know rather than a status it cannot verify. + expect(labelsOf(tester), contains('OUT:Are we still meeting tomorrow, AT-TIME')); + + handle.dispose(); + }); + + testWidgets('omits the delivery status when the message does not show one', (tester) async { + final handle = tester.ensureSemantics(); + + // A stacked message hides its metadata, so there is no status on screen + // for the announcement to mirror. + await tester.pumpWidget( + buildScene( + message(user: currentUser), + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.middle, + ), + ); + await tester.pumpAndSettle(); + + expect(labelsOf(tester), contains('OUT:Are we still meeting tomorrow, AT-TIME')); + expect(labelsOf(tester).where((it) => it.contains('Sent')), isEmpty); + + handle.dispose(); + }); + + testWidgets('announces the rendered text rather than its markdown source', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene(message(text: 'check [our docs](https://getstream.io) now')), + ); + await tester.pumpAndSettle(); + + // The bubble renders "check our docs now"; announcing the source would + // spell out the brackets and read the whole URL aloud. + expect(labelsOf(tester), contains('IN:Han Solo:check our docs now, AT-TIME')); + + handle.dispose(); + }); + + testWidgets('announces emphasised text without its markers', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene(message(text: 'that is **really** important')), + ); + await tester.pumpAndSettle(); + + expect(labelsOf(tester), contains('IN:Han Solo:that is really important, AT-TIME')); + + handle.dispose(); + }); + + testWidgets('an empty semanticsLabel leaves the row unlabeled', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message(user: currentUser), + semanticsLabel: '', + alignment: StreamMessageAlignment.end, + ), + ); + await tester.pumpAndSettle(); + + // An own message would otherwise have its delivery status appended to + // nothing, announcing a bare ", Sent". + expect(labelsOf(tester), isNot(contains(startsWith(',')))); + + // The row makes no claim to speak for the message, so the footer keeps + // announcing its own parts rather than the message going silent. + expect(labelsOf(tester).where((it) => it == 'Sent'), hasLength(1)); + + handle.dispose(); + }); + + testWidgets('an empty semanticsLabel leaves the message text audible', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message(user: currentUser), + semanticsLabel: '', + alignment: StreamMessageAlignment.end, + ), + ); + await tester.pumpAndSettle(); + + // Nothing composes a row label to speak the text, so the bubble has to. + expect(find.bySemanticsLabel('Are we still meeting tomorrow'), findsOneWidget); + + handle.dispose(); + }); + + testWidgets("does not take an authorless message for the reader's own", (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + Message( + id: 'authorless-message', + text: 'no one sent this', + createdAt: DateTime(2026, 8, 26, 15), + state: MessageState.sent, + ), + signedIn: false, + ), + ); + await tester.pumpAndSettle(); + + // A null author and a null reader used to compare equal, so the row + // claimed a delivery status for a message nobody sent. + expect(labelsOf(tester).first, 'no one sent this, AT-TIME'); + + handle.dispose(); + }); + + // Non-regression guards: these pass with and without the row label, and + // exist to prove the label did not swallow the stops a screen-reader user + // still needs to reach. + testWidgets('keeps the thread replies row as its own stop', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(buildScene(message(replyCount: 3))); + await tester.pumpAndSettle(); + + expect(labelsOf(tester), contains('plural:3')); + + handle.dispose(); + }); + + testWidgets('announces upload progress while attachments are sending', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message( + user: currentUser, + state: MessageState.sending, + attachments: [ + Attachment(type: AttachmentType.image, imageUrl: 'https://x/1.png'), + Attachment( + type: AttachmentType.image, + imageUrl: 'https://x/2.png', + uploadState: const UploadState.success(), + ), + ], + ), + alignment: StreamMessageAlignment.end, + ), + ); + await tester.pumpAndSettle(); + + // The footer shows the progress count rather than a tick, so the phrase + // carries it too instead of flattening to "Sending". + expect(labelsOf(tester).first, endsWith(', UP:1/2')); + expect(labelsOf(tester).first, isNot(contains('Sending'))); + + handle.dispose(); + }); + + testWidgets('announces a message that failed to send', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message(user: currentUser, state: MessageState.sendingFailed(skipPush: false, skipEnrichUrl: false)), + alignment: StreamMessageAlignment.end, + ), + ); + await tester.pumpAndSettle(); + + // The failure is shown as a badge on the bubble, which is a bare icon + // with no text, so the row phrase is the only place it can be heard. + final failed = DefaultTranslations.instance.accessibility.messageFailedStatusLabel; + expect(labelsOf(tester).first, endsWith(', $failed')); + + handle.dispose(); + }); + + testWidgets('folds the sending status into the row label', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + buildScene( + message(user: currentUser), + alignment: StreamMessageAlignment.end, + ), + ); + await tester.pumpAndSettle(); + + final status = DefaultTranslations.instance.accessibility.messageSentStatusLabel; + final labels = labelsOf(tester); + + // The status rides on the row phrase rather than costing a focus stop of + // its own — an own text message is a single stop. + expect(labels.single, endsWith(', $status')); + + handle.dispose(); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_metadata_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_metadata_test.dart new file mode 100644 index 0000000000..295cfcc68a --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_metadata_test.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_sending_status.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + final currentUser = OwnUser(id: 'me', name: 'Luke Skywalker'); + final otherUser = User(id: 'han', name: 'Han Solo'); + + Widget wrap(Widget body) { + 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); + when(() => channelState.readStream).thenAnswer((_) => Stream.value(const [])); + + return MaterialApp( + home: StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamMessageLayout( + data: const StreamMessageLayoutData(alignment: StreamMessageAlignment.end), + child: Align(alignment: Alignment.topLeft, child: body), + ), + ), + ), + ), + ); + } + + Message message({ + User? user, + DateTime? messageTextUpdatedAt, + }) { + return Message( + id: 'test-message', + text: 'Are we still meeting tomorrow', + createdAt: DateTime(2026, 8, 26, 15), + user: user ?? currentUser, + state: MessageState.sent, + messageTextUpdatedAt: messageTextUpdatedAt, + ); + } + + List labelsOf(WidgetTester tester) { + return tester.semantics.simulatedAccessibilityTraversal().map((it) => it.label).toList(); + } + + group('deleted message metadata', () { + final deleted = Message( + id: 'deleted-message', + type: MessageType.deleted, + createdAt: DateTime(2026, 8, 26, 15), + deletedAt: DateTime(2026, 8, 26, 16), + user: currentUser, + state: MessageState.sent, + messageTextUpdatedAt: DateTime(2026, 8, 26, 15, 30), + ); + + // The design shows a deleted message with the same timestamp and delivery + // status as any other. Asserting on the composed label alone would pass + // even if the footer stopped rendering, since the label is built from the + // message rather than from what was laid out. + testWidgets('renders the footer below the placeholder', (tester) async { + await tester.pumpWidget(wrap(StreamMessageItem(message: deleted))); + await tester.pumpAndSettle(); + + expect(find.byType(StreamMessageFooter), findsOneWidget); + expect(find.byType(StreamTimestamp), findsOneWidget); + }); + + testWidgets('renders the delivery status below the placeholder', (tester) async { + await tester.pumpWidget(wrap(StreamMessageItem(message: deleted))); + await tester.pumpAndSettle(); + + expect(find.byType(StreamMessageSendingStatus), findsOneWidget); + }); + + testWidgets('drops the edited marker', (tester) async { + await tester.pumpWidget(wrap(StreamMessageItem(message: deleted))); + await tester.pumpAndSettle(); + + // There is no text left to have been edited, so the marker would + // describe history the reader can no longer see. + final edited = DefaultTranslations.instance.editedMessageLabel; + expect(find.text(edited), findsNothing); + }); + + testWidgets('keeps the edited marker on a message that still has text', (tester) async { + await tester.pumpWidget( + wrap(StreamMessageItem(message: message(messageTextUpdatedAt: DateTime(2026, 8, 26, 15, 30)))), + ); + await tester.pumpAndSettle(); + + final edited = DefaultTranslations.instance.editedMessageLabel; + expect(find.text(edited), findsOneWidget); + }); + }); + + group('footer semantics outside a labeled row', () { + // StreamGiphyEphemeralMessage builds a footer without going through + // StreamMessageItem, so nothing composes a row label to speak its + // metadata. Excluding the footer there would leave it announcing nothing + // at all. + testWidgets('announces its own parts', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(wrap(StreamMessageFooter(message: message()))); + await tester.pumpAndSettle(); + + final a11y = DefaultTranslations.instance.accessibility; + expect(labelsOf(tester), contains(a11y.messageSentStatusLabel)); + + handle.dispose(); + }); + + testWidgets('announces the timestamp', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(wrap(StreamMessageFooter(message: message()))); + await tester.pumpAndSettle(); + + expect(labelsOf(tester).where((it) => it.contains('3:00 PM')), isNotEmpty); + + handle.dispose(); + }); + + testWidgets('stays silent inside a row that speaks for it', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(wrap(StreamMessageItem(message: message()))); + await tester.pumpAndSettle(); + + // The row label ends with the status, so the footer announcing it again + // would cost a second stop that repeats what the row just said. + final a11y = DefaultTranslations.instance.accessibility; + final sent = labelsOf(tester).where((it) => it.contains(a11y.messageSentStatusLabel)); + expect(sent, hasLength(1)); + expect(sent.single, isNot(equals(a11y.messageSentStatusLabel))); + + handle.dispose(); + }); + + testWidgets('announces the author name that a labeled row would suppress', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + wrap( + StreamMessageLayout( + data: const StreamMessageLayoutData(channelKind: StreamMessageChannelKind.group), + child: StreamMessageFooter(message: message(user: otherUser)), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(labelsOf(tester), contains('Han Solo')); + + handle.dispose(); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_quoted_message_semantics_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_quoted_message_semantics_test.dart new file mode 100644 index 0000000000..e6c08e5595 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_quoted_message_semantics_test.dart @@ -0,0 +1,162 @@ +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('StreamQuotedMessage reply attribution', () { + final currentUser = OwnUser(id: 'me', name: 'Luke Skywalker'); + final han = User(id: 'han', name: 'Han Solo'); + final leia = User(id: 'leia', name: 'Leia Organa'); + + const quotedText = 'are we still meeting tomorrow'; + + Widget wrap(Widget body) { + 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); + when(() => channelState.readStream).thenAnswer((_) => Stream.value(const [])); + + return MaterialApp( + home: StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamMessageLayout( + data: const StreamMessageLayoutData(), + child: Align(alignment: Alignment.topLeft, child: body), + ), + ), + ), + ), + ); + } + + Widget buildScene(Message message) => wrap(StreamMessageItem(message: message)); + + Message reply({required User by, required User to}) { + return Message( + id: 'reply', + text: 'sure thing', + createdAt: DateTime(2026, 8, 26, 15), + user: by, + state: MessageState.sent, + quotedMessage: Message( + id: 'quoted', + text: quotedText, + createdAt: DateTime(2026, 8, 26, 14), + user: to, + state: MessageState.sent, + ), + ); + } + + // The label the quoted preview contributes, merged with the body preview + // below it into a single focus stop. + Future quotedLabel(WidgetTester tester, Message message) async { + await tester.pumpWidget(buildScene(message)); + await tester.pumpAndSettle(); + + return tester.semantics + .simulatedAccessibilityTraversal() + .map((it) => it.label) + .singleWhere((it) => it.contains(quotedText)); + } + + testWidgets('someone replying to the current user names their message', (tester) async { + final handle = tester.ensureSemantics(); + + final label = await quotedLabel(tester, reply(by: han, to: currentUser)); + + // Without this the preview announced only "Han Solo", saying nothing + // about who was replied to. + expect(label, startsWith('Han Solo replied to your message')); + expect(label, contains(quotedText)); + + handle.dispose(); + }); + + testWidgets('the current user replying to someone names that someone', (tester) async { + final handle = tester.ensureSemantics(); + + final label = await quotedLabel(tester, reply(by: currentUser, to: leia)); + + expect(label, startsWith("You replied to Leia Organa's message")); + + handle.dispose(); + }); + + testWidgets('a reply between two other people names both', (tester) async { + final handle = tester.ensureSemantics(); + + final label = await quotedLabel(tester, reply(by: han, to: leia)); + + expect(label, startsWith("Han Solo replied to Leia Organa's message")); + + handle.dispose(); + }); + + testWidgets('the current user replying to themselves', (tester) async { + final handle = tester.ensureSemantics(); + + final label = await quotedLabel(tester, reply(by: currentUser, to: currentUser)); + + expect(label, startsWith('You replied to your message')); + + handle.dispose(); + }); + + testWidgets('the quoted preview stays a stop of its own', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget(buildScene(reply(by: han, to: currentUser))); + await tester.pumpAndSettle(); + + final labels = tester.semantics.simulatedAccessibilityTraversal().map((it) => it.label).toList(); + + // The row phrase comes first, the quote is reachable one level deeper + // and can be activated to jump to the original. + expect(labels.first, startsWith('Han Solo said, sure thing')); + expect(labels[1], startsWith('Han Solo replied to your message')); + + handle.dispose(); + }); + + testWidgets('falls back to the author name without a replying message', (tester) async { + final handle = tester.ensureSemantics(); + + // A consumer building the preview directly gets today's behaviour. + await tester.pumpWidget( + wrap( + StreamQuotedMessage( + quotedMessage: Message( + id: 'quoted', + text: quotedText, + createdAt: DateTime(2026, 8, 26, 14), + user: leia, + state: MessageState.sent, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Leia Organa'), findsOneWidget); + final labels = tester.semantics.simulatedAccessibilityTraversal().map((it) => it.label).toList(); + expect(labels.where((it) => it.contains('replied to')), isEmpty); + + handle.dispose(); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/misc/date_divider_test.dart b/packages/stream_chat_flutter/test/src/misc/date_divider_test.dart index a9eb6fb83a..34497f9d9b 100644 --- a/packages/stream_chat_flutter/test/src/misc/date_divider_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/date_divider_test.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -118,4 +119,76 @@ void main() { expect(find.text('custom format'), findsNothing); }, ); + + group('semantics', () { + // Pinned, and away from midnight: `isToday` compares the date against + // `clock.now()`, so a live clock could reclassify "Today" mid-test. + final now = DateTime(2026, 8, 26, 15); + + Widget buildDivider({required DateTime date, bool uppercase = false}) { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + + return MaterialApp( + home: StreamChat( + client: client, + child: Scaffold( + body: StreamDateDivider(dateTime: date, uppercase: uppercase), + ), + ), + ); + } + + testWidgets('announces the date it shows, without a time', (tester) async { + final handle = tester.ensureSemantics(); + + await withClock(Clock.fixed(now), () async { + await tester.pumpWidget(buildDivider(date: now)); + await tester.pumpAndSettle(); + }); + + final node = tester.semantics.find(find.byType(StreamDateDivider)); + expect(node.label, 'Today'); + // Left to its default, StreamTimestamp would announce + // `formatRecentDateTime`'s "Today at 3:00 PM" and invent a clock time + // that the divider never shows. + expect(node.label, isNot(contains('at'))); + + handle.dispose(); + }); + + testWidgets('is exposed as a header so days can be jumped between', (tester) async { + final handle = tester.ensureSemantics(); + + await withClock(Clock.fixed(now), () async { + await tester.pumpWidget(buildDivider(date: now)); + await tester.pumpAndSettle(); + }); + + expect( + tester.semantics.find(find.byType(StreamDateDivider)), + isSemantics(isHeader: true), + ); + + handle.dispose(); + }); + + testWidgets('announces the date unshouted when displayed uppercase', (tester) async { + final handle = tester.ensureSemantics(); + + await withClock(Clock.fixed(now), () async { + await tester.pumpWidget(buildDivider(date: now, uppercase: true)); + await tester.pumpAndSettle(); + }); + + expect(find.text('TODAY'), findsOneWidget); + // Some screen readers spell out all-caps words letter by letter. + expect(tester.semantics.find(find.byType(StreamDateDivider)).label, 'Today'); + + handle.dispose(); + }); + }); } diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 32cac6298b..9077fbda4c 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -3,6 +3,7 @@ ✅ 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. +- Added the message-list a11y strings behind the new screen-reader announcements: `outgoingMessageLabel` / `incomingMessageLabel`, `outgoingDeletedMessageLabel` / `incomingDeletedMessageLabel`, `outgoingReplyToOwnMessageLabel` / `outgoingReplyToMessageLabel` / `incomingReplyToOwnMessageLabel` / `incomingReplyToMessageLabel`, `attachmentPositionLabel`, and `messageFailedStatusLabel`. Every supported locale ships a native-language implementation. The four reply labels are split by who replied rather than composed from a word for "you", so a locale that inflects its verb for person can conjugate it. 🔄 Changed 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 f0bec00216..6a3e4ea199 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 @@ -963,6 +963,22 @@ class _AccessibilityTranslationsCa extends AccessibilityTranslations { return 'Foto, $title'; } + @override + String attachmentPositionLabel({required int index, required int total}) => '$index de $total'; + + @override + String outgoingReplyToOwnMessageLabel() => 'Has respost al teu propi missatge'; + + @override + String outgoingReplyToMessageLabel({required String authorName}) => 'Has respost al missatge de $authorName'; + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) => '$replierName ha respost al teu missatge'; + + @override + String incomingReplyToMessageLabel({required String replierName, required String authorName}) => + '$replierName ha respost al missatge de $authorName'; + @override String get voiceRecordingPlayTooltip => 'Reprodueix'; @@ -1011,6 +1027,18 @@ class _AccessibilityTranslationsCa extends AccessibilityTranslations { return senderName ?? 'Missatge'; } + @override + String outgoingMessageLabel({required String body}) => 'Has dit, $body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => '$senderName ha dit, $body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => 'Tu, $body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => '$senderName, $body'; + @override String get pollPreviewLabel => 'Enquesta'; @@ -1029,6 +1057,9 @@ class _AccessibilityTranslationsCa extends AccessibilityTranslations { @override String get messageReadStatusLabel => 'Llegit'; + @override + String get messageFailedStatusLabel => "No s'ha pogut enviar el missatge"; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( 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 17a74e894c..74a5ff9b6f 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 @@ -961,6 +961,24 @@ class _AccessibilityTranslationsDe extends AccessibilityTranslations { return 'Foto, $title'; } + @override + String attachmentPositionLabel({required int index, required int total}) => '$index von $total'; + + @override + String outgoingReplyToOwnMessageLabel() => 'Du hast auf deine eigene Nachricht geantwortet'; + + @override + String outgoingReplyToMessageLabel({required String authorName}) => + 'Du hast auf die Nachricht von $authorName geantwortet'; + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) => + '$replierName hat auf deine Nachricht geantwortet'; + + @override + String incomingReplyToMessageLabel({required String replierName, required String authorName}) => + '$replierName hat auf die Nachricht von $authorName geantwortet'; + @override String get voiceRecordingPlayTooltip => 'Abspielen'; @@ -1009,6 +1027,18 @@ class _AccessibilityTranslationsDe extends AccessibilityTranslations { return senderName ?? 'Nachricht'; } + @override + String outgoingMessageLabel({required String body}) => 'Du hast gesagt, $body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => '$senderName hat gesagt, $body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => 'Du, $body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => '$senderName, $body'; + @override String get pollPreviewLabel => 'Umfrage'; @@ -1027,6 +1057,9 @@ class _AccessibilityTranslationsDe extends AccessibilityTranslations { @override String get messageReadStatusLabel => 'Gelesen'; + @override + String get messageFailedStatusLabel => 'Nachricht konnte nicht gesendet werden'; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( 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 b2fd80aaac..905e70069f 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 @@ -961,6 +961,22 @@ class _AccessibilityTranslationsEn extends AccessibilityTranslations { return 'Photo, $title'; } + @override + String attachmentPositionLabel({required int index, required int total}) => '$index of $total'; + + @override + String outgoingReplyToOwnMessageLabel() => 'You replied to your message'; + + @override + String outgoingReplyToMessageLabel({required String authorName}) => "You replied to $authorName's message"; + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) => '$replierName replied to your message'; + + @override + String incomingReplyToMessageLabel({required String replierName, required String authorName}) => + "$replierName replied to $authorName's message"; + @override String get voiceRecordingPlayTooltip => 'Play'; @@ -1009,6 +1025,18 @@ class _AccessibilityTranslationsEn extends AccessibilityTranslations { return senderName ?? 'Message'; } + @override + String outgoingMessageLabel({required String body}) => 'You said, $body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => '$senderName said, $body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => 'You, $body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => '$senderName, $body'; + @override String get pollPreviewLabel => 'Poll'; @@ -1027,6 +1055,9 @@ class _AccessibilityTranslationsEn extends AccessibilityTranslations { @override String get messageReadStatusLabel => 'Read'; + @override + String get messageFailedStatusLabel => 'Message failed to send'; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( 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 c514a65cc6..716130d2ae 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 @@ -965,6 +965,22 @@ class _AccessibilityTranslationsEs extends AccessibilityTranslations { return 'Foto, $title'; } + @override + String attachmentPositionLabel({required int index, required int total}) => '$index de $total'; + + @override + String outgoingReplyToOwnMessageLabel() => 'Respondiste a tu propio mensaje'; + + @override + String outgoingReplyToMessageLabel({required String authorName}) => 'Respondiste al mensaje de $authorName'; + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) => '$replierName respondió a tu mensaje'; + + @override + String incomingReplyToMessageLabel({required String replierName, required String authorName}) => + '$replierName respondió al mensaje de $authorName'; + @override String get voiceRecordingPlayTooltip => 'Reproducir'; @@ -1013,6 +1029,18 @@ class _AccessibilityTranslationsEs extends AccessibilityTranslations { return senderName ?? 'Mensaje'; } + @override + String outgoingMessageLabel({required String body}) => 'Dijiste, $body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => '$senderName dijo, $body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => 'Tú, $body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => '$senderName, $body'; + @override String get pollPreviewLabel => 'Encuesta'; @@ -1031,6 +1059,9 @@ class _AccessibilityTranslationsEs extends AccessibilityTranslations { @override String get messageReadStatusLabel => 'Leído'; + @override + String get messageFailedStatusLabel => 'No se pudo enviar el mensaje'; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( 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 122e2758f9..c34a03938c 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 @@ -967,6 +967,22 @@ class _AccessibilityTranslationsFr extends AccessibilityTranslations { return 'Photo, $title'; } + @override + String attachmentPositionLabel({required int index, required int total}) => '$index sur $total'; + + @override + String outgoingReplyToOwnMessageLabel() => 'Vous avez répondu à votre propre message'; + + @override + String outgoingReplyToMessageLabel({required String authorName}) => 'Vous avez répondu au message de $authorName'; + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) => '$replierName a répondu à votre message'; + + @override + String incomingReplyToMessageLabel({required String replierName, required String authorName}) => + '$replierName a répondu au message de $authorName'; + @override String get voiceRecordingPlayTooltip => 'Lire'; @@ -1015,6 +1031,18 @@ class _AccessibilityTranslationsFr extends AccessibilityTranslations { return senderName ?? 'Message'; } + @override + String outgoingMessageLabel({required String body}) => 'Vous avez dit, $body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => '$senderName a dit, $body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => 'Vous, $body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => '$senderName, $body'; + @override String get pollPreviewLabel => 'Sondage'; @@ -1033,6 +1061,9 @@ class _AccessibilityTranslationsFr extends AccessibilityTranslations { @override String get messageReadStatusLabel => 'Lu'; + @override + String get messageFailedStatusLabel => "Échec de l'envoi du message"; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( 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 33b671432d..a538ffebf1 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 @@ -962,6 +962,22 @@ class _AccessibilityTranslationsHi extends AccessibilityTranslations { return 'फोटो, $title'; } + @override + String attachmentPositionLabel({required int index, required int total}) => '$total में से $index'; + + @override + String outgoingReplyToOwnMessageLabel() => 'आपने अपने संदेश का उत्तर दिया'; + + @override + String outgoingReplyToMessageLabel({required String authorName}) => 'आपने $authorName के संदेश का उत्तर दिया'; + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) => '$replierName ने आपके संदेश का उत्तर दिया'; + + @override + String incomingReplyToMessageLabel({required String replierName, required String authorName}) => + '$replierName ने $authorName के संदेश का उत्तर दिया'; + @override String get voiceRecordingPlayTooltip => 'चलाएँ'; @@ -1010,6 +1026,18 @@ class _AccessibilityTranslationsHi extends AccessibilityTranslations { return senderName ?? 'संदेश'; } + @override + String outgoingMessageLabel({required String body}) => 'आपने कहा, $body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => '$senderName ने कहा, $body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => 'आप, $body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => '$senderName, $body'; + @override String get pollPreviewLabel => 'पोल'; @@ -1028,6 +1056,9 @@ class _AccessibilityTranslationsHi extends AccessibilityTranslations { @override String get messageReadStatusLabel => 'पढ़ा गया'; + @override + String get messageFailedStatusLabel => 'संदेश भेजना विफल रहा'; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( 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 88eba28d75..3750dd2c73 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 @@ -975,6 +975,22 @@ class _AccessibilityTranslationsIt extends AccessibilityTranslations { return 'Foto, $title'; } + @override + String attachmentPositionLabel({required int index, required int total}) => '$index di $total'; + + @override + String outgoingReplyToOwnMessageLabel() => 'Hai risposto al tuo stesso messaggio'; + + @override + String outgoingReplyToMessageLabel({required String authorName}) => 'Hai risposto al messaggio di $authorName'; + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) => '$replierName ha risposto al tuo messaggio'; + + @override + String incomingReplyToMessageLabel({required String replierName, required String authorName}) => + '$replierName ha risposto al messaggio di $authorName'; + @override String get voiceRecordingPlayTooltip => 'Riproduci'; @@ -1023,6 +1039,18 @@ class _AccessibilityTranslationsIt extends AccessibilityTranslations { return senderName ?? 'Messaggio'; } + @override + String outgoingMessageLabel({required String body}) => 'Hai detto, $body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => '$senderName ha detto, $body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => 'Tu, $body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => '$senderName, $body'; + @override String get pollPreviewLabel => 'Sondaggio'; @@ -1041,6 +1069,9 @@ class _AccessibilityTranslationsIt extends AccessibilityTranslations { @override String get messageReadStatusLabel => 'Letto'; + @override + String get messageFailedStatusLabel => 'Invio del messaggio non riuscito'; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( 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 7e1c1d9add..7df754596c 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 @@ -940,6 +940,22 @@ class _AccessibilityTranslationsJa extends AccessibilityTranslations { return '写真、$title'; } + @override + String attachmentPositionLabel({required int index, required int total}) => '$total件中$index件目'; + + @override + String outgoingReplyToOwnMessageLabel() => '自分のメッセージに返信しました'; + + @override + String outgoingReplyToMessageLabel({required String authorName}) => '$authorNameのメッセージに返信しました'; + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) => '$replierNameがあなたのメッセージに返信しました'; + + @override + String incomingReplyToMessageLabel({required String replierName, required String authorName}) => + '$replierNameが$authorNameのメッセージに返信しました'; + @override String get voiceRecordingPlayTooltip => '再生'; @@ -988,6 +1004,18 @@ class _AccessibilityTranslationsJa extends AccessibilityTranslations { return senderName ?? 'メッセージ'; } + @override + String outgoingMessageLabel({required String body}) => '自分のメッセージ、$body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => '$senderNameさんのメッセージ、$body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => '自分、$body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => '$senderNameさん、$body'; + @override String get pollPreviewLabel => '投票'; @@ -1006,6 +1034,9 @@ class _AccessibilityTranslationsJa extends AccessibilityTranslations { @override String get messageReadStatusLabel => '既読'; + @override + String get messageFailedStatusLabel => 'メッセージを送信できませんでした'; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( 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 377d3748a1..d3b432b5db 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 @@ -943,6 +943,22 @@ class _AccessibilityTranslationsKo extends AccessibilityTranslations { return '사진, $title'; } + @override + String attachmentPositionLabel({required int index, required int total}) => '$total개 중 $index번째'; + + @override + String outgoingReplyToOwnMessageLabel() => '내 메시지에 답장했습니다'; + + @override + String outgoingReplyToMessageLabel({required String authorName}) => '$authorName님의 메시지에 답장했습니다'; + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) => '$replierName님이 내 메시지에 답장했습니다'; + + @override + String incomingReplyToMessageLabel({required String replierName, required String authorName}) => + '$replierName님이 $authorName님의 메시지에 답장했습니다'; + @override String get voiceRecordingPlayTooltip => '재생'; @@ -991,6 +1007,18 @@ class _AccessibilityTranslationsKo extends AccessibilityTranslations { return senderName ?? '메시지'; } + @override + String outgoingMessageLabel({required String body}) => '내 메시지, $body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => '$senderName님의 메시지, $body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => '나, $body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => '$senderName님, $body'; + @override String get pollPreviewLabel => '투표'; @@ -1009,6 +1037,9 @@ class _AccessibilityTranslationsKo extends AccessibilityTranslations { @override String get messageReadStatusLabel => '읽음'; + @override + String get messageFailedStatusLabel => '메시지를 보내지 못했습니다'; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( 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 724be8f0ba..4f99e01d5d 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 @@ -946,6 +946,22 @@ class _AccessibilityTranslationsNo extends AccessibilityTranslations { return 'Foto, $title'; } + @override + String attachmentPositionLabel({required int index, required int total}) => '$index av $total'; + + @override + String outgoingReplyToOwnMessageLabel() => 'Du svarte på din egen melding'; + + @override + String outgoingReplyToMessageLabel({required String authorName}) => 'Du svarte på meldingen fra $authorName'; + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) => '$replierName svarte på meldingen din'; + + @override + String incomingReplyToMessageLabel({required String replierName, required String authorName}) => + '$replierName svarte på meldingen fra $authorName'; + @override String get voiceRecordingPlayTooltip => 'Spill av'; @@ -994,6 +1010,18 @@ class _AccessibilityTranslationsNo extends AccessibilityTranslations { return senderName ?? 'Melding'; } + @override + String outgoingMessageLabel({required String body}) => 'Du sa, $body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => '$senderName sa, $body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => 'Du, $body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => '$senderName, $body'; + @override String get pollPreviewLabel => 'Avstemning'; @@ -1012,6 +1040,9 @@ class _AccessibilityTranslationsNo extends AccessibilityTranslations { @override String get messageReadStatusLabel => 'Lest'; + @override + String get messageFailedStatusLabel => 'Meldingen ble ikke sendt'; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( 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 dd34d8302a..305253b6d1 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 @@ -964,6 +964,22 @@ class _AccessibilityTranslationsPt extends AccessibilityTranslations { return 'Foto, $title'; } + @override + String attachmentPositionLabel({required int index, required int total}) => '$index de $total'; + + @override + String outgoingReplyToOwnMessageLabel() => 'Você respondeu à sua própria mensagem'; + + @override + String outgoingReplyToMessageLabel({required String authorName}) => 'Você respondeu à mensagem de $authorName'; + + @override + String incomingReplyToOwnMessageLabel({required String replierName}) => '$replierName respondeu à sua mensagem'; + + @override + String incomingReplyToMessageLabel({required String replierName, required String authorName}) => + '$replierName respondeu à mensagem de $authorName'; + @override String get voiceRecordingPlayTooltip => 'Reproduzir'; @@ -1012,6 +1028,18 @@ class _AccessibilityTranslationsPt extends AccessibilityTranslations { return senderName ?? 'Mensagem'; } + @override + String outgoingMessageLabel({required String body}) => 'Você disse, $body'; + + @override + String incomingMessageLabel({required String senderName, required String body}) => '$senderName disse, $body'; + + @override + String outgoingDeletedMessageLabel({required String body}) => 'Você, $body'; + + @override + String incomingDeletedMessageLabel({required String senderName, required String body}) => '$senderName, $body'; + @override String get pollPreviewLabel => 'Enquete'; @@ -1030,6 +1058,9 @@ class _AccessibilityTranslationsPt extends AccessibilityTranslations { @override String get messageReadStatusLabel => 'Lido'; + @override + String get messageFailedStatusLabel => 'Falha ao enviar a mensagem'; + @override String unreadMessagesLabel({required int count}) { return Intl.plural( diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index d673e9b505..15c7b3eb90 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -501,6 +501,22 @@ void main() { expect(a11y.outgoingMessagePreviewLabel, isNotNull); expect(a11y.incomingMessagePreviewLabel(), isNotNull); expect(a11y.incomingMessagePreviewLabel(senderName: 'Alice'), isNotNull); + expect(a11y.messageFailedStatusLabel, isNotNull); + expect(a11y.attachmentPositionLabel(index: 2, total: 5), isNotNull); + expect(a11y.outgoingReplyToOwnMessageLabel(), isNotNull); + expect(a11y.outgoingReplyToMessageLabel(authorName: 'Bob'), isNotNull); + expect(a11y.incomingReplyToOwnMessageLabel(replierName: 'Alice'), isNotNull); + expect( + a11y.incomingReplyToMessageLabel(replierName: 'Alice', authorName: 'Bob'), + isNotNull, + ); + expect(a11y.outgoingMessageLabel(body: 'Hello'), isNotNull); + expect(a11y.incomingMessageLabel(senderName: 'Alice', body: 'Hello'), isNotNull); + expect(a11y.outgoingDeletedMessageLabel(body: 'Message deleted'), isNotNull); + expect( + a11y.incomingDeletedMessageLabel(senderName: 'Alice', body: 'Message deleted'), + isNotNull, + ); expect(a11y.pollPreviewLabel, isNotNull); expect(a11y.draftPreviewLabel, isNotNull); expect(a11y.systemMessagePreviewLabel, isNotNull);