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
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 @@ -11,6 +11,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.
- Exported `FilterOperator` alongside `Filter`.

⚠️ 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
141 changes: 141 additions & 0 deletions packages/stream_chat/test/src/client/channel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6812,6 +6812,119 @@ 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 reset unread count on notification mark read event',
() async {
Expand Down Expand Up @@ -10973,6 +11086,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
19 changes: 19 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,26 @@
- Added `onReactionTap` to `StreamMessageItem` and `StreamMessageListView`, reporting the tapped message's `BuildContext` and a `ReactionTapDetails` with the tapped `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction).
- Exported `StreamEphemeralMessage`, the row `StreamMessageListView` builds for ephemeral messages, matching its already-exported `StreamSystemMessage` and `StreamModeratedMessage` siblings.
- Added an `unreadIndicator` parameter to `StreamBackButton` that overlays a widget (typically a `StreamUnreadIndicator`) on the button's top-end corner. Pass `StreamUnreadIndicator(excludeCid: cid)` to show the total unread count of other channels, or `StreamUnreadIndicator.channels(cid: cid)` for a single channel's count.
- 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 falls back to the (now deprecated) `unreadMessagesSeparatorText`, so a class that extends `Translations` keeps showing any custom text it already overrides.
- Exported `UnreadMessagesSeparator`, the divider widget `StreamMessageListView` renders at the unread boundary.
- 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

- `Translations.unreadMessagesSeparatorLabel` is a new interface member. Classes that `extends Translations` (or `GlobalStreamChatLocalizations`) inherit the fallback and need no change, but a class that `implements` either interface directly must add this member — Dart does not inherit method bodies through `implements`. Forward it to your existing `unreadMessagesSeparatorText()` to keep the previous copy.
- Changed the "↑ N unread" jump-to-unread pill to a count fixed when the channel opens, shown as soon as that count is known and dismissed permanently for the session once tapped, dismissed, or scrolled past.
- 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, instead of a fixed, count-less label.
- Tightened `StreamMessageListView`'s automatic mark-read gating to also require that the pre-existing unread boundary has been seen or scrolled past, and that there's no pending manual mark-unread. Channels with no boundary to reach — opened fully read, never opened at all, or tracking unread locally — are unaffected.

⚠️ 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 @@ -58,6 +71,12 @@
- Fixed the "Message deleted" bubble overflowing its maximum width when the localized label is long; the label now wraps instead.
- Fixed the thread scroll-to-bottom button keying off the parent channel's up-to-date state instead of the thread's own scroll position, so it no longer appears while already at the newest reply.
- 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, own and muted-sender messages no longer inflate either counter, and neither counts at all while the user has read receipts disabled.
- 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 the thread-replies footer under a message in the channel being hardcoded English and reading "1 replies" for a single reply; it now uses `threadReplyCountText`, which is localized and correctly singularized.
- Fixed a channel-list row briefly previewing another channel's last message after the list reorders. The preserved last-known message is now dropped when a row is rebound to a different channel, instead of being used as a fallback while the new channel is still loading.
- Fixed the channel list still showing a timestamp next to "No messages yet" after a channel is truncated. `ChannelLastMessageDate` now reads the date off the message the preview actually shows instead of `Channel.lastMessageAt`, which cannot be cleared once a truncation removes every message.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// ignore_for_file: lines_longer_than_80_chars

import 'package:intl/intl.dart';
import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/localization/accessibility_translations.dart';
import 'package:stream_chat_flutter/src/message_list_view/message_list_view.dart';
Expand Down Expand Up @@ -100,8 +101,27 @@ 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".
///
/// Falls back to the count-less `unreadMessagesSeparatorText`, so an
/// implementation written before this method existed — including one that
/// customises only that older string — keeps rendering its own text
/// rather than silently reverting to the built-in copy. Override this to
/// show the count.
///
/// Note that the fallback only helps classes that `extends` (or mix in)
/// [Translations]: Dart does not inherit method bodies through
/// `implements`, so a class implementing this interface directly has to
/// add this member. See the CHANGELOG for the migration.
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 @@ -1290,6 +1310,16 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments
@override
String unreadMessagesSeparatorText() => 'New messages';

@override
String unreadMessagesSeparatorLabel({required int count}) {
return Intl.plural(
count,
one: '$count unread message',
other: '$count unread messages',
locale: 'en',
);
}

@override
String get enableFileAccessMessage =>
'Please enable access to files'
Expand Down
Loading
Loading