Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Added `StreamChatClient.isLocalUnreadCountEnabled` (default `false`). When enabled, channels that have read events disabled (e.g. livestream channel types) track their unread count locally, on-device: incoming messages increment it, hard-deleted messages decrement it, and `Channel.markRead` / `markUnread` / `markUnreadByTimestamp` update it locally without a network request — including `Read.lastReadMessageId`, so the unread divider and jump-to-unread button anchor to the right message. Channels that support read receipts are unaffected and keep relying on server-driven unread counts.
- Added `Event.watcherCount`, exposing the server-provided `watcher_count` field on events (e.g. `user.watching.start`, `user.watching.stop`, `message.new`).
- Added `StreamChatNetworkError.type` (a `StreamChatNetworkErrorType` capturing the transport failure kind — connection error, timeout, cancellation, etc.).
- Added `ChannelClientState.isMarkedAsUnread`, reporting whether the current user has an active manual mark-unread on the channel that hasn't been read past yet. Set by `markUnreadLocally` and by a `notification.mark_unread` event for the current user; cleared by `markReadLocally` and by a `message.read` event for the current user.

⚠️ Deprecated

Expand Down
28 changes: 26 additions & 2 deletions packages/stream_chat/lib/src/client/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3473,10 +3473,12 @@ class ChannelClientState {
updateRead([updatedRead]);

// If the read event is from the current user, reconcile the
// channel delivery status with the updated read state.
// channel delivery status with the updated read state, and clear
// any pending manual mark-unread — the user has read past it.
final currentUser = _client.state.currentUser;
if (event.isFromUser(userId: currentUser?.id)) {
_client.channelDeliveryReporter.reconcileDelivery([_channel]);
_isMarkedAsUnread = false;
}
},
),
Expand All @@ -3499,7 +3501,14 @@ class ChannelClientState {
lastDeliveredMessageId: currentRead?.lastDeliveredMessageId,
);

return updateRead([updatedRead]);
updateRead([updatedRead]);

// Only a mark-unread for the current user's own read state
// should gate this device's auto mark-read.
final currentUser = _client.state.currentUser;
if (event.isFromUser(userId: currentUser?.id)) {
_isMarkedAsUnread = true;
}
},
),
)
Expand Down Expand Up @@ -3662,6 +3671,18 @@ class ChannelClientState {
return updateRead([existingUserRead.copyWith(unreadMessages: count)]);
}

/// Whether the current user explicitly marked a message in this channel as
/// unread during this session, without having read past that boundary
/// since.
///
/// Set by [markUnreadLocally] and by a `notification.mark_unread` event for
/// the current user; cleared by [markReadLocally] and by a `message.read`
/// event for the current user. Intended for UI-layer gating that shouldn't
/// immediately undo a manual mark-unread — mirrors the iOS SDK's
/// `ReadStateHandler.isMarkedAsUnread`.
bool get isMarkedAsUnread => _isMarkedAsUnread;
bool _isMarkedAsUnread = false;

/// Marks the channel as read locally, without making a network request.
///
/// Used for channels that track unread counts locally (see
Expand Down Expand Up @@ -3700,6 +3721,8 @@ class ChannelClientState {
// locally can still have delivery receipts enabled. Mirrors what the
// `message.read` event listener does for server-driven channels.
_client.channelDeliveryReporter.reconcileDelivery([_channel]);

_isMarkedAsUnread = false;
}

/// Marks the channel as unread locally, without making a network request.
Expand Down Expand Up @@ -3738,6 +3761,7 @@ class ChannelClientState {
final unread = messages.where((it) => MessageRules.canCountAsUnread(it, _channel)).length;

unreadCount = unread;
_isMarkedAsUnread = true;
}

/// Counts the number of unread messages mentioning the current user.
Expand Down
142 changes: 142 additions & 0 deletions packages/stream_chat/test/src/client/channel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6786,6 +6786,120 @@ void main() {
},
);

group('isMarkedAsUnread', () {
setUp(() {
// A message.read event from the current user also reconciles
// delivery status — stub it so that call doesn't throw.
when(
() => client.channelDeliveryReporter.reconcileDelivery(any()),
).thenAnswer((_) async {});
});

test('defaults to false', () {
expect(channel.state?.isMarkedAsUnread, isFalse);
});

test(
'is set by a notification.mark_unread event from the current user',
() async {
final currentUser = client.state.currentUser!;

final markUnreadEvent = Event(
cid: channel.cid,
type: EventType.notificationMarkUnread,
user: currentUser,
lastReadAt: DateTime(2019),
unreadMessages: 5,
);
client.addEvent(markUnreadEvent);
await Future.delayed(Duration.zero);

expect(channel.state?.isMarkedAsUnread, isTrue);
},
);

test(
'is NOT set by a notification.mark_unread event from a different user',
() async {
final markUnreadEvent = Event(
cid: channel.cid,
type: EventType.notificationMarkUnread,
user: User(id: 'someone-else'),
lastReadAt: DateTime(2019),
unreadMessages: 5,
);
client.addEvent(markUnreadEvent);
await Future.delayed(Duration.zero);

expect(channel.state?.isMarkedAsUnread, isFalse);
},
);

test(
'is cleared by a message.read event from the current user',
() async {
final currentUser = client.state.currentUser!;

client.addEvent(
Event(
cid: channel.cid,
type: EventType.notificationMarkUnread,
user: currentUser,
lastReadAt: DateTime(2019),
unreadMessages: 5,
),
);
await Future.delayed(Duration.zero);
expect(channel.state?.isMarkedAsUnread, isTrue);

client.addEvent(
Event(
cid: channel.cid,
type: EventType.messageRead,
user: currentUser,
createdAt: DateTime(2022),
unreadMessages: 0,
),
);
await Future.delayed(Duration.zero);

expect(channel.state?.isMarkedAsUnread, isFalse);
},
);

test(
'is NOT cleared by a message.read event from a different user',
() async {
final currentUser = client.state.currentUser!;

client.addEvent(
Event(
cid: channel.cid,
type: EventType.notificationMarkUnread,
user: currentUser,
lastReadAt: DateTime(2019),
unreadMessages: 5,
),
);
await Future.delayed(Duration.zero);
expect(channel.state?.isMarkedAsUnread, isTrue);

client.addEvent(
Event(
cid: channel.cid,
type: EventType.messageRead,
user: User(id: 'someone-else'),
createdAt: DateTime(2022),
unreadMessages: 0,
),
);
await Future.delayed(Duration.zero);

expect(channel.state?.isMarkedAsUnread, isTrue);
},
);
});

test('should update read state on message delivered event', () async {
final currentUser = User(id: 'test-user');
final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
Expand Down Expand Up @@ -10775,6 +10889,34 @@ void main() {
},
);

test(
'markUnreadByTimestamp sets isMarkedAsUnread locally',
() async {
final channel = _createLivestreamChannel();
expect(channel.state?.isMarkedAsUnread, isFalse);

await expectLater(
channel.markUnreadByTimestamp(DateTime(2024, 1, 1)),
completes,
);

expect(channel.state?.isMarkedAsUnread, isTrue);
},
);

test(
'markRead clears isMarkedAsUnread locally',
() async {
final channel = _createLivestreamChannel();
await channel.markUnreadByTimestamp(DateTime(2024, 1, 1));
expect(channel.state?.isMarkedAsUnread, isTrue);

await expectLater(channel.markRead(), completes);

expect(channel.state?.isMarkedAsUnread, isFalse);
},
);

group('local read boundary anchors', () {
final start = DateTime(2024, 1, 1);
final messages = [
Expand Down
12 changes: 12 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@
- Added a `size` (`StreamLoadingSpinnerSize`) parameter to `StreamScrollViewLoadingWidget`.
- Added `onReactionTap` to `StreamMessageItem` and `StreamMessageListView`, reporting the tapped message's `BuildContext` and a `ReactionTapDetails` with the tapped `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction).
- Added an `unreadIndicator` parameter to `StreamBackButton` that overlays a widget (typically a `StreamUnreadIndicator`) on the button's top-end corner. Pass `StreamUnreadIndicator(excludeCid: cid)` to show the total unread count of other channels, or `StreamUnreadIndicator.channels(cid: cid)` for a single channel's count.
- Added `StreamMessageListViewConfiguration.shouldMarkRead` to fully override the automatic mark-read gating described below.
- Added `Channel.isMarkedAsUnread` (via `ChannelClientState`), reporting whether the current user has an active manual mark-unread that hasn't been read past yet.
- Added `StreamChannel.openAtFirstUnread` (`stream_chat_flutter_core`), defaulting to `true`. Set to `false` to always open a channel at the latest message instead of scrolling to the first pre-existing unread message.
- Added `Translations.unreadMessagesSeparatorLabel`, used by the default `UnreadMessagesSeparator` to show a count, e.g. "5 unread messages".

🔄 Changed

- Changed the "↑ N unread" jump-to-unread pill to a count fixed when the channel opens, staying on screen for the whole session rather than reacting to the live, shrinking unread count. The pill now shows as soon as that count is known — even before the boundary message itself has loaded — and dismisses permanently for the session once tapped, dismissed, or scrolled past; it no longer reappears when a new message arrives.
- Changed the scroll-to-bottom badge to count only messages that arrive out of view during the current session, rather than being seeded from the channel's unread count. It always resets to 0 once the user reaches the bottom.
- Changed the "unread messages" divider to show a count, starting at the channel's open-time unread total and counting up as further messages arrive during the session — mirroring WhatsApp — instead of a fixed, count-less label.
- Changed `UnreadIndicatorButton` to take a `required int unreadCount` and render unconditionally, dropping its internal read-state subscription — `StreamMessageListView` now owns its visibility.
- Tightened `StreamMessageListView`'s automatic mark-read gating to also require that the pre-existing unread boundary (if any) has been seen or scrolled past, and that there's no pending manual mark-unread — mirroring the iOS SDK. Previously, reaching the bottom with unread messages present was sufficient.

⚠️ Deprecated

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ abstract class Translations {
/// in the [StreamMessageListView]
String unreadMessagesSeparatorText();

/// The label for the unread messages separator in the
/// [StreamMessageListView], e.g. "5 unread messages".
String unreadMessagesSeparatorLabel({required int count});

/// The label for "connected" in [StreamConnectionStatusBuilder]
String get connectedLabel;

Expand Down Expand Up @@ -1290,6 +1294,12 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments
@override
String unreadMessagesSeparatorText() => 'New messages';

@override
String unreadMessagesSeparatorLabel({required int count}) {
if (count == 1) return '1 unread message';
return '$count unread messages';
}

@override
String get enableFileAccessMessage =>
'Please enable access to files'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

/// The information available when deciding whether to automatically mark a
/// [StreamMessageListView]'s channel as read.
///
/// Passed to a caller-supplied predicate on
/// [StreamMessageListViewConfiguration.shouldMarkRead]. Not intended to be
/// constructed directly.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
class StreamMarkReadDetails {
/// Creates a set of details describing the current mark-read gate state.
const StreamMarkReadDetails({
required this.hasSeenLastMessage,
required this.hasSeenFirstUnreadMessage,
required this.isMarkedAsUnread,
required this.unreadCount,
});

/// Whether the bottom of the list has been fully visible at some point
/// since the last successful mark-read — either it's visible right now, or
/// it was visible earlier and the user has since scrolled away.
final bool hasSeenLastMessage;

/// Whether the user has seen (rendered on screen) or scrolled past the
/// pre-existing unread boundary captured when the channel was opened.
///
/// Always `true` when there was nothing to see in the first place — the
/// channel opened fully read, or it uses local unread counts with read
/// events disabled.
final bool hasSeenFirstUnreadMessage;

/// Whether the current user has an active manual mark-unread on this
/// channel that hasn't been read past yet.
final bool isMarkedAsUnread;

/// The channel's current unread count.
final int unreadCount;
}

/// Signature for overriding [StreamMessageListView]'s automatic mark-read
/// gating.
///
/// Return `true` to mark the channel as read, `false` to skip it for now —
/// the list retries on the next relevant scroll or message event.
typedef StreamShouldMarkReadPredicate = bool Function(StreamMarkReadDetails details);
Loading
Loading