Skip to content
Merged
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 @@ -30,6 +30,7 @@
- Fixed reaction groups synthesized from legacy `reaction_counts`/`reaction_scores` payloads being discarded at parse time when their score total was zero or negative despite a positive count.
- Fixed `Channel.getReplies` adding the parent message to `ChannelClientState.threads` when a backend returns it alongside the replies, which rendered the thread root twice. The online path now filters it out, matching the offline one.
- Fixed truncated channels dropping to the bottom of the list when sorting by `last_updated`.
- Fixed `ChannelClientState` no longer handling `notification.mark_read` (regression since 9.20.0), which left `unreadCount` stale after `Channel.markRead` on channels the user isn't watching.

## 10.2.0

Expand Down
2 changes: 1 addition & 1 deletion packages/stream_chat/lib/src/client/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3450,7 +3450,7 @@ class ChannelClientState {
void _listenReadEvents() {
_subscriptions
..add(
_channel.on(EventType.messageRead).listen(
_channel.on(EventType.messageRead, EventType.notificationMarkRead).listen(
(event) {
// Skip handling the event if delivered for a thread
if (event.thread != null) return;
Expand Down
172 changes: 172 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,178 @@ void main() {
},
);

test(
'should reset unread count on notification mark read event',
() async {
final currentUser = client.state.currentUser!;
final currentRead = Read(
user: currentUser,
lastRead: DateTime(2020),
unreadMessages: 10,
);

// Setup initial read state
channel.state?.updateChannelState(
channel.state!.channelState.copyWith(
read: [currentRead],
),
);

when(
() => client.channelDeliveryReporter.reconcileDelivery([channel]),
).thenAnswer((_) => Future.value());

// Verify initial state
expect(channel.state?.unreadCount, 10);

// notification.mark_read is delivered on the reading user's own
// connection, so it reaches non-watched channels as well.
client.addEvent(
Event(
cid: channel.cid,
type: EventType.notificationMarkRead,
user: currentUser,
createdAt: DateTime(2022),
lastReadMessageId: 'message-123',
),
);

// Wait for event to be processed
await Future.delayed(Duration.zero);

// Verify read state is updated
final updatedRead = channel.state?.read.first;
expect(updatedRead?.user.id, currentUser.id);
expect(channel.state?.unreadCount, 0);
expect(updatedRead?.lastReadMessageId, 'message-123');
expect(
updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)),
isTrue,
);
},
);

test(
'should preserve delivery info on notification mark read event',
() async {
final currentUser = User(id: 'test-user');
final currentRead = Read(
user: currentUser,
lastRead: DateTime(2020),
unreadMessages: 10,
lastDeliveredAt: DateTime(2021),
lastDeliveredMessageId: 'delivered-msg-456',
);

// Setup initial read state
channel.state?.updateChannelState(
channel.state!.channelState.copyWith(
read: [currentRead],
),
);

client.addEvent(
Event(
cid: channel.cid,
type: EventType.notificationMarkRead,
user: currentUser,
createdAt: DateTime(2022),
lastReadMessageId: 'message-123',
),
);

// Wait for event to be processed
await Future.delayed(Duration.zero);

// Verify read state is updated but delivery info is preserved
final updatedRead = channel.state?.read.first;
expect(updatedRead?.unreadMessages, 0);
expect(
updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)),
isTrue,
);
expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456');
},
);

test(
'should not update channel read state on thread notification mark '
'read event',
() async {
final currentUser = User(id: 'test-user');
final currentRead = Read(
user: currentUser,
lastRead: DateTime(2020),
unreadMessages: 10,
lastReadMessageId: 'channel-msg-1',
);

// Setup initial read state
channel.state?.updateChannelState(
channel.state!.channelState.copyWith(
read: [currentRead],
),
);

client.addEvent(
Event(
cid: channel.cid,
type: EventType.notificationMarkRead,
user: currentUser,
createdAt: DateTime(2022),
lastReadMessageId: 'thread-reply-99',
thread: Thread(
channelCid: channel.cid!,
parentMessageId: 'parent-msg-1',
createdByUserId: currentUser.id,
replyCount: 3,
participantCount: 2,
),
),
);

// Wait for event to be processed
await Future.delayed(Duration.zero);

// Channel read state must be untouched — thread reads
// must not clobber the channel-level Read.
final after = channel.state?.read.first;
expect(after?.unreadMessages, 10);
expect(after?.lastReadMessageId, 'channel-msg-1');
expect(after?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue);
},
);

test(
'should reconcile delivery when notification mark read event is from '
'current user',
() async {
final currentUser = client.state.currentUser;

when(
() => client.channelDeliveryReporter.reconcileDelivery([channel]),
).thenAnswer((_) => Future.value());

client.addEvent(
Event(
cid: channel.cid,
type: EventType.notificationMarkRead,
user: currentUser,
createdAt: DateTime(2022),
lastReadMessageId: 'message-123',
),
);

// Wait for event to be processed
await Future.delayed(Duration.zero);

// Verify reconcileDelivery was called
verify(
() => client.channelDeliveryReporter.reconcileDelivery([channel]),
).called(1);
},
);

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
Loading