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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
- Fixed `ChannelClientState` no longer handling `notification.mark_read` (regression since 9.20.0), which left `unreadCount` stale after `Channel.markRead` on channels the user isn't watching.
- Fixed `Channel.getReplies` adding the parent message to `ChannelClientState.threads` when a backend returns it alongside the replies, which rendered the thread root twice. The online path now filters it out, matching the offline one.
- Fixed truncated channels dropping to the bottom of the list when sorting by `last_updated`.
- Fixed pinned channels appearing at the bottom of the list when sorting by `pinned_at` descending.
- Fixed channels without messages appearing at the top of the list when sorting by `last_message_at` descending.

## 9.27.0

Expand Down
51 changes: 41 additions & 10 deletions packages/stream_chat/lib/src/core/api/sort_order.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// ignore_for_file: constant_identifier_names

import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/core/models/channel_state.dart';
import 'package:stream_chat/src/core/models/comparable_field.dart';

part 'sort_order.g.dart';
Expand Down Expand Up @@ -40,7 +41,7 @@ enum NullOrdering {
/// [ComparableField]). Pass a custom [Comparator] via the `comparator`
/// parameter to override this — e.g. to sort raw codepoints or apply a
/// locale-aware collator.
@JsonSerializable(includeIfNull: false)
@JsonSerializable(createFactory: false, includeIfNull: false)
class SortOption<T extends ComparableFieldProvider> {
/// Creates a new SortOption instance with the specified field and direction.
///
Expand All @@ -51,9 +52,15 @@ class SortOption<T extends ComparableFieldProvider> {
const SortOption(
this.field, {
this.direction = SortOption.DESC,
this.nullOrdering = NullOrdering.nullsFirst,
NullOrdering? nullOrdering,
Comparator<T>? comparator,
}) : _comparator = comparator;
}) : nullOrdering = nullOrdering ??
(direction == SortOption.ASC ||
field == ChannelSortKey.pinnedAt ||
field == ChannelSortKey.lastMessageAt
? NullOrdering.nullsLast
: NullOrdering.nullsFirst),
_comparator = comparator;

/// Creates a SortOption for descending order sorting by the specified field.
///
Expand All @@ -64,9 +71,18 @@ class SortOption<T extends ComparableFieldProvider> {
/// ```
const SortOption.desc(
this.field, {
this.nullOrdering = NullOrdering.nullsFirst,
NullOrdering? nullOrdering,
Comparator<T>? comparator,
}) : direction = SortOption.DESC,
// The server orders pinned_at and last_message_at NULLS LAST whichever
// direction they are sorted in, so pinned and message-less channels
// stay at the end of the list. Every other field is ordered with a bare
// direction, which puts nulls first on a descending sort.
nullOrdering = nullOrdering ??
(field == ChannelSortKey.pinnedAt ||
field == ChannelSortKey.lastMessageAt
? NullOrdering.nullsLast
: NullOrdering.nullsFirst),
_comparator = comparator;

/// Creates a SortOption for ascending order sorting by the specified field.
Expand All @@ -78,14 +94,26 @@ class SortOption<T extends ComparableFieldProvider> {
/// ```
const SortOption.asc(
this.field, {
this.nullOrdering = NullOrdering.nullsLast,
NullOrdering? nullOrdering,
Comparator<T>? comparator,
}) : direction = SortOption.ASC,
// Every field the server sorts ascending orders nulls last, either
// explicitly or by inheriting the default.
nullOrdering = nullOrdering ?? NullOrdering.nullsLast,
_comparator = comparator;

/// Create a new instance from JSON.
factory SortOption.fromJson(Map<String, dynamic> json) =>
_$SortOptionFromJson(json);
/// Creates a [SortOption] from its JSON-serialized representation.
///
/// Reconstructs via [SortOption.desc] / [SortOption.asc] based on the
/// `direction` field; [nullOrdering] resolves to the default for the field
/// and any custom comparator is discarded (comparators are not serialized).
factory SortOption.fromJson(Map<String, dynamic> json) {
final field = json['field'] as String;
final direction = (json['direction'] as num?)?.toInt() ?? SortOption.DESC;
return direction == SortOption.DESC
? SortOption<T>.desc(field)
: SortOption<T>.asc(field);
}

/// Ascending order (1)
static const ASC = 1;
Expand All @@ -101,8 +129,11 @@ class SortOption<T extends ComparableFieldProvider> {

/// The null ordering strategy to use when comparing null values.
///
/// Defaults to `NullOrdering.nullsFirst`, which treats null values as less
/// than any non-null value.
/// When not passed to the constructor, defaults to the ordering the server
/// applies for [field]: [NullOrdering.nullsLast] for
/// [ChannelSortKey.pinnedAt] and [ChannelSortKey.lastMessageAt] in either
/// direction, and for every field on an ascending sort;
/// [NullOrdering.nullsFirst] for any other field on a descending sort.
@JsonKey(includeToJson: false, includeFromJson: false)
final NullOrdering nullOrdering;

Expand Down
7 changes: 0 additions & 7 deletions packages/stream_chat/lib/src/core/api/sort_order.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

206 changes: 206 additions & 0 deletions packages/stream_chat/test/src/core/api/sort_order_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:stream_chat/src/core/api/sort_order.dart';
import 'package:stream_chat/src/core/models/channel_model.dart';
import 'package:stream_chat/src/core/models/channel_state.dart';
import 'package:stream_chat/src/core/models/comparable_field.dart';
import 'package:stream_chat/src/core/models/member.dart';
import 'package:test/test.dart';

/// Simple test model that implements ComparableFieldProvider
Expand Down Expand Up @@ -71,6 +74,209 @@ void main() {
expect(option.field, 'age');
expect(option.direction, SortOption.ASC);
});

test('should default to DESC when direction is missing from JSON', () {
final option = SortOption<TestModel>.fromJson({'field': 'age'});
expect(option.field, 'age');
expect(option.direction, SortOption.DESC);
});

test(
'should default pinnedAt and lastMessageAt to nullsLast in both '
'directions',
() {
const sortKeys = [
ChannelSortKey.pinnedAt,
ChannelSortKey.lastMessageAt,
];

for (final key in sortKeys) {
expect(
SortOption<ChannelState>.desc(key).nullOrdering,
NullOrdering.nullsLast,
reason: '$key desc',
);
expect(
SortOption<ChannelState>.asc(key).nullOrdering,
NullOrdering.nullsLast,
reason: '$key asc',
);
}
},
);

test('should let an explicit nullOrdering override the default', () {
const option = SortOption<ChannelState>.desc(
ChannelSortKey.pinnedAt,
nullOrdering: NullOrdering.nullsFirst,
);

expect(option.nullOrdering, NullOrdering.nullsFirst);
});

test('should resolve field defaults when deserialized from json', () {
final pinnedAt = SortOption<ChannelState>.fromJson(
{'field': 'pinned_at', 'direction': -1},
);
final lastMessageAt = SortOption<ChannelState>.fromJson(
{'field': 'last_message_at', 'direction': -1},
);
final lastUpdated = SortOption<ChannelState>.fromJson(
{'field': 'last_updated', 'direction': -1},
);

expect(pinnedAt.nullOrdering, NullOrdering.nullsLast);
expect(lastMessageAt.nullOrdering, NullOrdering.nullsLast);
expect(lastUpdated.nullOrdering, NullOrdering.nullsFirst);
});

test('should resolve field defaults on the deprecated constructor', () {
// ignore: deprecated_member_use_from_same_package
const pinnedAt = SortOption<ChannelState>(ChannelSortKey.pinnedAt);
// ignore: deprecated_member_use_from_same_package
const lastUpdated = SortOption<ChannelState>(ChannelSortKey.lastUpdated);
const ascending = SortOption<ChannelState>(
// ignore: deprecated_member_use_from_same_package
ChannelSortKey.lastUpdated,
direction: SortOption.ASC,
);

expect(pinnedAt.nullOrdering, NullOrdering.nullsLast);
expect(lastUpdated.nullOrdering, NullOrdering.nullsFirst);
expect(ascending.nullOrdering, NullOrdering.nullsLast);
});
});

group('Channel sort server parity', () {
final createdAt = DateTime.utc(2026, 1, 1);

ChannelState channelState(
String id, {
DateTime? pinnedAt,
DateTime? lastMessageAt,
}) {
return ChannelState(
channel: ChannelModel(
id: id,
type: 'messaging',
createdAt: createdAt,
lastMessageAt: lastMessageAt,
),
membership: Member(userId: 'me', pinnedAt: pinnedAt),
);
}

List<String> idsOf(List<ChannelState> states) =>
states.map((it) => it.channel!.id).toList();

test('should keep pinned channels on top when sorting by pinnedAt desc',
() {
final channels = [
channelState(
'unpinned-recent',
lastMessageAt: createdAt.add(const Duration(days: 5)),
),
channelState(
'pinned-old',
pinnedAt: createdAt.add(const Duration(days: 1)),
),
channelState(
'unpinned-older',
lastMessageAt: createdAt.add(const Duration(days: 4)),
),
channelState(
'pinned-new',
pinnedAt: createdAt.add(const Duration(days: 2)),
),
];

const sort = [
SortOption<ChannelState>.desc(ChannelSortKey.pinnedAt),
SortOption<ChannelState>.desc(ChannelSortKey.lastUpdated),
];

expect(idsOf(channels.sorted(sort.compare)), [
'pinned-new',
'pinned-old',
'unpinned-recent',
'unpinned-older',
]);
});

test('should keep pinned channels on top when sorting by pinnedAt asc', () {
final channels = [
channelState(
'unpinned',
lastMessageAt: createdAt.add(const Duration(days: 5)),
),
channelState(
'pinned-new',
pinnedAt: createdAt.add(const Duration(days: 2)),
),
channelState(
'pinned-old',
pinnedAt: createdAt.add(const Duration(days: 1)),
),
];

const sort = [SortOption<ChannelState>.asc(ChannelSortKey.pinnedAt)];

expect(
idsOf(channels.sorted(sort.compare)),
['pinned-old', 'pinned-new', 'unpinned'],
);
});

test(
'should keep channels without messages at the bottom when sorting by '
'lastMessageAt desc',
() {
final channels = [
channelState('no-messages'),
channelState(
'newest',
lastMessageAt: createdAt.add(const Duration(days: 5)),
),
channelState(
'oldest',
lastMessageAt: createdAt.add(const Duration(days: 1)),
),
];

const sort = [
SortOption<ChannelState>.desc(ChannelSortKey.lastMessageAt),
];

expect(
idsOf(channels.sorted(sort.compare)),
['newest', 'oldest', 'no-messages'],
);
},
);

test('should keep nulls first for other fields when sorting desc', () {
final channels = [
ChannelState(
channel: ChannelModel(
id: 'red-team',
type: 'messaging',
createdAt: createdAt,
extraData: const {'team': 'red'},
),
),
ChannelState(
channel: ChannelModel(
id: 'no-team',
type: 'messaging',
createdAt: createdAt,
),
),
];

const sort = [SortOption<ChannelState>.desc('team')];

expect(idsOf(channels.sorted(sort.compare)), ['no-team', 'red-team']);
});
});

group('SortOption single field', () {
Expand Down
Loading