Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions migrations/v11-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ onto Stream's OpenAPI-generated API client.
- [Partial Updates](#partial-updates)
- [Channel Lifecycle](#channel-lifecycle)
- [Read Receipts](#read-receipts)
- [Unread Counts](#unread-counts)
- [Migration Checklist](#migration-checklist)
- [For AI Agents](#for-ai-agents)
- [Contributing to this guide](#contributing-to-this-guide)
Expand Down Expand Up @@ -84,6 +85,7 @@ from the spec, so don't subclass them or depend on their private constructors.
| [**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` |
| [**Unread Counts**](#unread-counts) | `getUnreadCount` returns a `Result<GetUnreadCountResponse>` instead of throwing; the response and its `UnreadCounts*` models no longer decode JSON and compare by value |
| _(filled in per feature as PRs land)_ | |

---
Expand Down Expand Up @@ -255,6 +257,12 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed`
| `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 |
| `StreamChatClient.getUnreadCount` → `Future<GetUnreadCountResponse>` | `Future<Result<GetUnreadCountResponse>>` | `retyped` | Returns a `Result` instead of throwing. The current user's unread counts are still updated on success |
| `GetUnreadCountResponse.fromJson`, `UnreadCountsChannel.fromJson` / `toJson`, `UnreadCountsThread.fromJson` / `toJson`, `UnreadCountsChannelType.fromJson` / `toJson` | — | `removed` | The response and models are plain classes; construct them directly |
| `GetUnreadCountResponse()..totalUnreadCount = …` and its other setters | `GetUnreadCountResponse(duration: …, totalUnreadCount: …, …)` | `retyped` | A plain class with a const constructor and final fields |
| `GetUnreadCountResponse`, `UnreadCountsChannel`, `UnreadCountsThread`, `UnreadCountsChannelType` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal |
| `GetUnreadCountResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` |
| `StreamChatApi.user.getUnreadCount` | `StreamChatClient.getUnreadCount` | `removed` | The endpoint moved to the generated client |
| _(more added per feature as PRs land)_ | | | |

---
Expand Down Expand Up @@ -796,6 +804,40 @@ ChannelDeliveryReporter(
> 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.

### Unread Counts

**`StreamChatClient.getUnreadCount` returns a `Result<GetUnreadCountResponse>` instead of throwing.** A
`try`/`catch` around it still compiles, but no longer catches a failed call: read the returned `Result` instead.
On success it still updates the current user's `totalUnreadCount`, `unreadChannels` and `unreadThreads`; a
failure leaves them as they were.

```dart
// v10
try {
final counts = await client.getUnreadCount();
showBadge(counts.totalUnreadCount);
} on StreamChatException catch (e) {
report(e);
}

// v11
final result = await client.getUnreadCount();
switch (result) {
case Success(:final data): showBadge(data.totalUnreadCount);
case Failure(:final error): report(error);
}
```

**`UnreadCountsChannel`, `UnreadCountsThread` and `UnreadCountsChannelType` no longer decode from or encode to
JSON, and `GetUnreadCountResponse` no longer decodes from it.** All four compare by value and gain `copyWith`.
`GetUnreadCountResponse` is now a plain class with a const constructor and final fields, and its `duration` is
always present.

**`StreamChatApi.user.getUnreadCount` is removed.** Call `StreamChatClient.getUnreadCount`.

> **Why:** the endpoint moved onto the generated client, which returns a `Result` for every call. The response
> keeps its v10 name and fields; what changes is the error handling, the JSON codecs and value equality.

### Moderation

**Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient`
Expand Down
6 changes: 2 additions & 4 deletions openapi-migration/09-users.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 09 — Users

**Goal:** `User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the most.
**Goal:** `User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the most. [18](18-unread-counts.md) split the current user's unread counts out of it.

**Size:** 9 hand-written method(s) across 1 file(s) → 9 generated operation(s).
**Size:** 8 hand-written method(s) across 1 file(s) → 8 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.
Expand All @@ -19,7 +19,6 @@
| `user_api.dart` | `blockUser` | `UserBlockResponse` |
| `user_api.dart` | `unblockUser` | `EmptyResponse` |
| `user_api.dart` | `queryBlockedUsers` | `BlockedUsersResponse` |
| `user_api.dart` | `getUnreadCount` | `GetUnreadCountResponse` |
| `user_api.dart` | `getActiveLiveLocations` | `GetActiveLiveLocationsResponse` |
| `user_api.dart` | `updateLiveLocation` | `Location` |

Expand All @@ -32,7 +31,6 @@
| `GET` | `/api/v2/users/live_locations` | `getUserLiveLocations` | `SharedLocationsResponse` |
| `GET` | `/api/v2/users` | `queryUsers` | `QueryUsersResponse` |
| `POST` | `/api/v2/users/unblock` | `unblockUsers` | `UnblockUsersResponse` |
| `GET` | `/api/v2/chat/unread` | `unreadCounts` | `WrappedUnreadCountsResponse` |
| `PUT` | `/api/v2/users/live_locations` | `updateLiveLocation` | `SharedLocationResponse` |
| `POST` | `/api/v2/users` | `updateUsers` | `UpdateUsersResponse` |
| `PATCH` | `/api/v2/users` | `updateUsersPartial` | `UpdateUsersResponse` |
Expand Down
58 changes: 58 additions & 0 deletions openapi-migration/18-unread-counts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 18 — Unread Counts

**Goal:** Move reading the current user's unread counts ahead of [09](09-users.md): it takes no parameters, answers only counts, and needs none of the `User` restructuring.

**Size:** 0 hand-written method(s) across 0 file(s) → 1 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 |
| --- | --- | --- | --- |
| `GET` | `/api/v2/chat/unread` | `unreadCounts` | `WrappedUnreadCountsResponse` |

## Decisions taken

- **Split out of [09](09-users.md), ahead of it.** Nothing persists the response, and it embeds no
`User`.
- **The v2 route is the v1 handler.** `lib/chat/routes.go` mounts `/unread` and `/api/v2/chat/unread`
on the same `v1.UnreadCounts` in the shared `coreRoutes`. It is gated by
`ClassicUnreadCountsEnabled` on both, so the switch changes nothing about who may call it. It is
not in beta or deprecated.
- **Moved off `UserApi`:** `getUnreadCount`, now a `StreamChatClient` method over a new
`UsersRepository`, mirroring `UserApi`.
- **The v10 names stay:** `getUnreadCount` and `GetUnreadCountResponse`, rather than the spec's
`unreadCounts` and `WrappedUnreadCountsResponse`, which say nothing a caller needs.
- **`GetUnreadCountResponse` and the `UnreadCounts*` models are freezed plain models,** with v10's
fields and nullability; the generated types match them field for field. `duration` is non-null.
- **The current user's counts are updated only on success,** through `Result.onSuccess`, as v10 did
by throwing before it reached the update.

## 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<Result<T>>`; 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.
6 changes: 4 additions & 2 deletions openapi-migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ generated operations in scope, the decisions that group has to make, its risks,
| [06](06-reminders.md) | Message Reminders | 4 | 4 | ☐ |
| [07](07-threads-and-drafts.md) | Threads & Drafts | 7 | 7 | ☐ |
| [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | ☑ |
| [09](09-users.md) | Users | 9 | 9 | ☐ |
| [09](09-users.md) | Users | 8 | 8 | ☐ |
| [10](10-messages.md) | Messages & Search | 14 | 12 | ☐ |
| [11](11-channels-and-members.md) | Channels, Members & Sync | 13 | 15 | ☐ |
| [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☑ |
Expand All @@ -29,8 +29,9 @@ generated operations in scope, the decisions that group has to make, its risks,
| [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 | ☑ |
| [18](18-unread-counts.md) | Unread Counts — split out of 09 | 0 | 1 | ☑ |

**Coverage:** 70 hand-written methods across 10 files, and all 129 generated operations, each claimed by exactly
**Coverage:** 69 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).


Expand Down Expand Up @@ -176,6 +177,7 @@ surfaces before it reaches `Message` and `ChannelState`:
`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.
- **18** is the current user's unread counts, split out of 09 because it embeds no `User` and nothing persists it.
- **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
Expand Down
30 changes: 28 additions & 2 deletions openapi-migration/tool/generate_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,9 +516,9 @@ def match(verb, path):
dict(
num='09', slug='users', title='Users',
hand=['user_api.dart'],
match=owns('/api/v2/users', '/api/v2/chat/unread'),
match=owns('/api/v2/users'),
goal='`User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the '
'most.',
'most. [18](18-unread-counts.md) split the current user\'s unread counts out of it.',
decisions=[
'`User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group '
'restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the '
Expand Down Expand Up @@ -918,6 +918,32 @@ def match(verb, path):
risks=[],
done=DONE.replace('- [ ]', '- [x]'),
),
dict(
num='18', slug='unread-counts', title='Unread Counts',
hand=[],
match=only_ops('GET /api/v2/chat/unread'),
goal='Move reading the current user\'s unread counts ahead of [09](09-users.md): it takes no parameters, '
'answers only counts, and needs none of the `User` restructuring.',
decisions=[],
taken=textwrap.dedent("""\
- **Split out of [09](09-users.md), ahead of it.** Nothing persists the response, and it embeds no
`User`.
- **The v2 route is the v1 handler.** `lib/chat/routes.go` mounts `/unread` and `/api/v2/chat/unread`
on the same `v1.UnreadCounts` in the shared `coreRoutes`. It is gated by
`ClassicUnreadCountsEnabled` on both, so the switch changes nothing about who may call it. It is
not in beta or deprecated.
- **Moved off `UserApi`:** `getUnreadCount`, now a `StreamChatClient` method over a new
`UsersRepository`, mirroring `UserApi`.
- **The v10 names stay:** `getUnreadCount` and `GetUnreadCountResponse`, rather than the spec's
`unreadCounts` and `WrappedUnreadCountsResponse`, which say nothing a caller needs.
- **`GetUnreadCountResponse` and the `UnreadCounts*` models are freezed plain models,** with v10's
fields and nullability; the generated types match them field for field. `duration` is non-null.
- **The current user's counts are updated only on success,** through `Result.onSuccess`, as v10 did
by throwing before it reached the update.
"""),
risks=[],
done=DONE.replace('- [ ]', '- [x]'),
),
]


Expand Down
5 changes: 5 additions & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@
- `StreamChatClient.markChannelsDelivered` returns a `Result<MarkDeliveredResponse>` instead of throwing, and `MarkChannelsDelivered`, the callback `ChannelDeliveryReporter` takes, returns a `Future<Result<void>>`.
- `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.
- `StreamChatClient.getUnreadCount` returns a `Result<GetUnreadCountResponse>` instead of throwing.
- `GetUnreadCountResponse` no longer decodes from JSON, is immutable, built through a const constructor, and its `duration` is a non-nullable `String`.
- `UnreadCountsChannel`, `UnreadCountsThread` and `UnreadCountsChannelType` no longer decode from or encode to JSON.
- `GetUnreadCountResponse`, `UnreadCountsChannel`, `UnreadCountsThread` and `UnreadCountsChannelType` compare by value and gain `copyWith`.
- `StreamChatApi.user.getUnreadCount` is removed; call `StreamChatClient.getUnreadCount` instead.

🐞 Fixed

Expand Down
27 changes: 14 additions & 13 deletions packages/stream_chat/lib/src/client/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ 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';
import '../core/models/response/delete_channel_response.dart';
import '../core/models/response/get_unread_count_response.dart';
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';
Expand Down Expand Up @@ -94,6 +95,7 @@ import '../repository/general_repository.dart';
import '../repository/moderation_repository.dart';
import '../repository/roles_repository.dart';
import '../repository/user_groups_repository.dart';
import '../repository/users_repository.dart';
import '../ws/connect_request.dart';
import '../ws/connection_manager.dart';
import '../ws/connection_status.dart';
Expand Down Expand Up @@ -195,6 +197,7 @@ class StreamChatClient {
_rolesRepository = RolesRepository(api);
_devicesRepository = DevicesRepository(api);
_userGroupsRepository = UserGroupsRepository(api);
_usersRepository = UsersRepository(api);
_generalRepository = GeneralRepository(api);
_moderationRepository = ModerationRepository(api);
_channelsRepository = ChannelsRepository(api);
Expand Down Expand Up @@ -239,6 +242,7 @@ class StreamChatClient {
late final RolesRepository _rolesRepository;
late final DevicesRepository _devicesRepository;
late final UserGroupsRepository _userGroupsRepository;
late final UsersRepository _usersRepository;
late final GeneralRepository _generalRepository;
late final ModerationRepository _moderationRepository;
late final ChannelsRepository _channelsRepository;
Expand Down Expand Up @@ -1846,21 +1850,18 @@ class StreamChatClient {
}
}

/// Returns the unread count information for the current user.
Future<GetUnreadCountResponse> getUnreadCount() async {
final response = await _chatApi.user.getUnreadCount();

// Emit an local event with the unread count information as a side effect
// in order to update the current user state.
handleEvent(
Event(
totalUnreadCount: response.totalUnreadCount,
unreadChannels: response.channels.length,
unreadThreads: response.threads.length,
/// Gets how many unread messages and threads the current user has.
Future<Result<GetUnreadCountResponse>> getUnreadCount() async {
final result = await _usersRepository.getUnreadCount();
return result.onSuccess(
(response) => handleEvent(
Event(
totalUnreadCount: response.totalUnreadCount,
unreadChannels: response.channels.length,
unreadThreads: response.threads.length,
),
),
);

return response;
}

/// Marks all of the current user's channels as read.
Expand Down
26 changes: 0 additions & 26 deletions packages/stream_chat/lib/src/core/api/responses.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import '../models/push_preference.dart';
import '../models/reaction.dart';
import '../models/read.dart';
import '../models/thread.dart';
import '../models/unread_counts.dart';
import '../models/user.dart';
import '../models/user_block.dart';

Expand Down Expand Up @@ -621,31 +620,6 @@ class QueryRemindersResponse extends _BaseResponse {
static QueryRemindersResponse fromJson(Map<String, dynamic> json) => _$QueryRemindersResponseFromJson(json);
}

/// Model response for [StreamChatClient.getUnreadCount] api call
@JsonSerializable(createToJson: false)
class GetUnreadCountResponse extends _BaseResponse {
/// Total number of unread messages across all channels
late int totalUnreadCount;

/// Total number of threads with unread replies
late int totalUnreadThreadsCount;

/// Total number of unread messages grouped by team
late Map<String, int>? totalUnreadCountByTeam;

/// List of channels with unread messages
late List<UnreadCountsChannel> channels;

/// Summary of unread counts grouped by channel type
late List<UnreadCountsChannelType> channelType;

/// List of threads with unread replies
late List<UnreadCountsThread> threads;

/// Create a new instance from a json
static GetUnreadCountResponse fromJson(Map<String, dynamic> json) => _$GetUnreadCountResponseFromJson(json);
}

/// Model response for [StreamChatClient.setPushPreferences] api call
@JsonSerializable(createToJson: false)
class UpsertPushPreferencesResponse extends _BaseResponse {
Expand Down
Loading
Loading