Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
2 changes: 2 additions & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Added `Event.watcherCount`, exposing the server-provided `watcher_count` field on events (e.g. `user.watching.start`, `user.watching.stop`, `message.new`).
- Added `StreamChatNetworkError.type` (a `StreamChatNetworkErrorType` capturing the transport failure kind — connection error, timeout, cancellation, etc.).
- Added support for sending and deleting reactions while offline.

⚠️ Deprecated

Expand All @@ -12,6 +13,7 @@
🔄 Changed

- Raised the minimum `dio` version to `^5.11.0`.
- When offline storage is enabled, `Channel.sendReaction` and `Channel.deleteReaction` keep the optimistic change on a transient/offline error and replay it on reconnect, instead of reverting it.

🐞 Fixed

Expand Down
73 changes: 59 additions & 14 deletions packages/stream_chat/lib/src/client/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'dart:math' as math;

import 'package:collection/collection.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/client/reaction_pending_operation.dart';
import 'package:stream_chat/src/client/retry_queue.dart';
import 'package:stream_chat/src/core/util/utils.dart';
import 'package:stream_chat/stream_chat.dart';
Expand Down Expand Up @@ -1616,19 +1617,33 @@ class Channel {
state?.updateMessage(updatedMessage);

try {
final reactionResp = await _client.sendReaction(
return await _client.sendReaction(
messageId,
reaction,
skipPush: skipPush,
enforceUnique: enforceUnique,
);
return reactionResp;
} catch (_) {
// Reset the message if the update fails. Use replace (not merge)
// so the rollback wins over the optimistic local state — otherwise
// `Message.updateWith`'s enrichment preservation would keep the
// optimistic `ownReactions` for messages that previously had none.
state?.replaceMessage(message);
} catch (e) {
final retriable = e is StreamChatNetworkError && e.isRetriable;
// Enqueue the operation for retry when back online, keeping the
// optimistic state only if it was actually enqueued.
final enqueued =
_client.persistenceEnabled &&
retriable &&
await _enqueuePendingOperation(
ReactionPendingOperation.add(
reaction,
skipPush: skipPush,
enforceUnique: enforceUnique,
),
);
if (!enqueued) {
// Reset the message if the update fails. Use replace (not merge)
// so the rollback wins over the optimistic local state — otherwise
// `Message.updateWith`'s enrichment preservation would keep the
// optimistic `ownReactions` for messages that previously had none.
state?.replaceMessage(message);
}
rethrow;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not entirely sure if we should always rethrow here, maybe just in the non-retry-able case?

}
}
Expand All @@ -1647,19 +1662,49 @@ class Channel {
state?.updateMessage(updatedMessage);

try {
final deleteResponse = await _client.deleteReaction(
return await _client.deleteReaction(
message.id,
reaction.type,
);
return deleteResponse;
} catch (_) {
// Reset the message if the update fails. Use replace (not merge)
// for symmetry with `sendReaction` — see that method for context.
state?.replaceMessage(message);
} catch (e) {
final retriable = e is StreamChatNetworkError && e.isRetriable;
// Enqueue the operation for retry when back online, keeping the
// optimistic state only if it was actually enqueued.
final enqueued =
_client.persistenceEnabled &&
retriable &&
await _enqueuePendingOperation(
ReactionPendingOperation.delete(
messageId: message.id,
reactionType: reaction.type,
),
);
if (!enqueued) {
// Reset the message if the update fails. Use replace (not merge)
// for symmetry with `sendReaction` — see that method for context.
state?.replaceMessage(message);
}
rethrow;
}
}

/// Persists [operation] to the pending-operation queue when a persistence
/// client is available.
///
/// Returns `true` when the operation was enqueued, or `false` when there is
/// no persistence client or the insert failed.
Future<bool> _enqueuePendingOperation(PendingOperation operation) async {
final persistence = _client.chatPersistenceClient;
if (persistence == null) return false;
try {
await persistence.insertPendingOperation(operation);
return true;
} catch (e, stk) {
client.logger.warning('Failed to enqueue pending operation', e, stk);
return false;
}
}

/// Sends an event to stop AI response generation, leaving the message in
/// its current state.
Future<EmptyResponse> stopAIResponse() async {
Expand Down
7 changes: 7 additions & 0 deletions packages/stream_chat/lib/src/client/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/client/channel.dart';
import 'package:stream_chat/src/client/channel_delivery_reporter.dart';
import 'package:stream_chat/src/client/event_resolvers.dart' as event_resolvers;
import 'package:stream_chat/src/client/pending_operation_replayer.dart';
import 'package:stream_chat/src/client/query_channels_result.dart';
import 'package:stream_chat/src/client/retry_policy.dart';
import 'package:stream_chat/src/core/api/attachment_file_uploader.dart';
Expand Down Expand Up @@ -162,6 +163,7 @@ class StreamChatClient {
final _tokenManager = TokenManager();
final _connectionIdManager = ConnectionIdManager();
late final _appSettingsManager = AppSettingsManager(_chatApi.general);
late final _pendingOperationReplayer = PendingOperationReplayer(this);
static final _systemEnvironmentManager = SystemEnvironmentManager();

/// Updates the system environment information used by the client.
Expand Down Expand Up @@ -588,6 +590,11 @@ class StreamChatClient {
final connectionRecovered = !wasConnected && isConnected;

if (connectionRecovered) {
// Replay pending offline operations (e.g. reactions) BEFORE any
// server-state refresh, so the server has each mutation before a re-query
// returns state that would otherwise clobber the optimistic change.
await _pendingOperationReplayer.replay();

// connection recovered
final cids = [...state.channels.keys.toSet()];
if (cids.isNotEmpty) {
Expand Down
119 changes: 119 additions & 0 deletions packages/stream_chat/lib/src/client/pending_operation_replayer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import 'package:stream_chat/src/client/client.dart';
import 'package:stream_chat/src/client/reaction_pending_operation.dart';
import 'package:stream_chat/src/core/error/error.dart';
import 'package:stream_chat/src/core/models/pending_operation.dart';
import 'package:stream_chat/src/core/models/reaction.dart';

/// Replays the stored [PendingOperation] queue against the server when the
/// connection is recovered.
///
/// Replay is at-least-once: an operation is removed from the queue only after
/// the server accepts or terminally rejects it, so a crash between acceptance
/// and removal re-sends it on the next recovery. Every operation type handled
/// by [_replayCallFor] must therefore be idempotent on the server — e.g.
/// reactions dedupe by (message, type, user).
class PendingOperationReplayer {
/// Creates a replayer for [client]'s pending-operation queue.
PendingOperationReplayer(this._client);

final StreamChatClient _client;

// Prevents overlapping replays.
bool _isReplaying = false;

/// Replays each stored operation against the server in insertion order.
Future<void> replay() async {
final persistence = _client.chatPersistenceClient;
if (persistence == null) return;
if (_isReplaying) return;
_isReplaying = true;

try {
final operations = await persistence.getPendingOperations();
for (final operation in operations) {
try {
final Future<void> Function()? call;
try {
call = _replayCallFor(operation);
} catch (error, stk) {
// Malformed payload for a known type — can never be replayed.
_client.logger.warning(
'Dropping unreplayable pending operation ${operation.id}',
error,
stk,
);
await persistence.deletePendingOperation(operation.id!);
continue;
}

if (call == null) {
// Unknown type (e.g. persisted by a newer app version) — drop it.
_client.logger.warning(
'Dropping unknown pending operation type "${operation.type}" '
'(${operation.id})',
);
await persistence.deletePendingOperation(operation.id!);
continue;
}

try {
await call();
} on StreamChatNetworkError catch (error) {
// Keep transient failures for the next recovery.
if (error.isRetriable) continue;
}

// Accepted or terminally rejected by the server — drop it.
await persistence.deletePendingOperation(operation.id!);
} catch (error, stk) {
_client.logger.warning(
'Error replaying pending operation ${operation.id}',
error,
stk,
);
}
}
} catch (error, stk) {
_client.logger.severe(
'Error replaying pending operations',
error,
stk,
);
} finally {
_isReplaying = false;
}
}

/// Returns the server call that replays [operation], or `null` if its type
/// is unknown to this version.
Future<void> Function()? _replayCallFor(PendingOperation operation) {
switch (operation.type) {
case ReactionPendingOperation.addType:
final targetMessageId = operation.targetMessageId;
if (targetMessageId == null) {
throw StateError('Missing targetMessageId for ${operation.type}');
}
final reaction = Reaction.fromJson(
operation.payload[ReactionPendingOperation.reactionKey] as Map<String, dynamic>,
);
final skipPush = operation.payload[ReactionPendingOperation.skipPushKey] as bool? ?? false;
final enforceUnique = operation.payload[ReactionPendingOperation.enforceUniqueKey] as bool? ?? false;
return () => _client.sendReaction(
targetMessageId,
reaction,
skipPush: skipPush,
enforceUnique: enforceUnique,
);
case ReactionPendingOperation.deleteType:
final targetMessageId = operation.targetMessageId;
if (targetMessageId == null) {
throw StateError('Missing targetMessageId for ${operation.type}');
}
final reactionType = operation.payload[ReactionPendingOperation.reactionTypeKey] as String;
return () => _client.deleteReaction(targetMessageId, reactionType);
default:
// Unknown operation type — cannot be replayed by this version.
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import 'package:stream_chat/src/core/models/pending_operation.dart';
import 'package:stream_chat/src/core/models/reaction.dart';

/// Builds and identifies the reaction-specific forms of [PendingOperation].
abstract class ReactionPendingOperation {
/// The [PendingOperation.type] discriminator for a reaction add.
static const addType = 'reaction.add';

/// The [PendingOperation.type] discriminator for a reaction delete.
static const deleteType = 'reaction.delete';

/// The [PendingOperation.payload] key holding the serialized reaction of an
/// add.
static const reactionKey = 'reaction';

/// The [PendingOperation.payload] key holding the `enforce_unique` flag of an
/// add.
static const enforceUniqueKey = 'enforce_unique';

/// The [PendingOperation.payload] key holding the `skip_push` flag of an add.
static const skipPushKey = 'skip_push';

/// The [PendingOperation.payload] key holding the reaction type of a delete.
static const reactionTypeKey = 'reaction_type';

/// Builds the pending operation recording an optimistic reaction add.
static PendingOperation add(
Reaction reaction, {
required bool skipPush,
required bool enforceUnique,
}) => PendingOperation(
type: addType,
targetMessageId: reaction.messageId,
payload: {
reactionKey: reaction.toJson(),
enforceUniqueKey: enforceUnique,
skipPushKey: skipPush,
},
);

/// Builds the pending operation recording an optimistic reaction delete.
static PendingOperation delete({
required String messageId,
required String reactionType,
}) => PendingOperation(
type: deleteType,
targetMessageId: messageId,
payload: {reactionTypeKey: reactionType},
);
}
38 changes: 38 additions & 0 deletions packages/stream_chat/lib/src/core/models/pending_operation.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:equatable/equatable.dart';

/// {@template pendingOperation}
/// A durable record of an optimistic mutation awaiting server confirmation
/// (e.g. a reaction added or removed while offline).
///
/// Operations are replayed at-least-once on reconnect, so the server-side
/// effect of every operation type must be idempotent.
/// {@endtemplate}
class PendingOperation extends Equatable {
/// {@macro pendingOperation}
const PendingOperation({
required this.type,
required this.payload,
this.id,
this.targetMessageId,
});

/// The database autoincrement id, assigned when the operation is stored;
/// `null` until then.
final int? id;

/// The discriminator persisted in the `type` column, e.g. `reaction.add`.
final String type;

/// The id of the message the operation targets, if any.
final String? targetMessageId;

/// The operation-specific value fields, stored as JSON.
final Map<String, dynamic> payload;

@override
List<Object?> get props => [
type,
targetMessageId,
payload,
];
}
Comment on lines +10 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion — make PendingOperation type-safe with a sealed hierarchy, so illegal states stop being representable.

As written this is a stringly-typed bag: type is a String, payload is an untyped Map<String, dynamic>, and targetMessageId is nullable even though every operation type that exists requires it. All of the validation therefore happens at replay time in PendingOperationsManager._replayCallFor, which is why that function needs two StateError throws, two unchecked casts, and two silent ?? false defaults — and why two of the eight manager tests exist only to prove an impossible state is detected.

The DB row should stay generic (type, payload) — that is what makes the table reusable. The in-memory model doesn't have to be. Parse once at the persistence boundary, and replay becomes exhaustive and cast-free.

The repo is on Dart ^3.11 and already uses sealed hierarchies for exactly this shape (MessageState, MessageDeleteScope), so this is idiomatic here:

/// Identity of a queued operation: either a persisted row or session-only.
sealed class PendingOperationId extends Equatable {
  const PendingOperationId();
}

/// An operation mirrored to persistence, identified by its row id.
final class PersistedOperationId extends PendingOperationId {
  const PersistedOperationId(this.value) : assert(value > 0, 'row ids are positive');
  final int value;
  @override
  List<Object?> get props => [value];
}

/// An operation that lives only in memory for this session.
final class SessionOperationId extends PendingOperationId {
  const SessionOperationId(this.value);
  final int value;
  @override
  List<Object?> get props => [value];
}

/// {@macro pendingOperation}
sealed class PendingOperation extends Equatable {
  const PendingOperation({this.id});

  /// Identity of this operation, `null` until it is queued.
  final PendingOperationId? id;

  /// The discriminator persisted in the `type` column.
  String get type;

  /// The message this operation targets.
  String get targetMessageId;

  /// The operation-specific value fields. Must be JSON-encodable.
  Map<String, dynamic> toPayload();

  /// Returns a copy of this operation with [id] assigned.
  PendingOperation withId(PendingOperationId id);

  /// Rebuilds a stored operation, or returns `null` when [type] is unknown to
  /// this version. Throws [FormatException] on a malformed payload.
  static PendingOperation? fromStored({
    required PendingOperationId id,
    required String type,
    required String? targetMessageId,
    required Map<String, dynamic> payload,
  }) => switch (type) {
    AddReactionOperation.opType => AddReactionOperation.fromPayload(id, payload),
    DeleteReactionOperation.opType => DeleteReactionOperation.fromPayload(id, payload),
    _ => null,
  };
}

/// A reaction added while offline, awaiting replay.
final class AddReactionOperation extends PendingOperation {
  const AddReactionOperation({
    required this.reaction,
    this.skipPush = false,
    this.enforceUnique = false,
    super.id,
  });

  /// The discriminator persisted for this operation type.
  static const opType = 'reaction.add';

  /// The reaction to send.
  final Reaction reaction;

  /// Whether to skip the push notification for the reaction.
  final bool skipPush;

  /// Whether the reaction replaces the user's existing one.
  final bool enforceUnique;

  @override
  String get type => opType;

  @override
  String get targetMessageId => reaction.messageId!;

  // ... toPayload / fromPayload / withId / props
}

_replayCallFor then collapses to an exhaustive switch with no default, no null return, and no casts:

Future<void> Function() _replayCallFor(PendingOperation operation) =>
    switch (operation) {
      AddReactionOperation(:final targetMessageId, :final reaction, :final skipPush, :final enforceUnique) =>
        () => _client.sendReaction(targetMessageId, reaction, skipPush: skipPush, enforceUnique: enforceUnique),
      DeleteReactionOperation(:final targetMessageId, :final reactionType) =>
        () => _client.deleteReaction(targetMessageId, reactionType),
    };

What this buys, concretely:

  • enforceUnique can no longer silently default to false — the malformed-payload case becomes a FormatException at the parse boundary, not a semantic change at send time.
  • No StateError for a missing targetMessageId — it is non-nullable by construction.
  • No unchecked as Map<String, dynamic> / as String.
  • The id sign hack disappears. _remove becomes switch (id) { PersistedOperationId(:final value) => deletePendingOperation(value), SessionOperationId() => null } instead of if (id < 0) return, which is what makes the _memorySeq collision above possible in the first place. The assert(value > 0) also documents and enforces the insertPendingOperation contract that is currently only implied.
  • Adding message.send or a channel operation becomes a compile error until handled, instead of a silent default: return null that drops the row.

Two smaller things on the current class while it is being touched: props (L46-50) excludes id, so op == op.copyWith(id: 7) — harmless today because removal matches on id, but a future List.remove(op) would delete the wrong entry. And the doc on L19-20 says "the database autoincrement id, null until then" while PendingOperationsManager also assigns negative session ids to this field, so the documented value domain and the real one disagree.

Fully understand if the sealed refactor is out of scope for this PR — in that case the minimum I would ask for is making the strictness in _replayCallFor consistent (throw on a missing enforce_unique exactly as for targetMessageId), plus documenting on insertPendingOperation that ids must be positive and unique and that payload must be JSON-encodable.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: React Native already ships exactly this shape, which I think settles the "is this over-engineering?" question.

RN's equivalent type is a discriminated union whose payload type is derived from the method being replayed (stream-chat/src/offline-support/types.ts):

export type PendingTask = {
  channelId: string;
  channelType: string;
  messageId: string;
  id?: number;
} & (
  | { type: 'send-reaction';   payload: Parameters<Channel['sendReaction']> }
  | { type: 'delete-reaction'; payload: Parameters<Channel['deleteReaction']> }
  | { type: 'delete-message';  payload: Parameters<StreamChat['deleteMessage']> }
);

and its executor narrows on the discriminant and spreads the payload, so there are no casts and no defaults to get wrong (offline_support_api.ts:1268-1320):

if (task.type === 'send-reaction')   return await channel._sendReaction(...task.payload);
if (task.type === 'delete-reaction') return await channel._deleteReaction(...task.payload);

Same idea as the sealed hierarchy above: the row stays generic (type, payload) on disk, the in-memory model is typed, and the payload can't drift from the signature it feeds. Dart's sealed classes + exhaustive switch expressions are the direct analogue of TS's discriminated union + narrowing, and this repo already uses that pattern (MessageState, MessageDeleteScope).

Two extra reasons this matters more here than it looks:

  • RN's queue already carries four operation families (reactions, send-message, delete-message/update-message, drafts). If Flutter follows, _replayCallFor's default: return null becomes the place every future operation type can be silently forgotten — whereas an exhaustive switch makes each addition a compile error until handled.
  • It closes the id hole from the other thread by construction. A sealed PendingOperationId turns _remove's if (id < 0) return into a pattern match, so the _memorySeq collision stops being expressible.

Worth noting RN also carries channelId/channelType/threadId on the task, because its executor resolves a Channel to replay against. This PR routes replay through _client.sendReaction by message id, so it genuinely doesn't need them — but that's also why nothing here can clean up queued operations per channel.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was considering such approach as well, but in the end I decided for a flat hierarchy, because if we define the PendingOperation as sealed, adding a new subclass in the future could be considered a breaking a change (because the PendingOperation is exposed as public API because of the ChatPersistenceClient bridge, and a customer could potentially be doing switch over an instance of it). I understand that this is most likely an extreme edge-case, so if you think such approach is worth doing, I would be happy to refactor the implementation.

18 changes: 18 additions & 0 deletions packages/stream_chat/lib/src/db/chat_persistence_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:stream_chat/src/core/models/filter.dart';
import 'package:stream_chat/src/core/models/location.dart';
import 'package:stream_chat/src/core/models/member.dart';
import 'package:stream_chat/src/core/models/message.dart';
import 'package:stream_chat/src/core/models/pending_operation.dart';
import 'package:stream_chat/src/core/models/poll.dart';
import 'package:stream_chat/src/core/models/poll_vote.dart';
import 'package:stream_chat/src/core/models/reaction.dart';
Expand Down Expand Up @@ -536,6 +537,23 @@ abstract class ChatPersistenceClient {
]);
}

/// Inserts [operation] into the pending-operation queue.
///
/// Pending operations are optimistic mutations queued for replay once
/// connectivity is restored. The default no-op drops them unless overridden
/// alongside [getPendingOperations] and [deletePendingOperation].
Future<void> insertPendingOperation(PendingOperation operation) async {}

/// Returns all stored pending operations ordered by insertion.
///
/// Defaults to an empty list; see [insertPendingOperation].
Future<List<PendingOperation>> getPendingOperations() async => [];

/// Deletes the pending operation with the given [id].
///
/// No-op by default; see [insertPendingOperation].
Future<void> deletePendingOperation(int id) async {}

List<Reaction> _expandReactions(Message message) {
final own = message.ownReactions;
final latest = message.latestReactions;
Expand Down
1 change: 1 addition & 0 deletions packages/stream_chat/lib/stream_chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export 'src/core/models/message_state.dart';
export 'src/core/models/moderation.dart';
export 'src/core/models/mute.dart';
export 'src/core/models/own_user.dart';
export 'src/core/models/pending_operation.dart';
export 'src/core/models/poll.dart';
export 'src/core/models/poll_option.dart';
export 'src/core/models/poll_vote.dart';
Expand Down
Loading
Loading