From cee97fc3f580ae9606778077bb16d6e2bf6046ec Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 6 Oct 2026 13:00:49 +0200 Subject: [PATCH 1/4] refactor(llc, ui)!: route the read and delivery receipts through the generated client Co-Authored-By: Claude Opus 5.5 --- .claude/skills/openapi-migration/SKILL.md | 7 +- migrations/v11-migration.md | 64 ++++ openapi-migration/10-messages.md | 1 + openapi-migration/11-channels-and-members.md | 15 +- openapi-migration/16-channel-lifecycle.md | 8 +- openapi-migration/17-read-receipts.md | 73 ++++ openapi-migration/README.md | 9 +- openapi-migration/tool/generate_plan.py | 63 +++- packages/stream_chat/CHANGELOG.md | 5 + .../lib/src/client/channel/channel.dart | 100 ++++-- .../src/client/channel_delivery_reporter.dart | 71 ++-- .../stream_chat/lib/src/client/client.dart | 73 ++-- .../lib/src/core/api/channel_api.dart | 93 ----- .../lib/src/core/models/message_delivery.dart | 27 -- .../src/core/models/message_delivery.g.dart | 12 - .../core/models/request/message_delivery.dart | 24 ++ .../request/message_delivery.freezed.dart | 81 +++++ .../response/mark_delivered_response.dart | 16 + .../mark_delivered_response.freezed.dart | 76 ++++ .../models/response/mark_read_response.dart | 16 + .../response/mark_read_response.freezed.dart | 75 ++++ .../src/repository/channels_repository.dart | 66 ++++ .../src/repository/mapper/channel_mapper.dart | 23 ++ packages/stream_chat/lib/stream_chat.dart | 4 +- .../channel/channel_client_state_test.dart | 41 ++- .../test/src/client/channel/channel_test.dart | 330 +++++------------- .../channel_delivery_reporter_test.dart | 68 +++- .../src/client/client_mark_all_read_test.dart | 45 +++ .../client_mark_channels_delivered_test.dart | 55 +++ .../src/client/client_mark_read_test.dart | 98 ++++++ .../src/client/client_mark_unread_test.dart | 116 ++++++ .../test/src/client/client_test.dart | 97 ----- .../test/src/core/api/channel_api_test.dart | 135 ------- .../message_list_unread_controller.dart | 20 +- .../message_list_view/auto_scroll_test.dart | 4 +- .../src/message_list_view/mark_read_test.dart | 8 +- .../message_list_unread_controller_test.dart | 14 +- .../message_row_rebuild_test.dart | 4 +- .../message_row_updates_test.dart | 4 +- .../unread_divider_test.dart | 4 +- 40 files changed, 1263 insertions(+), 782 deletions(-) create mode 100644 openapi-migration/17-read-receipts.md delete mode 100644 packages/stream_chat/lib/src/core/models/message_delivery.dart delete mode 100644 packages/stream_chat/lib/src/core/models/message_delivery.g.dart create mode 100644 packages/stream_chat/lib/src/core/models/request/message_delivery.dart create mode 100644 packages/stream_chat/lib/src/core/models/request/message_delivery.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/mark_delivered_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/mark_delivered_response.freezed.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/mark_read_response.dart create mode 100644 packages/stream_chat/lib/src/core/models/response/mark_read_response.freezed.dart create mode 100644 packages/stream_chat/test/src/client/client_mark_all_read_test.dart create mode 100644 packages/stream_chat/test/src/client/client_mark_channels_delivered_test.dart create mode 100644 packages/stream_chat/test/src/client/client_mark_read_test.dart create mode 100644 packages/stream_chat/test/src/client/client_mark_unread_test.dart diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index f84b3ddd28..0a01cdd002 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -251,10 +251,9 @@ packages/stream_chat/lib/src/ ``` - **The role decides the folder.** An envelope goes in `response/` (`CreateUserGroupResponse`). A type the caller - passes in to shape a request goes in `request/`, whatever its name: `PartialUpdateUserRequest`, and also - `PaginationParams` and `ThreadOptions`. Every other model stays in `models/` - (`UserGroup`). No `request/` type exists yet: the first arrives when a group migrates one out of - `lib/src/core/api/requests.dart`. + passes in to shape a request goes in `request/`, whatever its name: `MessageDelivery`, and also + `PartialUpdateUserRequest`, `PaginationParams` and `ThreadOptions` when their groups migrate them. Every other + model stays in `models/` (`UserGroup`). Copy `lib/src/core/models/request/message_delivery.dart`. - **Models and envelopes** are `@freezed` classes with a const constructor and `@override final` fields. Copy `lib/src/core/models/user_group.dart` and `lib/src/core/models/response/create_user_group_response.dart`. Envelopes carry `required this.duration`, documented with an example value such as `4.21ms`, plus one field per diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 1b04bde91d..7d7d9e6b89 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -35,6 +35,7 @@ onto Stream's OpenAPI-generated API client. - [Link Previews](#link-previews) - [Partial Updates](#partial-updates) - [Channel Lifecycle](#channel-lifecycle) + - [Read Receipts](#read-receipts) - [Migration Checklist](#migration-checklist) - [For AI Agents](#for-ai-agents) - [Contributing to this guide](#contributing-to-this-guide) @@ -82,6 +83,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | | [**Partial Updates**](#partial-updates) | Channel and member partial updates — `updatePartial`, `updateName`, `updateImage`, slow mode, pin and archive — return a `Result` instead of throwing; their responses take the API's names, `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse`, and `partialMemberUpdate` becomes `updateMemberPartial` | | [**Channel Lifecycle**](#channel-lifecycle) | Hiding, showing and deleting a channel return a `Result` instead of throwing; stopping watching still throws | +| [**Read Receipts**](#read-receipts) | Marking read, unread and delivered return a `Result` instead of throwing; `ChannelDeliveryReporter`'s callback returns a `Result` | | _(filled in per feature as PRs land)_ | | --- @@ -244,6 +246,15 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `Channel.hide` / `show` / `delete` → `Future` | `Future>` / `Future>` / `Future>` | `retyped` | Returns a `Result` instead of throwing. Calling one before the channel is initialized still throws a `StateError` | | `StreamChatApi.channel.hideChannel` / `showChannel` / `deleteChannel` | the `StreamChatClient` methods | `removed` | The endpoints moved to the generated client | | `MemberUpdatePayload(pinned: true).toJson()` / `MemberUpdateType.pinned.name` | `{'pinned': true}` / `'pinned'` | `removed` | Same for `archived`. Or call `pinChannel` / `unpinChannel` / `archiveChannel` / `unarchiveChannel` | +| `StreamChatClient.markChannelRead` / `markThreadRead` / `markAllRead` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `StreamChatClient.markChannelUnread` / `markChannelUnreadByTimestamp` / `markThreadUnread` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing, and carries no value on success | +| `StreamChatClient.markChannelsDelivered` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing | +| `Channel.markRead` / `markThreadRead` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing, including when the current user cannot send read events. Calling one before the channel is initialized still throws a `StateError` | +| `Channel.markUnread` / `markUnreadByTimestamp` / `markThreadUnread` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing, including when the current user cannot send read events, and when `markUnread` counts locally and the message is not among the loaded ones. Calling one before the channel is initialized still throws a `StateError` | +| `MarkChannelsDelivered` = `Future Function(Iterable)` | `Future> Function(Iterable)` | `retyped` | The callback `ChannelDeliveryReporter` takes. Return a failure rather than throwing; the reporter keeps the receipts and sends them with a later batch | +| `MessageDelivery.toJson` | — | `removed` | `MessageDelivery` is a plain class | +| `MessageDelivery` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | +| `StreamChatApi.channel.markRead` / `markUnread` / `markUnreadByTimestamp` / `markThreadRead` / `markThreadUnread` / `markAllRead` / `markChannelsDelivered` | the `StreamChatClient` methods | `removed` | The endpoints moved to the generated client | | _(more added per feature as PRs land)_ | | | | --- @@ -732,6 +743,59 @@ result.fold( > **Why:** the endpoints moved onto the generated client, which returns a `Result` for every call. Each write > answers its own envelope so a field the API adds later reaches you without another break. +### Read Receipts + +**Marking a channel or thread read or unread, and sending delivery receipts, return a `Result` instead of +throwing.** That covers `StreamChatClient.markChannelRead`, `markChannelUnread`, `markChannelUnreadByTimestamp`, +`markThreadRead`, `markThreadUnread`, `markAllRead` and `markChannelsDelivered`, and `Channel.markRead`, +`markUnread`, `markUnreadByTimestamp`, `markThreadRead` and `markThreadUnread`. A `try`/`catch` around one still +compiles, but no longer catches a failed call: read the returned `Result` instead. That includes the +`StreamClientException`s the `Channel` methods raised when the current user cannot send read events in the +channel, and the one `markUnread` raised when it counts unread messages locally and the message is not among the +loaded ones. Both now arrive as failures. + +```dart +// v10 +try { + await channel.markRead(); +} on StreamChatException catch (e) { + report(e); +} + +// v11 +final result = await channel.markRead(); +if (result case Failure(:final error)) report(error); +``` + +**Marking read answers a `MarkReadResponse`, and sending delivery receipts a `MarkDeliveredResponse`**, where v10 +answered `EmptyResponse`. Marking unread carries no value on success. + +**`MarkChannelsDelivered` returns a `Result`.** It is the callback `ChannelDeliveryReporter` takes. A custom one +returns a failure rather than throwing, and the reporter keeps the receipts and sends them with a later batch: + +```dart +// v10 +ChannelDeliveryReporter( + onMarkChannelsDelivered: (deliveries) async { + await client.markChannelsDelivered(deliveries); + }, +); + +// v11 +ChannelDeliveryReporter( + onMarkChannelsDelivered: client.markChannelsDelivered, +); +``` + +**`MessageDelivery` no longer encodes to JSON.** It is a plain class that compares by value and gains `copyWith`. + +**`StreamChatApi.channel.markRead`, `markUnread`, `markUnreadByTimestamp`, `markThreadRead`, `markThreadUnread`, +`markAllRead` and `markChannelsDelivered` are removed.** Call them on `StreamChatClient`. + +> **Why:** the endpoints moved onto the generated client, which returns a `Result` for every call. Marking read +> and sending receipts answer their own envelope, so a field the API adds later reaches you without another +> break. Marking unread answers nothing the API could extend, so it carries no value. + ### Moderation **Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient` diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 7a5d1ea5cc..e7912f4088 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -51,6 +51,7 @@ - `Attachment`: the generated model defines fields our `extraData` currently absorbs. Decide the promotion rules before writing the mapper. - Replace the temporary `@DataSerializable` storage codec (`UserGroup`, `UserGroupMember`): decide between dedicated tables and codecs owned by `stream_chat_persistence` before `Message` and `Attachment` become plain models, then delete the typedef and every `fromData`/`toData` it generates. - `Action` is still the v10 json_serializable class, embedded in `Attachment.actions`, which `Attachment.toJson` writes when sending and `toData` writes to persistence. It becomes a plain model with `Attachment`; group 04 left it alone. +- Expose `MarkReadResponse.event`. [17](17-read-receipts.md) dropped it because its thread carries a `MessageResponse`; the mapper marks it with a TODO. ## Risks diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index cbfda809cb..65ce297757 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -1,8 +1,8 @@ # 11 — Channels, Members & Sync -**Goal:** The biggest group, and the one every controller above it reads through `ChannelState`. [15](15-partial-updates.md) split the partial channel and member updates out of it, and [16](16-channel-lifecycle.md) hiding, showing and deleting a channel. +**Goal:** The biggest group, and the one every controller above it reads through `ChannelState`. [15](15-partial-updates.md) split the partial channel and member updates out of it, [16](16-channel-lifecycle.md) hiding, showing and deleting a channel, and [17](17-read-receipts.md) the read and delivery receipts. -**Size:** 20 hand-written method(s) across 2 file(s) → 19 generated operation(s). +**Size:** 13 hand-written method(s) across 2 file(s) → 15 generated operation(s). > This whole file is generated by `openapi-migration/tool/generate_plan.py`, prose included. > Edit its `GROUPS` entry and re-run the script — edits made here are lost on the next run. @@ -15,7 +15,6 @@ | --- | --- | --- | | `channel_api.dart` | `queryChannel` | `ChannelState` | | `channel_api.dart` | `queryChannels` | `QueryChannelsResponse` | -| `channel_api.dart` | `markAllRead` | `EmptyResponse` | | `channel_api.dart` | `updateChannel` | `UpdateChannelResponse` | | `channel_api.dart` | `acceptChannelInvite` | `AcceptInviteResponse` | | `channel_api.dart` | `rejectChannelInvite` | `RejectInviteResponse` | @@ -24,13 +23,7 @@ | `channel_api.dart` | `removeMembers` | `RemoveMembersResponse` | | `channel_api.dart` | `sendEvent` | `EmptyResponse` | | `channel_api.dart` | `truncateChannel` | `EmptyResponse` | -| `channel_api.dart` | `markRead` | `EmptyResponse` | -| `channel_api.dart` | `markUnread` | `EmptyResponse` | -| `channel_api.dart` | `markUnreadByTimestamp` | `EmptyResponse` | -| `channel_api.dart` | `markThreadRead` | `EmptyResponse` | -| `channel_api.dart` | `markThreadUnread` | `EmptyResponse` | | `channel_api.dart` | `stopWatching` | `EmptyResponse` | -| `channel_api.dart` | `markChannelsDelivered` | `EmptyResponse` | | `general_api.dart` | `sync` | `SyncResponse` | | `general_api.dart` | `queryMembers` | `QueryMembersResponse` | @@ -45,10 +38,6 @@ | `POST` | `/api/v2/chat/channels/{type}/query` | `getOrCreateDistinctChannel` | `ChannelStateResponse` | | `GET` | `/api/v2/chat/channels/{type}/{id}/pinned_messages` | `getPinnedMessages` | `GetPinnedMessagesResponse` | | `POST` | `/api/v2/chat/channels/grouped` | `groupedQueryChannels` | `GroupedQueryChannelsResponse` | -| `POST` | `/api/v2/chat/channels/read` | `markChannelsRead` | `MarkReadResponse` | -| `POST` | `/api/v2/chat/channels/delivered` | `markDelivered` | `MarkDeliveredResponse` | -| `POST` | `/api/v2/chat/channels/{type}/{id}/read` | `markRead` | `MarkReadResponse` | -| `POST` | `/api/v2/chat/channels/{type}/{id}/unread` | `markUnread` | `DurationResponse` | | `POST` | `/api/v2/chat/channels` | `queryChannels` | `QueryChannelsResponse` | | `GET` | `/api/v2/chat/members` | `queryMembers` | `MembersResponse` | | `POST` | `/api/v2/chat/channels/{type}/{id}/event` | `sendEvent` | `EventResponse` | diff --git a/openapi-migration/16-channel-lifecycle.md b/openapi-migration/16-channel-lifecycle.md index 16500045a7..1090ebf40e 100644 --- a/openapi-migration/16-channel-lifecycle.md +++ b/openapi-migration/16-channel-lifecycle.md @@ -38,10 +38,10 @@ - **`StreamChannelListController.deleteChannel` returns the `Result`,** as `muteChannel` does. - **`hard_delete` is not exposed.** The server refuses it from a client-side token, and v10 never sent it. - **Show sends no body,** where v1 sent `{}`; the server accepts both. Hide still sends `clear_history`. -- **The read and delivery receipts stay in 11 for now.** `markRead` and its siblings share endpoints - with the thread read calls, fabricate a response on the local-unread path, and feed - `MessageListUnreadController` and the public `ChannelDeliveryReporter`, which rely on a throw; they - move in a slice of their own. +- **The read and delivery receipts are left to their own slice, + [17](17-read-receipts.md).** `markRead` and its siblings share endpoints with the thread read calls, + fabricate a response on the local-unread path, and feed `MessageListUnreadController` and the public + `ChannelDeliveryReporter`, which relied on a throw. ## Risks diff --git a/openapi-migration/17-read-receipts.md b/openapi-migration/17-read-receipts.md new file mode 100644 index 0000000000..152c7b6298 --- /dev/null +++ b/openapi-migration/17-read-receipts.md @@ -0,0 +1,73 @@ +# 17 — Read Receipts + +**Goal:** Move marking channels and threads read and unread, and the delivery receipts, ahead of [11](11-channels-and-members.md): they answer only a `duration` and a read event, and the event can wait for [10](10-messages.md). + +**Size:** 0 hand-written method(s) across 0 file(s) → 4 generated operation(s). + +> This whole file is generated by `openapi-migration/tool/generate_plan.py`, prose included. +> Edit its `GROUPS` entry and re-run the script — edits made here are lost on the next run. + +## Scope + +### Hand-written today + +| File | Method | Returns | +| --- | --- | --- | + +### Generated operations that cover it + +| Verb | Path | Operation | Response | +| --- | --- | --- | --- | +| `POST` | `/api/v2/chat/channels/read` | `markChannelsRead` | `MarkReadResponse` | +| `POST` | `/api/v2/chat/channels/delivered` | `markDelivered` | `MarkDeliveredResponse` | +| `POST` | `/api/v2/chat/channels/{type}/{id}/read` | `markRead` | `MarkReadResponse` | +| `POST` | `/api/v2/chat/channels/{type}/{id}/unread` | `markUnread` | `DurationResponse` | + +## Decisions taken + +- **Split out of [11](11-channels-and-members.md), ahead of it.** None of the calls reads anything into + client state — the `message.read`, `notification.mark_unread` and `message.delivered` events do that. +- **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts all four in the shared + `coreRoutes`; none is gated, in beta or deprecated. A live run answered the same on v1 and v2 for + every call, with the generated requests' explicit `null`s. +- **Moved off `ChannelApi`:** `markRead`, `markUnread`, `markUnreadByTimestamp`, `markThreadRead`, + `markThreadUnread`, `markAllRead` and `markChannelsDelivered`, each now a `StreamChatClient` method + over `ChannelsRepository`. The v10 names stay: seven calls share four operations, so the spec's + names cannot tell most of them apart. +- **Marking read answers `MarkReadResponse`, carrying only `duration`.** The spec's `event` holds a + `ThreadResponse` whose `parentMessage` is a `MessageResponse`, which needs group 10's mappers. + Exposing it later is additive; the mapper marks it with a TODO. +- **Marking unread answers `DurationResponse`, so it returns `Result`.** The delivery receipts + answer `MarkDeliveredResponse`. +- **`markAllRead` sends an empty `MarkChannelsReadRequest`.** `read_by_channel: null` marks every channel + read, as v1's `{}` did. The handler refuses a request with no body (400 "invalid json data"), but + the generated operation always sends one: it writes `{}` when given no request. +- **`Channel`'s guards return a failure.** A missing read-events capability, and on the local-unread + path a message `markUnread` cannot find, were thrown `StreamClientException`s; they are failures + now. An uninitialized channel still throws a `StateError`. +- **The local-unread path answers `MarkReadResponse(duration: '0ms')`** from `Channel.markRead`, where + v10 built an empty `EmptyResponse`, so it keeps the type of the remote path. +- **`MarkChannelsDelivered` returns `Future>`.** `ChannelDeliveryReporter` relied on a + throw to keep the receipts of a failed send; it now reads the failure. Changing the public typedef + is a break, approved for this group. +- **`MessageDelivery` moves to `models/request/`** as a freezed class without `toJson`. + +## Risks + + +## Definition of done + +- [x] Every method above either routes through `DefaultApi` or is listed here as deliberately left + hand-written, with the reason. +- [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. +- [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group + persists anything. +- [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. +- [x] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. +- [x] Decisions recorded in this file, and the status box ticked in `README.md`. +- [x] Public dartdoc follows [`STYLE_GUIDE.md` § Documentation](../STYLE_GUIDE.md#documentation) + and [`EFFECTIVE_DART_DOC.md`](../EFFECTIVE_DART_DOC.md), including on symbols this group + retyped but whose docs it left alone. +- [x] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each + name states its subject and behaviour. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 7175c8f2ee..03d4e990af 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -22,14 +22,15 @@ generated operations in scope, the decisions that group has to make, its risks, | [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | ☑ | | [09](09-users.md) | Users | 9 | 9 | ☐ | | [10](10-messages.md) | Messages & Search | 14 | 12 | ☐ | -| [11](11-channels-and-members.md) | Channels, Members & Sync | 20 | 19 | ☐ | +| [11](11-channels-and-members.md) | Channels, Members & Sync | 13 | 15 | ☐ | | [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☑ | | [13](13-push-preferences.md) | Push Preferences | 1 | 1 | ☐ | | [14](14-banned-users.md) | Banned Users — split out of 08 | 1 | 1 | ☐ | | [15](15-partial-updates.md) | Partial Updates — split out of 11 | 0 | 2 | ☑ | | [16](16-channel-lifecycle.md) | Channel Lifecycle — split out of 11 | 0 | 3 | ☑ | +| [17](17-read-receipts.md) | Read Receipts — split out of 11 | 0 | 4 | ☑ | -**Coverage:** 77 hand-written methods across 10 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 70 hand-written methods across 10 files, and all 129 generated operations, each claimed by exactly one group. Verified mechanically — see [Keeping this plan honest](#keeping-this-plan-honest). @@ -172,7 +173,9 @@ surfaces before it reaches `Message` and `ChannelState`: - **15** is the partial channel and member updates, split out of 11 so `channel_mapper.dart` lands on calls that read nothing into `ChannelState`. It landed first; the full update waits in 11 for group 10's message mappers. - **16** is hiding, showing and deleting a channel, split out of 11 after 15 because they answer nothing - `channel_mapper.dart` cannot map; the read and delivery receipts stay in 11 for a slice of their own. + `channel_mapper.dart` cannot map. +- **17** is the read and delivery receipts, split out of 11 after 16. They answer only a `duration` and a read + event, which waits for group 10's message mappers. - **12** comes late because it needs its own hand-written multipart client and is the highest-traffic path in the SDK. - **09** is last. Every group before it maps users through `user_mapper.dart` onto today's `User`; 09 migrates the diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 9df2879b35..d8990e8c42 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -570,6 +570,8 @@ def match(verb, path): '`Action` is still the v10 json_serializable class, embedded in `Attachment.actions`, which ' '`Attachment.toJson` writes when sending and `toData` writes to persistence. It becomes a plain model ' 'with `Attachment`; group 04 left it alone.', + 'Expose `MarkReadResponse.event`. [17](17-read-receipts.md) dropped it because its thread carries a ' + '`MessageResponse`; the mapper marks it with a TODO.', ], risks=[ '`message_api.dart` also holds the four draft methods, which belong to group 07 — leave them alone ' @@ -590,10 +592,15 @@ def match(verb, path): 'PATCH /api/v2/chat/channels/{type}/{id}/member', 'POST /api/v2/chat/channels/{type}/{id}/hide', 'POST /api/v2/chat/channels/{type}/{id}/show', - 'DELETE /api/v2/chat/channels/{type}/{id}')), + 'DELETE /api/v2/chat/channels/{type}/{id}', + 'POST /api/v2/chat/channels/{type}/{id}/read', + 'POST /api/v2/chat/channels/{type}/{id}/unread', + 'POST /api/v2/chat/channels/read', + 'POST /api/v2/chat/channels/delivered')), goal='The biggest group, and the one every controller above it reads through `ChannelState`. ' - '[15](15-partial-updates.md) split the partial channel and member updates out of it, and ' - '[16](16-channel-lifecycle.md) hiding, showing and deleting a channel.', + '[15](15-partial-updates.md) split the partial channel and member updates out of it, ' + '[16](16-channel-lifecycle.md) hiding, showing and deleting a channel, and ' + '[17](17-read-receipts.md) the read and delivery receipts.', decisions=[ '`ChannelState`, `ChannelModel` and `Member` are public, persisted, and rebuilt from WebSocket ' 'events. Keep ours and map.', @@ -861,10 +868,52 @@ def match(verb, path): - **`StreamChannelListController.deleteChannel` returns the `Result`,** as `muteChannel` does. - **`hard_delete` is not exposed.** The server refuses it from a client-side token, and v10 never sent it. - **Show sends no body,** where v1 sent `{}`; the server accepts both. Hide still sends `clear_history`. - - **The read and delivery receipts stay in 11 for now.** `markRead` and its siblings share endpoints - with the thread read calls, fabricate a response on the local-unread path, and feed - `MessageListUnreadController` and the public `ChannelDeliveryReporter`, which rely on a throw; they - move in a slice of their own. + - **The read and delivery receipts are left to their own slice, + [17](17-read-receipts.md).** `markRead` and its siblings share endpoints with the thread read calls, + fabricate a response on the local-unread path, and feed `MessageListUnreadController` and the public + `ChannelDeliveryReporter`, which relied on a throw. + """), + risks=[], + done=DONE.replace('- [ ]', '- [x]'), + ), + dict( + num='17', slug='read-receipts', title='Read Receipts', + hand=[], + match=only_ops('POST /api/v2/chat/channels/{type}/{id}/read', + 'POST /api/v2/chat/channels/{type}/{id}/unread', + 'POST /api/v2/chat/channels/read', + 'POST /api/v2/chat/channels/delivered'), + goal='Move marking channels and threads read and unread, and the delivery receipts, ahead of ' + '[11](11-channels-and-members.md): they answer only a `duration` and a read event, and the event can ' + 'wait for [10](10-messages.md).', + decisions=[], + taken=textwrap.dedent("""\ + - **Split out of [11](11-channels-and-members.md), ahead of it.** None of the calls reads anything into + client state — the `message.read`, `notification.mark_unread` and `message.delivered` events do that. + - **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts all four in the shared + `coreRoutes`; none is gated, in beta or deprecated. A live run answered the same on v1 and v2 for + every call, with the generated requests' explicit `null`s. + - **Moved off `ChannelApi`:** `markRead`, `markUnread`, `markUnreadByTimestamp`, `markThreadRead`, + `markThreadUnread`, `markAllRead` and `markChannelsDelivered`, each now a `StreamChatClient` method + over `ChannelsRepository`. The v10 names stay: seven calls share four operations, so the spec's + names cannot tell most of them apart. + - **Marking read answers `MarkReadResponse`, carrying only `duration`.** The spec's `event` holds a + `ThreadResponse` whose `parentMessage` is a `MessageResponse`, which needs group 10's mappers. + Exposing it later is additive; the mapper marks it with a TODO. + - **Marking unread answers `DurationResponse`, so it returns `Result`.** The delivery receipts + answer `MarkDeliveredResponse`. + - **`markAllRead` sends an empty `MarkChannelsReadRequest`.** `read_by_channel: null` marks every channel + read, as v1's `{}` did. The handler refuses a request with no body (400 "invalid json data"), but + the generated operation always sends one: it writes `{}` when given no request. + - **`Channel`'s guards return a failure.** A missing read-events capability, and on the local-unread + path a message `markUnread` cannot find, were thrown `StreamClientException`s; they are failures + now. An uninitialized channel still throws a `StateError`. + - **The local-unread path answers `MarkReadResponse(duration: '0ms')`** from `Channel.markRead`, where + v10 built an empty `EmptyResponse`, so it keeps the type of the remote path. + - **`MarkChannelsDelivered` returns `Future>`.** `ChannelDeliveryReporter` relied on a + throw to keep the receipts of a failed send; it now reads the failure. Changing the public typedef + is a break, approved for this group. + - **`MessageDelivery` moves to `models/request/`** as a freezed class without `toJson`. """), risks=[], done=DONE.replace('- [ ]', '- [x]'), diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index d42f270420..7f11424a49 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -93,6 +93,11 @@ - `MemberUpdatePayload` and `MemberUpdateType` are removed; pass `set: {'pinned': true}` or `unset: ['pinned']` (and `archived`) to `updateMemberPartial`, or call `pinChannel`, `archiveChannel` and their counterparts. - `hideChannel`, `showChannel` and `deleteChannel` on `StreamChatClient`, and `hide`, `show` and `delete` on `Channel`, return a `Result`, `Result` and `Result` instead of throwing. - `StreamChatApi.channel.hideChannel`, `showChannel` and `deleteChannel` are removed; call them on `StreamChatClient` instead. +- `markChannelRead`, `markThreadRead` and `markAllRead` on `StreamChatClient`, and `markRead` and `markThreadRead` on `Channel`, return a `Result` instead of throwing. +- `markChannelUnread`, `markChannelUnreadByTimestamp` and `markThreadUnread` on `StreamChatClient`, and `markUnread`, `markUnreadByTimestamp` and `markThreadUnread` on `Channel`, return a `Result` instead of throwing. +- `StreamChatClient.markChannelsDelivered` returns a `Result` instead of throwing, and `MarkChannelsDelivered`, the callback `ChannelDeliveryReporter` takes, returns a `Future>`. +- `MessageDelivery` no longer encodes to JSON, compares by value and gains `copyWith`. +- `StreamChatApi.channel.markRead`, `markUnread`, `markUnreadByTimestamp`, `markThreadRead`, `markThreadUnread`, `markAllRead` and `markChannelsDelivered` are removed; call them on `StreamChatClient` instead. 🐞 Fixed diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 297881012d..cea2df14a1 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1824,36 +1824,42 @@ class Channel { return res; } - /// Mark all messages as read. + /// Marks this channel as read for the current user. /// - /// Optionally provide a [messageId] if you want to mark channel as - /// read from a particular message onwards. + /// Messages up to and including the one with [messageId] are marked as + /// read, or all of them when [messageId] is null. /// /// If [usesLocalUnreadCount] is `true` for this channel, this updates the - /// unread count locally, on-device, without making a network request. In - /// that case [messageId] is recorded as the read boundary but does **not** - /// narrow the count: the channel is always treated as fully read and the - /// count drops to zero. See [ChannelClientState.markReadLocally]. - Future markRead({String? messageId}) async { + /// unread count locally, on-device, without making a network request, and + /// returns a [MarkReadResponse] whose `duration` is `0ms`. In that case + /// [messageId] is recorded as the read boundary but does **not** narrow the + /// count: the channel is always treated as fully read and the count drops + /// to zero. See [ChannelClientState.markReadLocally]. + /// + /// Returns a failure if [canUseReadReceipts] is `false`, unless [usesLocalUnreadCount] is `true`. + Future> markRead({String? messageId}) async { _checkInitialized(); if (usesLocalUnreadCount) { state!.markReadLocally(messageId: messageId); - return EmptyResponse(); + return const Result.success(MarkReadResponse(duration: '0ms')); } if (!canUseReadReceipts) { - throw const StreamClientException( - message: ''' + return const Result.failure( + StreamClientException( + message: ''' Cannot mark as read: Channel does not support read events. Enable read_events in your channel type configuration.''', + ), ); } return _client.markChannelRead(id!, type, messageId: messageId); } - /// Marks the channel as unread by a given [messageId]. + /// Marks this channel as unread for the current user, from the message + /// with [messageId] onwards. /// /// All messages from the provided message onwards will be marked as unread, /// **including** the message itself. Contrast with @@ -1863,8 +1869,10 @@ class Channel { /// If [usesLocalUnreadCount] is `true` for this channel, this updates the /// unread count locally, on-device, without making a network request. The /// message must be part of the locally-known messages ([Channel.messages]) - /// for the count to be recomputed. - Future markUnread(String messageId) async { + /// for the count to be recomputed; otherwise this returns a failure. + /// + /// Returns a failure if [canUseReadReceipts] is `false`, unless [usesLocalUnreadCount] is `true`. + Future> markUnread(String messageId) async { _checkInitialized(); if (usesLocalUnreadCount) { @@ -1873,11 +1881,13 @@ class Channel { final messages = state!.messages; final anchorIndex = messages.indexWhere((it) => it.id == messageId); if (anchorIndex < 0) { - throw StreamClientException( - message: - ''' + return Result.failure( + StreamClientException( + message: + ''' Cannot mark as unread: Message "$messageId" was not found in the locally-known messages for this channel.''', + ), ); } @@ -1891,32 +1901,36 @@ class Channel { // back into the read set if the two share an identical `createdAt`. final lastRead = anchor.createdAt.subtract(const Duration(microseconds: 1)); state!.markUnreadLocally(lastRead: lastRead, lastReadMessageId: previous?.id); - return EmptyResponse(); + return const Result.success(null); } if (!canUseReadReceipts) { - throw const StreamClientException( - message: ''' + return const Result.failure( + StreamClientException( + message: ''' Cannot mark as unread: Channel does not support read events. Enable read_events in your channel type configuration.''', + ), ); } return _client.markChannelUnread(id!, type, messageId); } - /// Marks the channel as unread by a given [timestamp]. + /// Marks the messages of this channel created after [timestamp] as unread + /// for the current user. /// - /// All messages after the provided timestamp will be marked as unread. This - /// boundary is **exclusive**: a message created at exactly [timestamp] stays - /// read. Contrast with [markUnread], which is inclusive of the message it is - /// given — `markUnread(m.id)` is equivalent to + /// The boundary is **exclusive**: a message created at exactly [timestamp] + /// stays read. Contrast with [markUnread], which is inclusive of the message + /// it is given — `markUnread(m.id)` is equivalent to /// `markUnreadByTimestamp(m.createdAt - 1µs)`, not to /// `markUnreadByTimestamp(m.createdAt)`. /// /// If [usesLocalUnreadCount] is `true` for this channel, this updates the /// unread count locally, on-device, without making a network request. - Future markUnreadByTimestamp(DateTime timestamp) async { + /// + /// Returns a failure if [canUseReadReceipts] is `false`, unless [usesLocalUnreadCount] is `true`. + Future> markUnreadByTimestamp(DateTime timestamp) async { _checkInitialized(); if (usesLocalUnreadCount) { @@ -1930,44 +1944,56 @@ class Channel { lastRead: timestamp, lastReadMessageId: lastReadMessage?.id, ); - return EmptyResponse(); + return const Result.success(null); } if (!canUseReadReceipts) { - throw const StreamClientException( - message: ''' + return const Result.failure( + StreamClientException( + message: ''' Cannot mark as unread: Channel does not support read events. Enable read_events in your channel type configuration.''', + ), ); } return _client.markChannelUnreadByTimestamp(id!, type, timestamp); } - /// Mark the thread with [threadId] in the channel as read. - Future markThreadRead(String threadId) async { + /// Marks a thread in this channel as read for the current user. + /// + /// [threadId] is the id of the thread's parent message. Returns a failure + /// if [canUseReadReceipts] is `false`. + Future> markThreadRead(String threadId) async { _checkInitialized(); if (!canUseReadReceipts) { - throw const StreamClientException( - message: ''' + return const Result.failure( + StreamClientException( + message: ''' Cannot mark thread as read: Channel does not support read events. Enable read_events in your channel type configuration.''', + ), ); } return _client.markThreadRead(id!, type, threadId); } - /// Mark the thread with [threadId] in the channel as unread. - Future markThreadUnread(String threadId) async { + /// Marks a thread in this channel as unread for the current user. + /// + /// [threadId] is the id of the thread's parent message. Returns a failure + /// if [canUseReadReceipts] is `false`. + Future> markThreadUnread(String threadId) async { _checkInitialized(); if (!canUseReadReceipts) { - throw const StreamClientException( - message: ''' + return const Result.failure( + StreamClientException( + message: ''' Cannot mark thread as unread: Channel does not support read events. Enable read_events in your channel type configuration.''', + ), ); } diff --git a/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart index ff4ec969a6..84891e05a0 100644 --- a/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart +++ b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart @@ -1,18 +1,19 @@ import 'package:rate_limiter/rate_limiter.dart'; -import 'package:stream_core/stream_core.dart' show StreamLogger; +import 'package:stream_core/stream_core.dart' show Failure, Result, StreamLogger; import 'package:synchronized/synchronized.dart'; import '../core/models/message.dart'; -import '../core/models/message_delivery.dart'; +import '../core/models/request/message_delivery.dart'; import '../core/util/message_rules.dart'; import 'channel/channel.dart'; -/// A callback that sends delivery receipts for multiple channels. +/// Signature for a callback that sends delivery receipts for multiple channels. /// -/// Each [MessageDeliveryInfo] represents an acknowledgment that the current -/// user has received a message. +/// Each [MessageDelivery] represents an acknowledgment that the current +/// user has received a message. The returned [Result] reports whether the +/// receipts were sent. typedef MarkChannelsDelivered = - Future Function( + Future> Function( Iterable deliveries, ); @@ -41,6 +42,8 @@ class ChannelDeliveryReporter { /// The callback invoked to send delivery receipts. /// /// Receives delivery receipts acknowledging that messages were received. + /// When it returns a failure, or throws, the receipts are kept and sent + /// with a later batch. final MarkChannelsDelivered onMarkChannelsDelivered; final _deliveryCandidatesLock = Lock(); @@ -166,30 +169,40 @@ class ChannelDeliveryReporter { _logger.d(() => 'Marking ${messageDeliveries.length} channels as delivered'); + final result = await _markDelivered(messageDeliveries); + if (result case Failure(:final error, :final stackTrace)) { + _logger.w(() => 'Failed to mark channels as delivered', error: error, stackTrace: stackTrace); + return; + } + + // Clear the successfully delivered candidates. If a channel's message ID + // has changed since we started delivery, keep it for the next batch. + await _deliveryCandidatesLock.synchronized(() { + for (final delivery in messageDeliveries) { + final deliveredChannelCid = delivery.channelCid; + final deliveredMessageId = delivery.messageId; + + final currentMessage = _deliveryCandidates[deliveredChannelCid]; + // Skip removal if a newer message has been added while we were + // processing the current batch. + if (currentMessage?.id != deliveredMessageId) continue; + _deliveryCandidates.remove(deliveredChannelCid); + } + + // Schedule the next batch if there are remaining candidates. + if (_deliveryCandidates.isNotEmpty) { + _throttledMarkCandidatesAsDelivered.call(); + } + }); + } + + // Calls [onMarkChannelsDelivered], reporting an error it throws as a + // failure so the receipts are kept for a later batch. + Future> _markDelivered(Iterable deliveries) async { try { - await onMarkChannelsDelivered(messageDeliveries); - - // Clear the successfully delivered candidates. If a channel's message ID - // has changed since we started delivery, keep it for the next batch. - await _deliveryCandidatesLock.synchronized(() { - for (final delivery in messageDeliveries) { - final deliveredChannelCid = delivery.channelCid; - final deliveredMessageId = delivery.messageId; - - final currentMessage = _deliveryCandidates[deliveredChannelCid]; - // Skip removal if a newer message has been added while we were - // processing the current batch. - if (currentMessage?.id != deliveredMessageId) continue; - _deliveryCandidates.remove(deliveredChannelCid); - } - - // Schedule the next batch if there are remaining candidates. - if (_deliveryCandidates.isNotEmpty) { - _throttledMarkCandidatesAsDelivered.call(); - } - }); - } catch (e, stk) { - _logger.w(() => 'Failed to mark channels as delivered', error: e, stackTrace: stk); + return await onMarkChannelsDelivered(deliveries); + } catch (error, stackTrace) { + return Result.failure(error, stackTrace); } } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index d1e4f183af..4595d3eb2d 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -51,7 +51,6 @@ import '../core/models/draft_message.dart'; import '../core/models/location.dart'; import '../core/models/member.dart'; import '../core/models/message.dart'; -import '../core/models/message_delivery.dart'; import '../core/models/message_reminder.dart'; import '../core/models/own_user.dart'; import '../core/models/poll.dart'; @@ -60,6 +59,7 @@ import '../core/models/poll_vote.dart'; import '../core/models/push_preference.dart'; import '../core/models/push_provider.dart'; import '../core/models/reaction.dart'; +import '../core/models/request/message_delivery.dart'; import '../core/models/response/add_user_group_members_response.dart'; import '../core/models/response/app_settings_response.dart'; import '../core/models/response/create_user_group_response.dart'; @@ -68,6 +68,8 @@ import '../core/models/response/get_user_group_response.dart'; import '../core/models/response/hide_channel_response.dart'; import '../core/models/response/list_devices_response.dart'; import '../core/models/response/list_user_groups_response.dart'; +import '../core/models/response/mark_delivered_response.dart'; +import '../core/models/response/mark_read_response.dart'; import '../core/models/response/og_attachment_response.dart'; import '../core/models/response/remove_user_group_members_response.dart'; import '../core/models/response/search_roles_response.dart'; @@ -1579,69 +1581,70 @@ class StreamChatClient { formData, ); - /// Mark [channelId] of type [channelType] all messages as read - /// Optionally provide a [messageId] if you want to mark a - /// particular message as read - Future markChannelRead( + /// Marks a channel as read for the current user. + /// + /// Messages up to and including the one with [messageId] are marked as read, or all of them when [messageId] is + /// null. + Future> markChannelRead( String channelId, String channelType, { String? messageId, - }) => _chatApi.channel.markRead( + }) => _channelsRepository.markRead( channelId, channelType, messageId: messageId, ); - /// Marks the [channelId] of type [channelType] as unread - /// by a given [messageId]. + /// Marks a channel as unread for the current user, from the message with [messageId] onwards. /// - /// All messages from the provided message onwards will be marked as unread. - Future markChannelUnread( + /// That message and every later one are marked as unread. + Future> markChannelUnread( String channelId, String channelType, String messageId, - ) => _chatApi.channel.markUnread( + ) => _channelsRepository.markUnread( channelId, channelType, - messageId, + messageId: messageId, ); - /// Marks the [channelId] of type [channelType] as unread - /// by a given [timestamp]. + /// Marks the messages of a channel created after [timestamp] as unread for the current user. /// - /// All messages after the provided timestamp will be marked as unread. - Future markChannelUnreadByTimestamp( + /// A message created at exactly [timestamp] stays read. + Future> markChannelUnreadByTimestamp( String channelId, String channelType, DateTime timestamp, - ) => _chatApi.channel.markUnreadByTimestamp( + ) => _channelsRepository.markUnread( channelId, channelType, - timestamp, + messageTimestamp: timestamp, ); - /// Mark the thread with [threadId] in the channel with [channelId] of type - /// [channelType] as read. - Future markThreadRead( + /// Marks a thread in a channel as read for the current user. + /// + /// [threadId] is the id of the thread's parent message. + Future> markThreadRead( String channelId, String channelType, String threadId, - ) => _chatApi.channel.markThreadRead( + ) => _channelsRepository.markRead( channelId, channelType, - threadId, + threadId: threadId, ); - /// Mark the thread with [threadId] in the channel with [channelId] of type - /// [channelType] as unread. - Future markThreadUnread( + /// Marks a thread in a channel as unread for the current user. + /// + /// [threadId] is the id of the thread's parent message. + Future> markThreadUnread( String channelId, String channelType, String threadId, - ) => _chatApi.channel.markThreadUnread( + ) => _channelsRepository.markUnread( channelId, channelType, - threadId, + threadId: threadId, ); /// Creates a new Poll @@ -1860,8 +1863,8 @@ class StreamChatClient { return response; } - /// Mark all channels for this user as read - Future markAllRead() => _chatApi.channel.markAllRead(); + /// Marks all of the current user's channels as read. + Future> markAllRead() => _channelsRepository.markChannelsRead(); /// Sends delivery receipts for the latest messages in multiple channels. /// @@ -1872,19 +1875,17 @@ class StreamChatClient { /// /// ```dart /// // From notification payload - /// final receipt = MessageDeliveryInfo( - /// channelCid: notificationData['channel_id'], + /// final receipt = MessageDelivery( + /// channelCid: notificationData['cid'], /// messageId: notificationData['message_id'], /// ); /// await client.markChannelsDelivered([receipt]); /// ``` /// /// Accepts up to 100 channels per call. - Future markChannelsDelivered( + Future> markChannelsDelivered( Iterable deliveries, - ) { - return _chatApi.channel.markChannelsDelivered([...deliveries]); - } + ) => _channelsRepository.markDelivered(deliveries); /// Send an event to a particular channel Future sendEvent( diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index a5d0e6045a..cb4ff9df4b 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -4,7 +4,6 @@ import '../../ws/events/event.dart'; import '../http/stream_http_client.dart'; import '../models/channel_state.dart'; import '../models/message.dart'; -import '../models/message_delivery.dart'; import 'requests.dart'; import 'responses.dart'; @@ -92,12 +91,6 @@ class ChannelApi { return QueryChannelsResponse.fromJson(response.data); } - /// Mark all channels for this user as read - Future markAllRead() async { - final response = await _client.post('/channels/read', data: {}); - return EmptyResponse.fromJson(response.data); - } - /// Replaces the [channelId] of type [ChannelType] data with [data] Future updateChannel( String channelId, @@ -237,77 +230,6 @@ class ChannelApi { return EmptyResponse.fromJson(response.data); } - /// Mark [channelId] of type [channelType] all messages as read - /// Optionally provide a [messageId] if you want to mark channel as - /// read from particular message onwards - Future markRead( - String channelId, - String channelType, { - String? messageId, - }) async { - final response = await _client.post( - '${_getChannelUrl(channelId, channelType)}/read', - data: {if (messageId != null) 'message_id': messageId}, - ); - return EmptyResponse.fromJson(response.data); - } - - /// Marks the channel as unread by a given [messageId]. - /// - /// All messages from the provided message onwards will be marked as unread. - Future markUnread( - String channelId, - String channelType, - String messageId, - ) async { - final response = await _client.post( - '${_getChannelUrl(channelId, channelType)}/unread', - data: {'message_id': messageId}, - ); - return EmptyResponse.fromJson(response.data); - } - - /// Marks the channel as unread by a given [timestamp]. - /// - /// All messages after the provided timestamp will be marked as unread. - Future markUnreadByTimestamp( - String channelId, - String channelType, - DateTime timestamp, - ) async { - final response = await _client.post( - '${_getChannelUrl(channelId, channelType)}/unread', - data: {'message_timestamp': timestamp.toUtc().toIso8601String()}, - ); - return EmptyResponse.fromJson(response.data); - } - - /// Mark the provided [threadId] of the channel as read. - Future markThreadRead( - String channelId, - String channelType, - String threadId, - ) async { - final response = await _client.post( - '${_getChannelUrl(channelId, channelType)}/read', - data: {'thread_id': threadId}, - ); - return EmptyResponse.fromJson(response.data); - } - - /// Mark the provided [threadId] of the channel as unread. - Future markThreadUnread( - String channelId, - String channelType, - String threadId, - ) async { - final response = await _client.post( - '${_getChannelUrl(channelId, channelType)}/unread', - data: {'thread_id': threadId}, - ); - return EmptyResponse.fromJson(response.data); - } - /// Stop watching the channel Future stopWatching( String channelId, @@ -319,19 +241,4 @@ class ChannelApi { ); return EmptyResponse.fromJson(response.data); } - - /// Sends delivery receipts for the latest messages in multiple channels. - /// - /// Accepts up to 100 channels per call. - Future markChannelsDelivered( - List deliveries, - ) async { - final response = await _client.post( - '/channels/delivered', - data: jsonEncode({ - 'latest_delivered_messages': deliveries, - }), - ); - return EmptyResponse.fromJson(response.data); - } } diff --git a/packages/stream_chat/lib/src/core/models/message_delivery.dart b/packages/stream_chat/lib/src/core/models/message_delivery.dart deleted file mode 100644 index e814a2fc16..0000000000 --- a/packages/stream_chat/lib/src/core/models/message_delivery.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'message_delivery.g.dart'; - -/// A delivery receipt for a message in a channel. -/// -/// Used to acknowledge that the current user has received a message, -/// notifying the sender that their message was delivered. -@JsonSerializable(createFactory: false) -class MessageDelivery { - /// Creates a delivery receipt for a message. - const MessageDelivery({ - required this.channelCid, - required this.messageId, - }); - - /// The channel identifier containing the message. - @JsonKey(name: 'cid') - final String channelCid; - - /// The identifier of the message received. - @JsonKey(name: 'id') - final String messageId; - - /// Converts this [MessageDelivery] to JSON. - Map toJson() => _$MessageDeliveryToJson(this); -} diff --git a/packages/stream_chat/lib/src/core/models/message_delivery.g.dart b/packages/stream_chat/lib/src/core/models/message_delivery.g.dart deleted file mode 100644 index 96a3c10157..0000000000 --- a/packages/stream_chat/lib/src/core/models/message_delivery.g.dart +++ /dev/null @@ -1,12 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'message_delivery.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Map _$MessageDeliveryToJson(MessageDelivery instance) => { - 'cid': instance.channelCid, - 'id': instance.messageId, -}; diff --git a/packages/stream_chat/lib/src/core/models/request/message_delivery.dart b/packages/stream_chat/lib/src/core/models/request/message_delivery.dart new file mode 100644 index 0000000000..30040ef418 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/request/message_delivery.dart @@ -0,0 +1,24 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'message_delivery.freezed.dart'; + +/// A delivery receipt for a message in a channel. +/// +/// Used to acknowledge that the current user has received a message, +/// notifying the sender that their message was delivered. +@freezed +class MessageDelivery with _$MessageDelivery { + /// Creates a delivery receipt for a message. + const MessageDelivery({ + required this.channelCid, + required this.messageId, + }); + + /// The cid of the channel that contains the message, such as `messaging:general`. + @override + final String channelCid; + + /// The id of the message received. + @override + final String messageId; +} diff --git a/packages/stream_chat/lib/src/core/models/request/message_delivery.freezed.dart b/packages/stream_chat/lib/src/core/models/request/message_delivery.freezed.dart new file mode 100644 index 0000000000..b4aebf8294 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/request/message_delivery.freezed.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'message_delivery.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$MessageDelivery { + String get channelCid; + String get messageId; + + /// Create a copy of MessageDelivery + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $MessageDeliveryCopyWith get copyWith => _$MessageDeliveryCopyWithImpl( + this as MessageDelivery, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is MessageDelivery && + (identical(other.channelCid, channelCid) || other.channelCid == channelCid) && + (identical(other.messageId, messageId) || other.messageId == messageId)); + } + + @override + int get hashCode => Object.hash(runtimeType, channelCid, messageId); + + @override + String toString() { + return 'MessageDelivery(channelCid: $channelCid, messageId: $messageId)'; + } +} + +/// @nodoc +abstract mixin class $MessageDeliveryCopyWith<$Res> { + factory $MessageDeliveryCopyWith( + MessageDelivery value, + $Res Function(MessageDelivery) _then, + ) = _$MessageDeliveryCopyWithImpl; + @useResult + $Res call({String channelCid, String messageId}); +} + +/// @nodoc +class _$MessageDeliveryCopyWithImpl<$Res> implements $MessageDeliveryCopyWith<$Res> { + _$MessageDeliveryCopyWithImpl(this._self, this._then); + + final MessageDelivery _self; + final $Res Function(MessageDelivery) _then; + + /// Create a copy of MessageDelivery + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? channelCid = null, Object? messageId = null}) { + return _then( + MessageDelivery( + channelCid: null == channelCid + ? _self.channelCid + : channelCid // ignore: cast_nullable_to_non_nullable + as String, + messageId: null == messageId + ? _self.messageId + : messageId // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/mark_delivered_response.dart b/packages/stream_chat/lib/src/core/models/response/mark_delivered_response.dart new file mode 100644 index 0000000000..3bc1a28668 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/mark_delivered_response.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'mark_delivered_response.freezed.dart'; + +/// The outcome of sending delivery receipts. +@freezed +class MarkDeliveredResponse with _$MarkDeliveredResponse { + /// Creates a new [MarkDeliveredResponse]. + const MarkDeliveredResponse({ + required this.duration, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; +} diff --git a/packages/stream_chat/lib/src/core/models/response/mark_delivered_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/mark_delivered_response.freezed.dart new file mode 100644 index 0000000000..7f97495f31 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/mark_delivered_response.freezed.dart @@ -0,0 +1,76 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'mark_delivered_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$MarkDeliveredResponse { + String get duration; + + /// Create a copy of MarkDeliveredResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $MarkDeliveredResponseCopyWith get copyWith => + _$MarkDeliveredResponseCopyWithImpl( + this as MarkDeliveredResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is MarkDeliveredResponse && + (identical(other.duration, duration) || other.duration == duration)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration); + + @override + String toString() { + return 'MarkDeliveredResponse(duration: $duration)'; + } +} + +/// @nodoc +abstract mixin class $MarkDeliveredResponseCopyWith<$Res> { + factory $MarkDeliveredResponseCopyWith( + MarkDeliveredResponse value, + $Res Function(MarkDeliveredResponse) _then, + ) = _$MarkDeliveredResponseCopyWithImpl; + @useResult + $Res call({String duration}); +} + +/// @nodoc +class _$MarkDeliveredResponseCopyWithImpl<$Res> implements $MarkDeliveredResponseCopyWith<$Res> { + _$MarkDeliveredResponseCopyWithImpl(this._self, this._then); + + final MarkDeliveredResponse _self; + final $Res Function(MarkDeliveredResponse) _then; + + /// Create a copy of MarkDeliveredResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null}) { + return _then( + MarkDeliveredResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/mark_read_response.dart b/packages/stream_chat/lib/src/core/models/response/mark_read_response.dart new file mode 100644 index 0000000000..2fb71f3ac2 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/mark_read_response.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'mark_read_response.freezed.dart'; + +/// The outcome of marking one or more channels, or a thread, as read. +@freezed +class MarkReadResponse with _$MarkReadResponse { + /// Creates a new [MarkReadResponse]. + const MarkReadResponse({ + required this.duration, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; +} diff --git a/packages/stream_chat/lib/src/core/models/response/mark_read_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/mark_read_response.freezed.dart new file mode 100644 index 0000000000..ae7231a394 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/mark_read_response.freezed.dart @@ -0,0 +1,75 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'mark_read_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$MarkReadResponse { + String get duration; + + /// Create a copy of MarkReadResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $MarkReadResponseCopyWith get copyWith => _$MarkReadResponseCopyWithImpl( + this as MarkReadResponse, + _$identity, + ); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is MarkReadResponse && + (identical(other.duration, duration) || other.duration == duration)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration); + + @override + String toString() { + return 'MarkReadResponse(duration: $duration)'; + } +} + +/// @nodoc +abstract mixin class $MarkReadResponseCopyWith<$Res> { + factory $MarkReadResponseCopyWith( + MarkReadResponse value, + $Res Function(MarkReadResponse) _then, + ) = _$MarkReadResponseCopyWithImpl; + @useResult + $Res call({String duration}); +} + +/// @nodoc +class _$MarkReadResponseCopyWithImpl<$Res> implements $MarkReadResponseCopyWith<$Res> { + _$MarkReadResponseCopyWithImpl(this._self, this._then); + + final MarkReadResponse _self; + final $Res Function(MarkReadResponse) _then; + + /// Create a copy of MarkReadResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null}) { + return _then( + MarkReadResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/repository/channels_repository.dart b/packages/stream_chat/lib/src/repository/channels_repository.dart index 1028e3366b..9cb04e7865 100644 --- a/packages/stream_chat/lib/src/repository/channels_repository.dart +++ b/packages/stream_chat/lib/src/repository/channels_repository.dart @@ -1,12 +1,16 @@ import 'package:stream_core/stream_core.dart' show PatternMatching, Result; import '../../open_api/api.dart' as api; +import '../core/models/request/message_delivery.dart'; import '../core/models/response/delete_channel_response.dart'; import '../core/models/response/hide_channel_response.dart'; +import '../core/models/response/mark_delivered_response.dart'; +import '../core/models/response/mark_read_response.dart'; import '../core/models/response/show_channel_response.dart'; import '../core/models/response/update_channel_partial_response.dart'; import '../core/models/response/update_member_partial_response.dart'; import 'mapper/channel_mapper.dart'; +import 'mapper/result_mapper.dart'; /// Repository dedicated to channel operations. class ChannelsRepository { @@ -89,4 +93,66 @@ class ChannelsRepository { return result.map((response) => response.toModel()); } + + /// Marks a channel, or one of its threads, as read for the current user. + /// + /// With [threadId], the id of a thread's parent message, marks that thread as read. Otherwise marks the channel's + /// messages as read up to and including the one with [messageId], or all of them when [messageId] is null. + Future> markRead( + String channelId, + String channelType, { + String? messageId, + String? threadId, + }) async { + final result = await _api.markRead( + type: channelType, + id: channelId, + markReadRequest: api.MarkReadRequest(messageId: messageId, threadId: threadId), + ); + + return result.map((response) => response.toModel()); + } + + /// Marks a channel, or one of its threads, as unread for the current user. + /// + /// Takes exactly one of [messageId], [messageTimestamp] and [threadId]. [messageId] marks that message and every + /// later one as unread, [messageTimestamp] every message created after it, and [threadId], the id of a thread's + /// parent message, that thread. + Future> markUnread( + String channelId, + String channelType, { + String? messageId, + DateTime? messageTimestamp, + String? threadId, + }) async { + final result = await _api.markUnread( + type: channelType, + id: channelId, + markUnreadRequest: api.MarkUnreadRequest( + messageId: messageId, + messageTimestamp: messageTimestamp, + threadId: threadId, + ), + ); + + return result.ignoreValue(); + } + + /// Marks all of the current user's channels as read. + Future> markChannelsRead() async { + final result = await _api.markChannelsRead(markChannelsReadRequest: const api.MarkChannelsReadRequest()); + + return result.map((response) => response.toModel()); + } + + /// Sends delivery receipts for the messages in [deliveries]. + Future> markDelivered(Iterable deliveries) async { + final result = await _api.markDelivered( + markDeliveredRequest: api.MarkDeliveredRequest( + latestDeliveredMessages: [...deliveries.map((it) => it.toRequest())], + ), + ); + + return result.map((response) => response.toModel()); + } } diff --git a/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart index e804cb6385..220837e675 100644 --- a/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/channel_mapper.dart @@ -7,8 +7,11 @@ import '../../core/models/chat_preferences.dart'; import '../../core/models/command.dart'; import '../../core/models/member.dart'; import '../../core/models/push_level.dart'; +import '../../core/models/request/message_delivery.dart'; import '../../core/models/response/delete_channel_response.dart'; import '../../core/models/response/hide_channel_response.dart'; +import '../../core/models/response/mark_delivered_response.dart'; +import '../../core/models/response/mark_read_response.dart'; import '../../core/models/response/show_channel_response.dart'; import '../../core/models/response/update_channel_partial_response.dart'; import '../../core/models/response/update_member_partial_response.dart'; @@ -209,3 +212,23 @@ extension DeleteChannelResponseMapper on api.DeleteChannelResponse { channel: channel?.toModel(), ); } + +/// Maps a generated [api.MarkReadResponse] to a [MarkReadResponse]. +extension MarkReadResponseMapper on api.MarkReadResponse { + // TODO(openapi-migration): map `event` in group 10; its thread carries a `MessageResponse`. + + /// Converts this response into a [MarkReadResponse]. + MarkReadResponse toModel() => MarkReadResponse(duration: duration); +} + +/// Maps a generated [api.MarkDeliveredResponse] to a [MarkDeliveredResponse]. +extension MarkDeliveredResponseMapper on api.MarkDeliveredResponse { + /// Converts this response into a [MarkDeliveredResponse]. + MarkDeliveredResponse toModel() => MarkDeliveredResponse(duration: duration); +} + +/// Maps a [MessageDelivery] to a generated [api.DeliveredMessagePayload]. +extension MessageDeliveryMapper on MessageDelivery { + /// Converts this receipt into an [api.DeliveredMessagePayload]. + api.DeliveredMessagePayload toRequest() => api.DeliveredMessagePayload(cid: channelCid, id: messageId); +} diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 14cbd62b5e..ce12bc13d2 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -102,7 +102,6 @@ export 'src/core/models/location.dart'; export 'src/core/models/member.dart'; export 'src/core/models/message.dart'; export 'src/core/models/message_delete_scope.dart'; -export 'src/core/models/message_delivery.dart'; export 'src/core/models/message_reminder.dart'; export 'src/core/models/message_state.dart'; export 'src/core/models/moderation.dart'; @@ -120,6 +119,7 @@ export 'src/core/models/push_provider.dart'; export 'src/core/models/reaction.dart'; export 'src/core/models/reaction_group.dart'; export 'src/core/models/read.dart'; +export 'src/core/models/request/message_delivery.dart'; export 'src/core/models/response/add_user_group_members_response.dart'; export 'src/core/models/response/app_settings_response.dart'; export 'src/core/models/response/create_user_group_response.dart'; @@ -129,6 +129,8 @@ export 'src/core/models/response/get_user_group_response.dart'; export 'src/core/models/response/hide_channel_response.dart'; export 'src/core/models/response/list_devices_response.dart'; export 'src/core/models/response/list_user_groups_response.dart'; +export 'src/core/models/response/mark_delivered_response.dart'; +export 'src/core/models/response/mark_read_response.dart'; export 'src/core/models/response/mute_users_response.dart'; export 'src/core/models/response/og_attachment_response.dart'; export 'src/core/models/response/remove_user_group_members_response.dart'; diff --git a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart index 08f4852be8..e9c6d2cb52 100644 --- a/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_client_state_test.dart @@ -5429,7 +5429,7 @@ void main() { channel.state!.unreadCount = 3; expect(channel.state?.unreadCount, equals(3)); - await expectLater(channel.markRead(), completes); + await expectLater(channel.markRead(), completion(isA>())); expect(channel.state?.unreadCount, equals(0)); verifyNever( @@ -5442,6 +5442,17 @@ void main() { }, ); + test( + 'markRead answers a 0ms response when counting unread messages locally', + () async { + final channel = _createLivestreamChannel(); + + final res = await channel.markRead(); + + expect(res.getOrNull(), const MarkReadResponse(duration: '0ms')); + }, + ); + test( 'markUnreadByTimestamp recomputes unreadCount locally without making a ' 'network request', @@ -5477,7 +5488,7 @@ void main() { await expectLater( channel.markUnreadByTimestamp(now.add(const Duration(seconds: 30))), - completes, + completion(const Result.success(null)), ); // Only m2 and m3 were created after the given timestamp. @@ -5489,14 +5500,13 @@ void main() { ); test( - 'markUnread throws when the message is not locally known', + 'markUnread returns a failure when the message is not locally known', () async { final channel = _createLivestreamChannel(); - await expectLater( - channel.markUnread('unknown-message-id'), - throwsA(isA()), - ); + final res = await channel.markUnread('unknown-message-id'); + + expect(res.exceptionOrNull(), isA()); verifyNever( () => client.markChannelUnread(any(), any(), any()), ); @@ -5509,7 +5519,7 @@ void main() { final channel = _createLivestreamChannel(); channel.state!.unreadCount = 2; - await expectLater(channel.markRead(), completes); + await expectLater(channel.markRead(), completion(isA>())); verify( () => client.channelDeliveryReporter.reconcileDelivery([channel]), @@ -5525,7 +5535,7 @@ void main() { await expectLater( channel.markUnreadByTimestamp(DateTime(2024, 1, 1)), - completes, + completion(const Result.success(null)), ); expect(channel.state?.isMarkedAsUnread, isTrue); @@ -5539,7 +5549,7 @@ void main() { await channel.markUnreadByTimestamp(DateTime(2024, 1, 1)); expect(channel.state?.isMarkedAsUnread, isTrue); - await expectLater(channel.markRead(), completes); + await expectLater(channel.markRead(), completion(isA>())); expect(channel.state?.isMarkedAsUnread, isFalse); }, @@ -5579,7 +5589,7 @@ void main() { ], ); - await expectLater(channel.markUnread('m2'), completes); + await expectLater(channel.markUnread('m2'), completion(const Result.success(null))); // m2 (the anchor) and m3 are unread; m1 stays read. expect(channel.state?.unreadCount, equals(2)); @@ -5599,7 +5609,7 @@ void main() { ], ); - await expectLater(channel.markUnread('m1'), completes); + await expectLater(channel.markUnread('m1'), completion(const Result.success(null))); expect(channel.state?.unreadCount, equals(3)); expect(channel.state?.currentUserRead?.lastReadMessageId, isNull); @@ -5618,7 +5628,10 @@ void main() { ); // Exactly m2's createdAt: m2 stays read, only m3 becomes unread. - await expectLater(channel.markUnreadByTimestamp(messages[1].createdAt), completes); + await expectLater( + channel.markUnreadByTimestamp(messages[1].createdAt), + completion(const Result.success(null)), + ); expect(channel.state?.unreadCount, equals(1)); expect(channel.state?.currentUserRead?.lastReadMessageId, equals('m2')); @@ -5689,7 +5702,7 @@ void main() { // A plain local mutation (via updateChannelState, not // updateChannelStateFromServer) should still be able to change the // locally-tracked read state. - await expectLater(channel.markRead(), completes); + await expectLater(channel.markRead(), completion(isA>())); expect(channel.state?.unreadCount, equals(0)); }, diff --git a/packages/stream_chat/test/src/client/channel/channel_test.dart b/packages/stream_chat/test/src/client/channel/channel_test.dart index 3c20a0d7fa..1b95c0052d 100644 --- a/packages/stream_chat/test/src/client/channel/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel/channel_test.dart @@ -6219,279 +6219,123 @@ void main() { when(() => client.state).thenReturn(clientState); }); - test( - ".markRead should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - await expectLater( - channel.markRead(messageId: 'message-id-123'), - throwsA(isA()), - ); - }, - ); - - test( - '.markRead should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markChannelRead( - channelId, - channelType, - messageId: 'message-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markRead(messageId: 'message-id-123'), - completes, - ); - - verify( - () => client.markChannelRead( - channelId, - channelType, - messageId: 'message-id-123', - ), - ).called(1); - }, - ); - - test( - ".markUnread should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); - - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - await expectLater( - channel.markUnread('message-id-123'), - throwsA(isA()), - ); - }, - ); - - test( - '.markUnread should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); + Channel channelWith(List ownCapabilities) { + final channel = Channel.fromState( + client, + _generateChannelState(channelId, channelType, ownCapabilities: ownCapabilities), + ); + addTearDown(channel.dispose); + return channel; + } - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + test('Channel.markRead returns a failure when the current user cannot send read events', () async { + final channel = channelWith([]); - when( - () => client.markChannelUnread( - channelId, - channelType, - 'message-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); + final res = await channel.markRead(messageId: 'message-id-123'); - await expectLater( - channel.markUnread('message-id-123'), - completes, - ); + expect(res.exceptionOrNull(), isA()); + verifyNever(() => client.markChannelRead(channelId, channelType, messageId: 'message-id-123')); + }); - verify( - () => client.markChannelUnread( - channelId, - channelType, - 'message-id-123', - ), - ).called(1); - }, - ); + test("Channel.markRead returns the client's markChannelRead response", () async { + const response = MarkReadResponse(duration: '0.01ms'); + final channel = channelWith([ChannelCapability.readEvents]); + when( + () => client.markChannelRead(channelId, channelType, messageId: 'message-id-123'), + ).thenAnswer((_) async => const Result.success(response)); - test( - ".markUnreadByTimestamp should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); + final res = await channel.markRead(messageId: 'message-id-123'); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + expect(res.getOrNull(), response); + verify(() => client.markChannelRead(channelId, channelType, messageId: 'message-id-123')).called(1); + }); - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); + test('Channel.markUnread returns a failure when the current user cannot send read events', () async { + final channel = channelWith([]); - await expectLater( - channel.markUnreadByTimestamp(timestamp), - throwsA(isA()), - ); - }, - ); + final res = await channel.markUnread('message-id-123'); - test( - '.markUnreadByTimestamp should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); + expect(res.exceptionOrNull(), isA()); + verifyNever(() => client.markChannelUnread(channelId, channelType, 'message-id-123')); + }); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + test("Channel.markUnread returns the client's markChannelUnread result", () async { + final channel = channelWith([ChannelCapability.readEvents]); + when( + () => client.markChannelUnread(channelId, channelType, 'message-id-123'), + ).thenAnswer((_) async => const Result.success(null)); - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); + final res = await channel.markUnread('message-id-123'); - when( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).thenAnswer((_) async => EmptyResponse()); + expect(res, const Result.success(null)); + verify(() => client.markChannelUnread(channelId, channelType, 'message-id-123')).called(1); + }); - await expectLater( - channel.markUnreadByTimestamp(timestamp), - completes, - ); + test('Channel.markUnreadByTimestamp returns a failure when the current user cannot send read events', () async { + final timestamp = DateTime.utc(2024); + final channel = channelWith([]); - verify( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).called(1); - }, - ); + final res = await channel.markUnreadByTimestamp(timestamp); - test( - ".markThreadRead should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); + expect(res.exceptionOrNull(), isA()); + verifyNever(() => client.markChannelUnreadByTimestamp(channelId, channelType, timestamp)); + }); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + test("Channel.markUnreadByTimestamp returns the client's markChannelUnreadByTimestamp result", () async { + final timestamp = DateTime.utc(2024); + final channel = channelWith([ChannelCapability.readEvents]); + when( + () => client.markChannelUnreadByTimestamp(channelId, channelType, timestamp), + ).thenAnswer((_) async => const Result.success(null)); - await expectLater( - channel.markThreadRead('thread-id-123'), - throwsA(isA()), - ); - }, - ); + final res = await channel.markUnreadByTimestamp(timestamp); - test( - '.markThreadRead should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); + expect(res, const Result.success(null)); + verify(() => client.markChannelUnreadByTimestamp(channelId, channelType, timestamp)).called(1); + }); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + test('Channel.markThreadRead returns a failure when the current user cannot send read events', () async { + final channel = channelWith([]); - when( - () => client.markThreadRead( - channelId, - channelType, - 'thread-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); + final res = await channel.markThreadRead('thread-id-123'); - await expectLater( - channel.markThreadRead('thread-id-123'), - completes, - ); + expect(res.exceptionOrNull(), isA()); + verifyNever(() => client.markThreadRead(channelId, channelType, 'thread-id-123')); + }); - verify( - () => client.markThreadRead( - channelId, - channelType, - 'thread-id-123', - ), - ).called(1); - }, - ); + test("Channel.markThreadRead returns the client's markThreadRead response", () async { + const response = MarkReadResponse(duration: '0.01ms'); + final channel = channelWith([ChannelCapability.readEvents]); + when( + () => client.markThreadRead(channelId, channelType, 'thread-id-123'), + ).thenAnswer((_) async => const Result.success(response)); - test( - ".markThreadUnread should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability - ); + final res = await channel.markThreadRead('thread-id-123'); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + expect(res.getOrNull(), response); + verify(() => client.markThreadRead(channelId, channelType, 'thread-id-123')).called(1); + }); - await expectLater( - channel.markThreadUnread('thread-id-123'), - throwsA(isA()), - ); - }, - ); + test('Channel.markThreadUnread returns a failure when the current user cannot send read events', () async { + final channel = channelWith([]); - test( - '.markThreadUnread should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], - ); + final res = await channel.markThreadUnread('thread-id-123'); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + expect(res.exceptionOrNull(), isA()); + verifyNever(() => client.markThreadUnread(channelId, channelType, 'thread-id-123')); + }); - when( - () => client.markThreadUnread( - channelId, - channelType, - 'thread-id-123', - ), - ).thenAnswer((_) async => EmptyResponse()); + test("Channel.markThreadUnread returns the client's markThreadUnread result", () async { + final channel = channelWith([ChannelCapability.readEvents]); + when( + () => client.markThreadUnread(channelId, channelType, 'thread-id-123'), + ).thenAnswer((_) async => const Result.success(null)); - await expectLater( - channel.markThreadUnread('thread-id-123'), - completes, - ); + final res = await channel.markThreadUnread('thread-id-123'); - verify( - () => client.markThreadUnread( - channelId, - channelType, - 'thread-id-123', - ), - ).called(1); - }, - ); + expect(res, const Result.success(null)); + verify(() => client.markThreadUnread(channelId, channelType, 'thread-id-123')).called(1); + }); }); group('Retry functionality with parameter preservation', () { diff --git a/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart b/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart index 3b83e62658..cd9b93896a 100644 --- a/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart +++ b/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart @@ -28,6 +28,7 @@ void main() { throttleDuration: const Duration(milliseconds: 100), onMarkChannelsDelivered: (deliveries) async { capturedDeliveries.addAll(deliveries); + return const Result.success(null); }, ); }); @@ -426,36 +427,87 @@ void main() { }, ); - test('should handle delivery errors gracefully', () async { + test('should keep the receipts of a failed delivery for the next batch', () async { var shouldFail = true; final errorReporter = ChannelDeliveryReporter( throttleDuration: const Duration(milliseconds: 50), onMarkChannelsDelivered: (deliveries) async { if (shouldFail) { shouldFail = false; - throw Exception('Network error'); + return const Result.failure(StreamNetworkException(message: 'Network error')); } capturedDeliveries.addAll(deliveries); + return const Result.success(null); }, ); try { - final message = _createMessage((m) => m.copyWith(id: 'message-1')); - final channel = _createDeliverableChannel( + final channel1 = _createDeliverableChannel( client, cid: 'test:channel-1', - message: message, + message: _createMessage((m) => m.copyWith(id: 'message-1')), + ); + final channel2 = _createDeliverableChannel( + client, + cid: 'test:channel-2', + message: _createMessage((m) => m.copyWith(id: 'message-2')), ); - await errorReporter.submitForDelivery([channel]); + await errorReporter.submitForDelivery([channel1]); await delay(100); expect(capturedDeliveries, isEmpty); - await errorReporter.submitForDelivery([channel]); + await errorReporter.submitForDelivery([channel2]); await delay(100); - expect(capturedDeliveries, hasLength(1)); + expect( + capturedDeliveries.map((it) => it.messageId), + unorderedEquals(['message-1', 'message-2']), + ); + } finally { + errorReporter.cancel(); + } + }); + + test('should keep the receipts of a delivery that throws for the next batch', () async { + var shouldFail = true; + final errorReporter = ChannelDeliveryReporter( + throttleDuration: const Duration(milliseconds: 50), + onMarkChannelsDelivered: (deliveries) async { + if (shouldFail) { + shouldFail = false; + throw const StreamNetworkException(message: 'Network error'); + } + capturedDeliveries.addAll(deliveries); + return const Result.success(null); + }, + ); + + try { + final channel1 = _createDeliverableChannel( + client, + cid: 'test:channel-1', + message: _createMessage((m) => m.copyWith(id: 'message-1')), + ); + final channel2 = _createDeliverableChannel( + client, + cid: 'test:channel-2', + message: _createMessage((m) => m.copyWith(id: 'message-2')), + ); + + await errorReporter.submitForDelivery([channel1]); + await delay(100); + + expect(capturedDeliveries, isEmpty); + + await errorReporter.submitForDelivery([channel2]); + await delay(100); + + expect( + capturedDeliveries.map((it) => it.messageId), + unorderedEquals(['message-1', 'message-2']), + ); } finally { errorReporter.cancel(); } diff --git a/packages/stream_chat/test/src/client/client_mark_all_read_test.dart b/packages/stream_chat/test/src/client/client_mark_all_read_test.dart new file mode 100644 index 0000000000..e652e9d447 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_mark_all_read_test.dart @@ -0,0 +1,45 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.MarkChannelsReadRequest())); + + test('StreamChatClient.markAllRead sends an empty request and returns the response', () async { + const request = api.MarkChannelsReadRequest(); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markChannelsRead(markChannelsReadRequest: request), + ).thenAnswer((_) async => const Result.success(api.MarkReadResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.markAllRead(); + + expect(res.getOrNull(), const MarkReadResponse(duration: '0.01ms')); + verify(() => defaultApi.markChannelsRead(markChannelsReadRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.markAllRead returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markChannelsRead(markChannelsReadRequest: any(named: 'markChannelsReadRequest')), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.markAllRead(); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} diff --git a/packages/stream_chat/test/src/client/client_mark_channels_delivered_test.dart b/packages/stream_chat/test/src/client/client_mark_channels_delivered_test.dart new file mode 100644 index 0000000000..bfa1a1db5c --- /dev/null +++ b/packages/stream_chat/test/src/client/client_mark_channels_delivered_test.dart @@ -0,0 +1,55 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.MarkDeliveredRequest())); + + test('StreamChatClient.markChannelsDelivered sends the receipts and returns the response', () async { + const request = api.MarkDeliveredRequest( + latestDeliveredMessages: [ + api.DeliveredMessagePayload(cid: 'messaging:general', id: 'message-1'), + api.DeliveredMessagePayload(cid: 'messaging:random', id: 'message-2'), + ], + ); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markDelivered(markDeliveredRequest: request), + ).thenAnswer((_) async => const Result.success(api.MarkDeliveredResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.markChannelsDelivered(const [ + MessageDelivery(channelCid: 'messaging:general', messageId: 'message-1'), + MessageDelivery(channelCid: 'messaging:random', messageId: 'message-2'), + ]); + + expect(res.getOrNull(), const MarkDeliveredResponse(duration: '0.01ms')); + verify(() => defaultApi.markDelivered(markDeliveredRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.markChannelsDelivered returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markDelivered(markDeliveredRequest: any(named: 'markDeliveredRequest')), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.markChannelsDelivered(const [ + MessageDelivery(channelCid: 'messaging:general', messageId: 'message-1'), + ]); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} diff --git a/packages/stream_chat/test/src/client/client_mark_read_test.dart b/packages/stream_chat/test/src/client/client_mark_read_test.dart new file mode 100644 index 0000000000..1b77c960e1 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_mark_read_test.dart @@ -0,0 +1,98 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.MarkReadRequest())); + + test('StreamChatClient.markChannelRead sends the message id and returns the response', () async { + const request = api.MarkReadRequest(messageId: 'message-id'); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markRead(type: 'messaging', id: 'general', markReadRequest: request), + ).thenAnswer((_) async => const Result.success(api.MarkReadResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.markChannelRead('general', 'messaging', messageId: 'message-id'); + + expect(res.getOrNull(), const MarkReadResponse(duration: '0.01ms')); + verify(() => defaultApi.markRead(type: 'messaging', id: 'general', markReadRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.markChannelRead sends no message id when none is given and returns the response', () async { + const request = api.MarkReadRequest(); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markRead(type: 'messaging', id: 'general', markReadRequest: request), + ).thenAnswer((_) async => const Result.success(api.MarkReadResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.markChannelRead('general', 'messaging'); + + expect(res.getOrNull(), const MarkReadResponse(duration: '0.01ms')); + verify(() => defaultApi.markRead(type: 'messaging', id: 'general', markReadRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.markChannelRead returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markRead( + type: any(named: 'type'), + id: any(named: 'id'), + markReadRequest: any(named: 'markReadRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.markChannelRead('general', 'messaging'); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.markThreadRead sends the thread and returns the response', () async { + const request = api.MarkReadRequest(threadId: 'thread-id'); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markRead(type: 'messaging', id: 'general', markReadRequest: request), + ).thenAnswer((_) async => const Result.success(api.MarkReadResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.markThreadRead('general', 'messaging', 'thread-id'); + + expect(res.getOrNull(), const MarkReadResponse(duration: '0.01ms')); + verify(() => defaultApi.markRead(type: 'messaging', id: 'general', markReadRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.markThreadRead returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markRead( + type: any(named: 'type'), + id: any(named: 'id'), + markReadRequest: any(named: 'markReadRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.markThreadRead('general', 'messaging', 'thread-id'); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} diff --git a/packages/stream_chat/test/src/client/client_mark_unread_test.dart b/packages/stream_chat/test/src/client/client_mark_unread_test.dart new file mode 100644 index 0000000000..7857905000 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_mark_unread_test.dart @@ -0,0 +1,116 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; + +void main() { + setUpAll(() => registerFallbackValue(const api.MarkUnreadRequest())); + + test('StreamChatClient.markChannelUnread sends the message id and returns a success', () async { + const request = api.MarkUnreadRequest(messageId: 'message-id'); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markUnread(type: 'messaging', id: 'general', markUnreadRequest: request), + ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.markChannelUnread('general', 'messaging', 'message-id'); + + expect(res, const Result.success(null)); + verify(() => defaultApi.markUnread(type: 'messaging', id: 'general', markUnreadRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.markChannelUnread returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markUnread( + type: any(named: 'type'), + id: any(named: 'id'), + markUnreadRequest: any(named: 'markUnreadRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.markChannelUnread('general', 'messaging', 'message-id'); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.markChannelUnreadByTimestamp sends the timestamp and returns a success', () async { + final timestamp = DateTime.utc(2024, 1, 2, 3, 4, 5); + final request = api.MarkUnreadRequest(messageTimestamp: timestamp); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markUnread(type: 'messaging', id: 'general', markUnreadRequest: request), + ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.markChannelUnreadByTimestamp('general', 'messaging', timestamp); + + expect(res, const Result.success(null)); + verify(() => defaultApi.markUnread(type: 'messaging', id: 'general', markUnreadRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.markChannelUnreadByTimestamp returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markUnread( + type: any(named: 'type'), + id: any(named: 'id'), + markUnreadRequest: any(named: 'markUnreadRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.markChannelUnreadByTimestamp('general', 'messaging', DateTime.utc(2024)); + + expect(res.exceptionOrNull(), error); + }); + + test('StreamChatClient.markThreadUnread sends the thread and returns a success', () async { + const request = api.MarkUnreadRequest(threadId: 'thread-id'); + + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markUnread(type: 'messaging', id: 'general', markUnreadRequest: request), + ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '0.01ms'))); + final client = _client(defaultApi); + + final res = await client.markThreadUnread('general', 'messaging', 'thread-id'); + + expect(res, const Result.success(null)); + verify(() => defaultApi.markUnread(type: 'messaging', id: 'general', markUnreadRequest: request)).called(1); + verifyNoMoreInteractions(defaultApi); + }); + + test('StreamChatClient.markThreadUnread returns the failure without throwing', () async { + const error = StreamClientException(message: 'boom'); + final defaultApi = MockDefaultApi(); + when( + () => defaultApi.markUnread( + type: any(named: 'type'), + id: any(named: 'id'), + markUnreadRequest: any(named: 'markUnreadRequest'), + ), + ).thenAnswer((_) async => const Result.failure(error)); + final client = _client(defaultApi); + + final res = await client.markThreadUnread('general', 'messaging', 'thread-id'); + + expect(res.exceptionOrNull(), error); + }); +} + +StreamChatClient _client(api.DefaultApi defaultApi) { + final client = StreamChatClient('test-api-key', defaultApi: defaultApi); + addTearDown(client.dispose); + return client; +} diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index bbbce8b14f..d88f60eb49 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -2982,72 +2982,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.message); }); - test('`.markChannelRead`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - - when(() => fakeChatApi.channel.markRead(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.markChannelRead(channelId, channelType); - - expect(res, isNotNull); - - verify(() => fakeChatApi.channel.markRead(channelId, channelType)).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - - test('`.markChannelUnread`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const messageId = 'test-message-id'; - - when( - () => fakeChatApi.channel.markUnread(channelId, channelType, messageId), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await client.markChannelUnread( - channelId, - channelType, - messageId, - ); - - expect(res, isNotNull); - - verify(() => fakeChatApi.channel.markUnread(channelId, channelType, messageId)).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - - test('`.markChannelUnreadByTimestamp`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - - when( - () => fakeChatApi.channel.markUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).thenAnswer((_) async => EmptyResponse()); - - final res = await client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, - ); - - expect(res, isNotNull); - - verify( - () => fakeChatApi.channel.markUnreadByTimestamp( - channelId, - channelType, - timestamp, - ), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - test('`.createPoll`', () async { final poll = Poll( name: 'What is your favorite color?', @@ -4141,37 +4075,6 @@ void main() { }); }); - test('`.markAllRead`', () async { - when(() => fakeChatApi.channel.markAllRead()).thenAnswer((_) async => EmptyResponse()); - - final res = await client.markAllRead(); - expect(res, isNotNull); - - verify(() => fakeChatApi.channel.markAllRead()).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - - test('`.markChannelsDelivered`', () async { - final deliveries = [ - const MessageDelivery( - channelCid: 'messaging:test-channel-1', - messageId: 'test-message-id-1', - ), - const MessageDelivery( - channelCid: 'messaging:test-channel-2', - messageId: 'test-message-id-2', - ), - ]; - - when(() => fakeChatApi.channel.markChannelsDelivered(deliveries)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.markChannelsDelivered(deliveries); - expect(res, isNotNull); - - verify(() => fakeChatApi.channel.markChannelsDelivered(deliveries)).called(1); - verifyNoMoreInteractions(fakeChatApi.channel); - }); - test('`.sendEvent`', () async { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index bf8933dc5b..67b7ecb216 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -265,18 +265,6 @@ void main() { verifyNoMoreInteractions(client); }); - test('markAllRead', () async { - const path = '/channels/read'; - when(() => client.post(path, data: {})).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await channelApi.markAllRead(); - - expect(res, isNotNull); - - verify(() => client.post(path, data: {})).called(1); - verifyNoMoreInteractions(client); - }); - test('updateChannel', () async { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; @@ -672,88 +660,6 @@ void main() { verifyNoMoreInteractions(client); }); - test('markRead', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const messageId = 'test-message-id'; - - final path = '${_getChannelUrl(channelId, channelType)}/read'; - - when( - () => client.post( - path, - data: { - 'message_id': messageId, - }, - ), - ).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await channelApi.markRead( - channelId, - channelType, - messageId: messageId, - ); - - expect(res, isNotNull); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('markUnread', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - const messageId = 'test-message-id'; - - final path = '${_getChannelUrl(channelId, channelType)}/unread'; - - when( - () => client.post( - path, - data: {'message_id': messageId}, - ), - ).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await channelApi.markUnread( - channelId, - channelType, - messageId, - ); - - expect(res, isNotNull); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('markUnreadByTimestamp', () async { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - - final path = '${_getChannelUrl(channelId, channelType)}/unread'; - - when( - () => client.post( - path, - data: { - 'message_timestamp': timestamp.toUtc().toIso8601String(), - }, - ), - ).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await channelApi.markUnreadByTimestamp( - channelId, - channelType, - timestamp, - ); - - expect(res, isNotNull); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - test('stopWatching', () async { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; @@ -769,45 +675,4 @@ void main() { verify(() => client.post(path, data: {})).called(1); verifyNoMoreInteractions(client); }); - - test('markChannelsDelivered', () async { - const path = '/channels/delivered'; - - final deliveries = [ - const MessageDelivery( - channelCid: 'messaging:test-channel-1', - messageId: 'test-message-id-1', - ), - const MessageDelivery( - channelCid: 'messaging:test-channel-2', - messageId: 'test-message-id-2', - ), - ]; - - when( - () => client.post( - path, - data: any(named: 'data'), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: {}, - ), - ); - - final res = await channelApi.markChannelsDelivered(deliveries); - - expect(res, isNotNull); - - verify( - () => client.post( - path, - data: jsonEncode({ - 'latest_delivered_messages': deliveries, - }), - ), - ).called(1); - verifyNoMoreInteractions(client); - }); } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart index d06b1612e3..5c4d7cd148 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart @@ -308,15 +308,17 @@ class MessageListUnreadController { // transient failure (offline, a 5xx) would block every later attempt for // that state until a new message arrived or the channel was reopened. // - // The error itself is swallowed rather than rethrown: the request is a - // best-effort background action with no user-facing surface, and the future - // is discarded by the debouncer, so rethrowing would only raise an - // unhandled async error. - Future? _retryableMarkRead(Future? request) { - return request?.onError((_, __) { - _lastMarkReadAttempt = null; - return EmptyResponse(); - }); + // The failure itself is swallowed: the request is a best-effort background + // action with no user-facing surface, and the future is discarded by the + // debouncer, so an error escaping it would only raise an unhandled async + // error. + Future? _retryableMarkRead(Future>? request) { + return request?.then( + (result) { + if (result.isFailure) _lastMarkReadAttempt = null; + }, + onError: (_, __) => _lastMarkReadAttempt = null, + ); } /// Resets every piece of unread state for a newly attached channel. diff --git a/packages/stream_chat_flutter/test/src/message_list_view/auto_scroll_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/auto_scroll_test.dart index 19fcceb291..c320a7cd4e 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/auto_scroll_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/auto_scroll_test.dart @@ -65,7 +65,9 @@ void main() { when(() => channelClientState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); when(() => channelClientState.messagesStream).thenAnswer((_) => messagesController.stream); - when(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse()); + when( + () => channel.markRead(messageId: any(named: 'messageId')), + ).thenAnswer((_) async => const Result.success(MarkReadResponse(duration: '0ms'))); }); Future pumpMessageList( diff --git a/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart index cf271ee20a..87dc11a75a 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/mark_read_test.dart @@ -88,8 +88,12 @@ void main() { when(() => channelClientState.isMarkedAsUnread).thenReturn(false); // Mark-read mocks return immediately. - when(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse()); - when(() => channel.markThreadRead(any())).thenAnswer((_) async => EmptyResponse()); + when( + () => channel.markRead(messageId: any(named: 'messageId')), + ).thenAnswer((_) async => const Result.success(MarkReadResponse(duration: '0ms'))); + when( + () => channel.markThreadRead(any()), + ).thenAnswer((_) async => const Result.success(MarkReadResponse(duration: '0ms'))); // Thread reply loader called by MessageListCore when parentMessage is set. when( () => channel.getReplies( diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart index ec5ea5e9ed..5bb1af1140 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_unread_controller_test.dart @@ -120,9 +120,13 @@ void main() { when(() => channel.client).thenReturn(client); when(() => channel.state).thenReturn(channelState); when(() => client.isLocalUnreadCountEnabled).thenReturn(false); - when(() => channel.markRead()).thenAnswer((_) async => EmptyResponse()); - when(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse()); - when(() => channel.markThreadRead(any())).thenAnswer((_) async => EmptyResponse()); + when(() => channel.markRead()).thenAnswer((_) async => const Result.success(MarkReadResponse(duration: '0ms'))); + when( + () => channel.markRead(messageId: any(named: 'messageId')), + ).thenAnswer((_) async => const Result.success(MarkReadResponse(duration: '0ms'))); + when( + () => channel.markThreadRead(any()), + ).thenAnswer((_) async => const Result.success(MarkReadResponse(duration: '0ms'))); when(() => channelState.currentUserRead).thenReturn(null); messages = []; @@ -518,7 +522,9 @@ void main() { test('a failed mark-read can be retried for the same state', () { when(() => channelState.unreadCount).thenReturn(3); when(() => channelState.currentUserRead).thenReturn(read(unreadMessages: 3)); - when(() => channel.markRead()).thenAnswer((_) => Future.error(Exception('offline'))); + when(() => channel.markRead()).thenAnswer( + (_) async => const Result.failure(StreamNetworkException(message: 'offline')), + ); messages = [message(id: 'newest')]; fakeAsync((async) { diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_row_rebuild_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_row_rebuild_test.dart index bb7f93aca5..46b24b162f 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/message_row_rebuild_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_row_rebuild_test.dart @@ -50,7 +50,9 @@ void main() { when(() => channelClientState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); when(() => channelClientState.messagesStream).thenAnswer((_) => messagesController.stream); - when(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse()); + when( + () => channel.markRead(messageId: any(named: 'messageId')), + ).thenAnswer((_) async => const Result.success(MarkReadResponse(duration: '0ms'))); }); Widget buildApp({required StreamMessageItemBuilder messageBuilder}) { diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_row_updates_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_row_updates_test.dart index bdb9bd45b4..560eb36df0 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/message_row_updates_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_row_updates_test.dart @@ -201,7 +201,9 @@ class _FakeChat { when(() => channel.client).thenReturn(client); when(() => channel.state).thenReturn(channelState); - when(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse()); + when( + () => channel.markRead(messageId: any(named: 'messageId')), + ).thenAnswer((_) async => const Result.success(MarkReadResponse(duration: '0ms'))); when(() => channelState.threadsStream).thenAnswer((_) => const Stream.empty()); when(() => channelState.isUpToDate).thenReturn(true); diff --git a/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart index 095847065d..ff6e14f46b 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/unread_divider_test.dart @@ -80,7 +80,9 @@ void main() { when(() => channelClientState.messagesStream).thenAnswer((_) => messagesController.stream); when(() => channelClientState.isMarkedAsUnread).thenReturn(false); - when(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse()); + when( + () => channel.markRead(messageId: any(named: 'messageId')), + ).thenAnswer((_) async => const Result.success(MarkReadResponse(duration: '0ms'))); }); Future pumpMessageList( From 1d35681277a1944d62e0d5e95c754bfcb5d4aa0e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 6 Oct 2026 13:35:51 +0200 Subject: [PATCH 2/4] refactor(llc): keep the delivery reporter's try/catch around the callback Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 4 +- .../src/client/channel_delivery_reporter.dart | 62 +++++++++---------- 2 files changed, 30 insertions(+), 36 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 7d7d9e6b89..77d95d45da 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -251,7 +251,7 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `StreamChatClient.markChannelsDelivered` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing | | `Channel.markRead` / `markThreadRead` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing, including when the current user cannot send read events. Calling one before the channel is initialized still throws a `StateError` | | `Channel.markUnread` / `markUnreadByTimestamp` / `markThreadUnread` → `Future` | `Future>` | `retyped` | Returns a `Result` instead of throwing, including when the current user cannot send read events, and when `markUnread` counts locally and the message is not among the loaded ones. Calling one before the channel is initialized still throws a `StateError` | -| `MarkChannelsDelivered` = `Future Function(Iterable)` | `Future> Function(Iterable)` | `retyped` | The callback `ChannelDeliveryReporter` takes. Return a failure rather than throwing; the reporter keeps the receipts and sends them with a later batch | +| `MarkChannelsDelivered` = `Future Function(Iterable)` | `Future> Function(Iterable)` | `retyped` | The callback `ChannelDeliveryReporter` takes. Return a failure rather than throwing; the reporter keeps the receipts for a later batch | | `MessageDelivery.toJson` | — | `removed` | `MessageDelivery` is a plain class | | `MessageDelivery` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal | | `StreamChatApi.channel.markRead` / `markUnread` / `markUnreadByTimestamp` / `markThreadRead` / `markThreadUnread` / `markAllRead` / `markChannelsDelivered` | the `StreamChatClient` methods | `removed` | The endpoints moved to the generated client | @@ -771,7 +771,7 @@ if (result case Failure(:final error)) report(error); answered `EmptyResponse`. Marking unread carries no value on success. **`MarkChannelsDelivered` returns a `Result`.** It is the callback `ChannelDeliveryReporter` takes. A custom one -returns a failure rather than throwing, and the reporter keeps the receipts and sends them with a later batch: +returns a failure rather than throwing, and the reporter keeps the receipts for a later batch: ```dart // v10 diff --git a/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart index 84891e05a0..5a7784b041 100644 --- a/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart +++ b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart @@ -42,8 +42,8 @@ class ChannelDeliveryReporter { /// The callback invoked to send delivery receipts. /// /// Receives delivery receipts acknowledging that messages were received. - /// When it returns a failure, or throws, the receipts are kept and sent - /// with a later batch. + /// When it returns a failure, or throws, the receipts are kept for a later + /// batch. final MarkChannelsDelivered onMarkChannelsDelivered; final _deliveryCandidatesLock = Lock(); @@ -169,40 +169,34 @@ class ChannelDeliveryReporter { _logger.d(() => 'Marking ${messageDeliveries.length} channels as delivered'); - final result = await _markDelivered(messageDeliveries); - if (result case Failure(:final error, :final stackTrace)) { - _logger.w(() => 'Failed to mark channels as delivered', error: error, stackTrace: stackTrace); - return; - } - - // Clear the successfully delivered candidates. If a channel's message ID - // has changed since we started delivery, keep it for the next batch. - await _deliveryCandidatesLock.synchronized(() { - for (final delivery in messageDeliveries) { - final deliveredChannelCid = delivery.channelCid; - final deliveredMessageId = delivery.messageId; - - final currentMessage = _deliveryCandidates[deliveredChannelCid]; - // Skip removal if a newer message has been added while we were - // processing the current batch. - if (currentMessage?.id != deliveredMessageId) continue; - _deliveryCandidates.remove(deliveredChannelCid); - } - - // Schedule the next batch if there are remaining candidates. - if (_deliveryCandidates.isNotEmpty) { - _throttledMarkCandidatesAsDelivered.call(); + try { + final result = await onMarkChannelsDelivered(messageDeliveries); + if (result case Failure(:final error, :final stackTrace)) { + _logger.w(() => 'Failed to mark channels as delivered', error: error, stackTrace: stackTrace); + return; } - }); - } - // Calls [onMarkChannelsDelivered], reporting an error it throws as a - // failure so the receipts are kept for a later batch. - Future> _markDelivered(Iterable deliveries) async { - try { - return await onMarkChannelsDelivered(deliveries); - } catch (error, stackTrace) { - return Result.failure(error, stackTrace); + // Clear the successfully delivered candidates. If a channel's message ID + // has changed since we started delivery, keep it for the next batch. + await _deliveryCandidatesLock.synchronized(() { + for (final delivery in messageDeliveries) { + final deliveredChannelCid = delivery.channelCid; + final deliveredMessageId = delivery.messageId; + + final currentMessage = _deliveryCandidates[deliveredChannelCid]; + // Skip removal if a newer message has been added while we were + // processing the current batch. + if (currentMessage?.id != deliveredMessageId) continue; + _deliveryCandidates.remove(deliveredChannelCid); + } + + // Schedule the next batch if there are remaining candidates. + if (_deliveryCandidates.isNotEmpty) { + _throttledMarkCandidatesAsDelivered.call(); + } + }); + } catch (e, stk) { + _logger.w(() => 'Failed to mark channels as delivered', error: e, stackTrace: stk); } } From 03bf719315c8715fdc636cc25765d67955dbd978 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 9 Oct 2026 16:29:11 +0200 Subject: [PATCH 3/4] refactor(llc): name the message delivery request mapper like the others Co-Authored-By: Claude Opus 5.5 --- .../lib/src/repository/mapper/channels_mapper.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart index 220837e675..87bdfee655 100644 --- a/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart +++ b/packages/stream_chat/lib/src/repository/mapper/channels_mapper.dart @@ -227,8 +227,8 @@ extension MarkDeliveredResponseMapper on api.MarkDeliveredResponse { MarkDeliveredResponse toModel() => MarkDeliveredResponse(duration: duration); } -/// Maps a [MessageDelivery] to a generated [api.DeliveredMessagePayload]. -extension MessageDeliveryMapper on MessageDelivery { +/// Maps a [MessageDelivery] to the generated [api.DeliveredMessagePayload]. +extension MessageDeliveryRequestMapper on MessageDelivery { /// Converts this receipt into an [api.DeliveredMessagePayload]. api.DeliveredMessagePayload toRequest() => api.DeliveredMessagePayload(cid: channelCid, id: messageId); } From c869e739f499c18adfebb7b6d571c6c4ce9e2b29 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 13 Oct 2026 11:36:22 +0200 Subject: [PATCH 4/4] refactor(llc): align the read receipt repository names and docs with the client Co-Authored-By: Claude Opus 5.5 --- migrations/v11-migration.md | 2 ++ .../lib/src/client/channel/channel.dart | 18 ++++++++++-------- .../stream_chat/lib/src/client/client.dart | 4 ++-- .../src/repository/channels_repository.dart | 10 +++++----- .../message_list_unread_controller.dart | 4 +--- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 743c5b1e20..7bccd1d296 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -979,6 +979,8 @@ Work top to bottom; each item is independently verifiable. - [ ] Apply every row of the [Symbol Map](#symbol-map) - [ ] Re-check custom data access: fields that used to arrive in `extraData` may now be typed properties - [ ] If you implement `AttachmentFileUploader`, return a `Result` from it — see [File Upload](#file-upload) +- [ ] If you pass a custom `onMarkChannelsDelivered` to `ChannelDeliveryReporter`, return a `Result` from it — see + [Read Receipts](#read-receipts) - [ ] If you persist models yourself, re-check nullability as endpoints move to the generated types, which are nullable wherever the API allows it - [ ] Replace `sort: null` on any list controller with `XSort.empty` if you relied on server ordering — see [Sorting](#sorting) diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 01acbb67cb..58fb3a201e 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1831,10 +1831,10 @@ class Channel { /// /// If [usesLocalUnreadCount] is `true` for this channel, this updates the /// unread count locally, on-device, without making a network request, and - /// returns a [MarkReadResponse] whose `duration` is `0ms`. In that case - /// [messageId] is recorded as the read boundary but does **not** narrow the - /// count: the channel is always treated as fully read and the count drops - /// to zero. See [ChannelClientState.markReadLocally]. + /// returns a [MarkReadResponse] whose [MarkReadResponse.duration] is `0ms`. + /// In that case [messageId] is recorded as the read boundary but does + /// **not** narrow the count: the channel is always treated as fully read and + /// the count drops to zero. See [ChannelClientState.markReadLocally]. /// /// Returns a failure if [canUseReadReceipts] is `false`, unless [usesLocalUnreadCount] is `true`. Future> markRead({String? messageId}) async { @@ -1962,8 +1962,9 @@ class Channel { /// Marks a thread in this channel as read for the current user. /// - /// [threadId] is the id of the thread's parent message. Returns a failure - /// if [canUseReadReceipts] is `false`. + /// [threadId] is the id of the thread's parent message. + /// + /// Returns a failure if [canUseReadReceipts] is `false`. Future> markThreadRead(String threadId) async { _checkInitialized(); @@ -1982,8 +1983,9 @@ class Channel { /// Marks a thread in this channel as unread for the current user. /// - /// [threadId] is the id of the thread's parent message. Returns a failure - /// if [canUseReadReceipts] is `false`. + /// [threadId] is the id of the thread's parent message. + /// + /// Returns a failure if [canUseReadReceipts] is `false`. Future> markThreadUnread(String threadId) async { _checkInitialized(); diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index fbfb600a4e..f70b35c0a6 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1864,7 +1864,7 @@ class StreamChatClient { } /// Marks all of the current user's channels as read. - Future> markAllRead() => _channelsRepository.markChannelsRead(); + Future> markAllRead() => _channelsRepository.markAllRead(); /// Sends delivery receipts for the latest messages in multiple channels. /// @@ -1885,7 +1885,7 @@ class StreamChatClient { /// Accepts up to 100 channels per call. Future> markChannelsDelivered( Iterable deliveries, - ) => _channelsRepository.markDelivered(deliveries); + ) => _channelsRepository.markChannelsDelivered(deliveries); /// Send an event to a particular channel Future sendEvent( diff --git a/packages/stream_chat/lib/src/repository/channels_repository.dart b/packages/stream_chat/lib/src/repository/channels_repository.dart index fa73605bf6..7ba39f2518 100644 --- a/packages/stream_chat/lib/src/repository/channels_repository.dart +++ b/packages/stream_chat/lib/src/repository/channels_repository.dart @@ -115,9 +115,9 @@ class ChannelsRepository { /// Marks a channel, or one of its threads, as unread for the current user. /// - /// Takes exactly one of [messageId], [messageTimestamp] and [threadId]. [messageId] marks that message and every - /// later one as unread, [messageTimestamp] every message created after it, and [threadId], the id of a thread's - /// parent message, that thread. + /// Takes exactly one of [messageId], [messageTimestamp] and [threadId]. With [messageId], marks that message and + /// every later one as unread. With [messageTimestamp], marks every message created after it as unread. With + /// [threadId], the id of a thread's parent message, marks that thread as unread. Future> markUnread( String channelId, String channelType, { @@ -139,14 +139,14 @@ class ChannelsRepository { } /// Marks all of the current user's channels as read. - Future> markChannelsRead() async { + Future> markAllRead() async { final result = await _api.markChannelsRead(markChannelsReadRequest: const api.MarkChannelsReadRequest()); return result.map((response) => response.toModel()); } /// Sends delivery receipts for the messages in [deliveries]. - Future> markDelivered(Iterable deliveries) async { + Future> markChannelsDelivered(Iterable deliveries) async { final result = await _api.markDelivered( markDeliveredRequest: api.MarkDeliveredRequest( latestDeliveredMessages: [...deliveries.map((it) => it.toRequest())], diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart index 5c4d7cd148..8db8fa9ec8 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_unread_controller.dart @@ -314,9 +314,7 @@ class MessageListUnreadController { // error. Future? _retryableMarkRead(Future>? request) { return request?.then( - (result) { - if (result.isFailure) _lastMarkReadAttempt = null; - }, + (result) => result.onFailure((_, _) => _lastMarkReadAttempt = null), onError: (_, __) => _lastMarkReadAttempt = null, ); }