Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
37 changes: 37 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,43 @@ Optional local persistence using Drift (SQLite). Implements `ChatPersistenceClie
- Trailing commas: `preserve` (formatter setting)
- Generated files (`.g.dart`, `.freezed.dart`) are excluded from analysis

## Breaking Changes

This is a published SDK: every symbol exported from a package's barrel
(`lib/<package>.dart`) is public API that customers may already depend on.

**Always ask the user for explicit permission before making a change that could break
customer code.** Propose the change, name what breaks and who it affects, offer a
non-breaking alternative if one exists, and wait for a decision. Do not assume a change
is acceptable because it is small, "unlikely to be used", or internally more correct.

Treat all of the following as potentially breaking, even when the diff looks trivial:

- Removing, renaming, or moving a public class, method, getter, typedef, or extension
- Changing a constructor parameter's type, name, or nullability — including changing a
callback signature (e.g. `void Function(String?)` → `void Function()`)
- Adding a `required` parameter to an existing public constructor or method
- Adding a member to, or changing a member's signature on, an interface customers
implement or subclass (e.g. `Translations`, `ChatPersistenceClient`, theme data classes)
- Changing a default value, or changing which widget/behaviour a public widget renders
- Making a public widget stop reading state it used to read (a customer's override or
wrapper may silently stop taking effect — a *behavioural* break with no compile error)
- Changing the semantics of an existing field without changing its type

Behavioural breaks deserve the same scrutiny as compile breaks; they are worse, because
customers get no compiler warning.

When a breaking change is approved:

- Prefer the non-breaking path where it exists: add the new API alongside the old one,
`@Deprecated('Use X instead.')` the old one, and keep it for at least one minor release.
- Make new parameters optional with a default that preserves the previous behaviour.
- Use `refactor(scope)!:` / `feat(scope)!:` in the commit and PR title.
- Record it in the package's `CHANGELOG.md` under `🔄 Changed` (or `⚠️ Deprecated`),
spelling out the migration for customers.
- If a translation key or theme property stops being used, deprecate it rather than
leaving it silently dead.

## PR & Commit Conventions

PR titles follow [Conventional Commits](https://www.conventionalcommits.org/):
Expand Down
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
27 changes: 25 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,17 @@ 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.
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 +3720,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 +3760,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
18 changes: 18 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@
- 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 `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". It has a default implementation that falls back to the (now deprecated) `unreadMessagesSeparatorText`, so existing translation classes keep compiling and any custom text they already override keeps being shown.
- Added an optional `unreadCount` to `UnreadIndicatorButton`. When supplied, the widget renders unconditionally with that count and skips its internal read-state subscription, letting the host own visibility — this is how `StreamMessageListView` now drives it. Omitting it keeps the previous self-subscribing behaviour, and `onJumpTap` keeps its `String? lastReadMessageId` argument, so existing usages are unaffected.

🔄 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.
- 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. Previously, reaching the bottom with unread messages present was sufficient.

⚠️ Deprecated

- Deprecated `StreamMessageReactionPicker.onReactionPicked` in favor of `onReactionSelected`.
- Deprecated `onReactionsTap` (and the `OnReactionsTap` typedef) on `StreamMessageItem` and `StreamMessageListView` in favor of `onReactionTap`.
- Deprecated `height`/`width` of `StreamScrollViewLoadingWidget` in favor of `size`.
- Deprecated `StreamBackButton.showUnreadCount` and `StreamBackButton.channelId` in favor of `unreadIndicator`.
- Deprecated `Translations.unreadMessagesSeparatorText` in favor of `unreadMessagesSeparatorLabel`, which takes a `count`. The old string is still used as the fallback for translation classes that haven't overridden the new one.

🐞 Fixed

Expand All @@ -23,6 +35,12 @@
- Fixed `StreamTypingIndicator` briefly showing typing users from a different context (main channel vs. thread) on its first frame.
- Fixed the attachment picker throwing a `Tooltip` assertion error when a custom `TabbedAttachmentPickerOption` is added without a `title`; the tooltip is now only shown when a title is provided.
- Fixed the `StreamBackButton` unread badge including the currently open channel in its total count.
- Fixed messages arriving while the user was mid-drag or mid-fling being dropped from the scroll-to-bottom badge and the unread divider's count. The "don't fight a scroll in motion" guard ran before the counting, so those arrivals were never counted at all.
- Fixed the scroll-to-bottom badge and unread divider counting messages the channel's own unread count ignores — silent, shadowed, ephemeral, thread-only, restricted, and muted-sender messages no longer inflate either counter.
- Fixed thread reads being blocked whenever the parent channel wasn't up to date. `markThreadRead` no longer consults the channel's `isUpToDate`, which is unrelated to a thread's own read state.
- Fixed the jump-to-unread pill being dismissed by the slightest scroll after marking a message unread. Its anchor is the message the user just acted on, so it starts out on screen; only scrolling past it now retires the pill.
- Fixed the jump-to-unread pill flickering back in and straight out on every new message after being dismissed. The mark-unread reset now runs on the transition into the marked-unread state rather than on every read-state emission while it is set.
- Fixed tapping the jump-to-unread pill doing nothing on a channel the current user has never opened, where there is no read boundary to jump to. It now scrolls to the oldest loaded message and pulls in the next page, leaving the pill up until the real boundary is reached.
- Fixed `StreamMessageListView` jumping several screens when selecting text in a message on desktop or web. The `ScrollablePositionedList` viewports now account for their `anchor` in `getOffsetToReveal`, so implicit reveals (`Scrollable.ensureVisible`, `RenderObject.showOnScreen`) no longer overshoot. [#2862](https://github.com/GetStream/stream-chat-flutter/issues/2862)

## 10.2.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,22 @@ abstract class Translations {

/// The text for showing the unread messages count
/// in the [StreamMessageListView]
@Deprecated('Use unreadMessagesSeparatorLabel instead. Will be removed in the next major version.')
String unreadMessagesSeparatorText();

/// The label for the unread messages separator in the
/// [StreamMessageListView], e.g. "5 unread messages".
///
/// Defaults to the count-less `unreadMessagesSeparatorText` so that
/// implementations written before this method existed — including ones
/// that customise only that older string — keep rendering their own text
/// rather than silently reverting to the built-in copy. Override this to
/// show the count.
String unreadMessagesSeparatorLabel({required int count}) {
// ignore: deprecated_member_use_from_same_package
return unreadMessagesSeparatorText();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

Expand Down Expand Up @@ -1288,8 +1302,15 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments
String get linkDisabledError => 'Links are disabled';

@override
// ignore: deprecated_member_use_from_same_package
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
Loading
Loading