Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fcacdd8
fix(ui, localization): announce message sender and direction to scree…
renefloor Aug 26, 2026
6cede96
fix(ui): fold delivery status into the message row phrase, fix date d…
renefloor Aug 26, 2026
660411e
fix(ui, localization): label message-list attachment tiles for screen…
renefloor Aug 26, 2026
d92411e
fix(ui): keep the timestamp and delivery status on a deleted message
renefloor Aug 26, 2026
7b61439
fix(ui): drop the edited marker from a deleted message
renefloor Aug 26, 2026
0f6b347
fix(ui, localization): announce who replied to whom on a quoted message
renefloor Aug 26, 2026
c0bc8b3
docs(changelog): collapse the message a11y entries into one
renefloor Aug 26, 2026
1bef9f4
fix(ui): announce attachment upload progress instead of a bare "Sending"
renefloor Aug 26, 2026
6fdedb2
fix(ui, localization): announce a message that failed to send
renefloor Aug 26, 2026
90b36a1
Merge branch 'master' into renefloor/flu-592-announce-message-sender-…
renefloor Aug 26, 2026
c597c74
fix(ui): announce the message text the bubble actually shows
renefloor Aug 26, 2026
5009b73
docs(ui): explain the unnamed-sender fallback in the message a11y label
renefloor Aug 26, 2026
33d8035
fix(ui): don't default the reader's language when announcing message …
renefloor Aug 26, 2026
06d1155
fix(ui, localization): address review findings on the message a11y work
renefloor Aug 27, 2026
dab683a
fix(ui): don't take an authorless message for the reader's own
renefloor Aug 27, 2026
a5795b9
Merge branch 'master' into renefloor/flu-592-announce-message-sender-…
renefloor Aug 27, 2026
38a4361
refactor(ui): give the message delivery status one source of truth
renefloor Aug 27, 2026
79ce4d8
fix(ui): announce message metadata outside a labeled message row
renefloor Aug 27, 2026
6d25b63
fix(ui): keep an own message rendered before its channel is watched
renefloor Aug 27, 2026
06d4864
fix(ui): announce the delivery status only when the message shows one
renefloor Aug 27, 2026
6519a1e
fix(ui): announce the rendered message text, not its markdown source
renefloor Aug 27, 2026
6b45dc4
fix(localizations): conjugate the quoted-reply labels for whoever rep…
renefloor Aug 27, 2026
a12dfa9
docs(ui): file the new accessibility API under Added
renefloor Aug 27, 2026
3ac850f
improvements on link preview attachment
renefloor Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,27 @@
✅ 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

- Raised minimum Flutter to `>=3.44.0` and Dart SDK to `^3.12.0`.

🐞 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, …" / "<name> 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,
),
),
],
],
],
),
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
),
),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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}
Expand Down Expand Up @@ -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,
);
}

Expand All @@ -54,7 +63,7 @@ class StreamSendingIndicator extends StatelessWidget {
context.streamIcons.checks,
size: size,
color: color ?? colorScheme.textSecondary,
semanticLabel: a11y.messageDeliveredStatusLabel,
semanticLabel: semanticLabel,
);
}

Expand All @@ -63,7 +72,7 @@ class StreamSendingIndicator extends StatelessWidget {
context.streamIcons.checkmark,
size: size,
color: color ?? colorScheme.textSecondary,
semanticLabel: a11y.messageSentStatusLabel,
semanticLabel: semanticLabel,
);
}

Expand All @@ -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;
}
}
Loading
Loading