diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index ffbb23205b..d4aec56737 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -2,6 +2,7 @@ ✅ Added +- Added support for Channel pinning and archiving. - Added support for 'DraftMessage' feature, which allows users to save draft messages in channels. Several methods have been added to the `Client` and `Channel` class to manage draft messages: - `channel.createDraft`: Saves a draft message for a specific channel. diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index f87c7e6e81..23ed70c481 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -236,6 +236,32 @@ class Channel { return state!.channelStateStream.map((cs) => cs.channel?.hidden == true); } + /// Channel pinned status. + /// Status is specific to the current user. + bool get isPinned { + _checkInitialized(); + return membership?.pinnedAt != null; + } + + /// Channel pinned status as a stream. + /// Status is specific to the current user. + Stream get isPinnedStream { + return membershipStream.map((m) => m?.pinnedAt != null); + } + + /// Channel archived status. + /// Status is specific to the current user. + bool get isArchived { + _checkInitialized(); + return membership?.archivedAt != null; + } + + /// Channel archived status as a stream. + /// Status is specific to the current user. + Stream get isArchivedStream { + return membershipStream.map((m) => m?.archivedAt != null); + } + /// The last date at which the channel got truncated. DateTime? get truncatedAt { _checkInitialized(); @@ -1935,6 +1961,54 @@ class Channel { return _client.showChannel(id!, type); } + /// Pins the channel for the current user. + Future pin() async { + _checkInitialized(); + + final response = await _client.pinChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + + /// Unpins the channel. + Future unpin() async { + _checkInitialized(); + + final response = await _client.unpinChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + + /// Archives the channel. + Future archive() async { + _checkInitialized(); + + final response = await _client.archiveChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + + /// Unarchives the channel for the current user. + Future unarchive() async { + _checkInitialized(); + + final response = await _client.unarchiveChannel( + channelId: id!, + channelType: type, + ); + + return response.channelMember; + } + /// Stream of [Event] coming from websocket connection specific for the /// channel. Pass an eventType as parameter in order to filter just a type /// of event. diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index e2f545e6b9..9b0f53f909 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1877,6 +1877,83 @@ class StreamChatClient { unset: unset, ); + /// Pins the channel for the current user. + Future pinChannel({ + required String channelId, + required String channelType, + }) { + return partialMemberUpdate( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(pinned: true).toJson(), + ); + } + + /// Unpins the channel for the current user. + Future unpinChannel({ + required String channelId, + required String channelType, + }) { + return partialMemberUpdate( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.pinned.name], + ); + } + + /// Archives the channel for the current user. + Future archiveChannel({ + required String channelId, + required String channelType, + }) { + final currentUser = state.currentUser; + if (currentUser == null) { + throw const StreamChatError( + 'User is not set on client, ' + 'use `connectUser` or `connectAnonymousUser` instead', + ); + } + + return partialMemberUpdate( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(archived: true).toJson(), + ); + } + + /// Unarchives the channel for the current user. + Future unarchiveChannel({ + required String channelId, + required String channelType, + }) { + return partialMemberUpdate( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.archived.name], + ); + } + + /// Partially updates the member of the given channel. + /// + /// Use [set] to define values to be set. + /// Use [unset] to define values to be unset. + /// When [userId] is not provided, the current user will be used. + Future partialMemberUpdate({ + required String channelId, + required String channelType, + Map? set, + List? unset, + }) { + assert(set != null || unset != null, 'Set or unset must be provided.'); + + return _chatApi.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: set, + unset: unset, + ); + } + /// Closes the [_ws] connection and resets the [state] /// If [flushChatPersistence] is true the client deletes all offline /// user's data. 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 b4d9df213a..f87b99d539 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -375,4 +375,24 @@ class ChannelApi { ); return EmptyResponse.fromJson(response.data); } + + /// Updates some of the member data + Future updateMemberPartial({ + required String channelId, + required String channelType, + Map? set, + List? unset, + }) async { + final response = await _client.patch( + // Note: user_id is not required for client side Apis as it can be fetched + // directly from the user token but, for the api path is built with it + // so we need to pass it as a placeholder. + '${_getChannelUrl(channelId, channelType)}/member/{user_id}', + data: { + if (set != null) 'set': set, + if (unset != null) 'unset': unset, + }, + ); + return PartialUpdateMemberResponse.fromJson(response.data); + } } diff --git a/packages/stream_chat/lib/src/core/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart index 2e0bc5e6e7..141df822ec 100644 --- a/packages/stream_chat/lib/src/core/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -205,3 +205,31 @@ class ThreadOptions extends Equatable { @override List get props => [watch, replyLimit, participantLimit, memberLimit]; } + +/// Payload for updating a member. +@JsonSerializable(createFactory: false, includeIfNull: false) +class MemberUpdatePayload { + /// Creates a new MemberUpdatePayload instance. + const MemberUpdatePayload({ + this.archived, + this.pinned, + }); + + /// Set to true to archive the channel for a user. + final bool? archived; + + /// Set to true to pin the channel for a user. + final bool? pinned; + + /// Serialize model to json + Map toJson() => _$MemberUpdatePayloadToJson(this); +} + +/// Type of member update to unset. +enum MemberUpdateType { + /// Unset the archived flag. + archived, + + /// Unset the pinned flag. + pinned, +} diff --git a/packages/stream_chat/lib/src/core/api/requests.g.dart b/packages/stream_chat/lib/src/core/api/requests.g.dart index d0200535df..7a77503534 100644 --- a/packages/stream_chat/lib/src/core/api/requests.g.dart +++ b/packages/stream_chat/lib/src/core/api/requests.g.dart @@ -76,3 +76,10 @@ Map _$ThreadOptionsToJson(ThreadOptions instance) => 'member_limit': instance.memberLimit, 'props': instance.props, }; + +Map _$MemberUpdatePayloadToJson( + MemberUpdatePayload instance) => + { + if (instance.archived case final value?) 'archived': value, + if (instance.pinned case final value?) 'pinned': value, + }; diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index e91648b230..96627a45bd 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -100,6 +100,18 @@ class QueryMembersResponse extends _BaseResponse { _$QueryMembersResponseFromJson(json); } +/// Model response for update member API calls, such as +/// [StreamChatClient.updateMemberPartial] +@JsonSerializable(createToJson: false) +class PartialUpdateMemberResponse extends _BaseResponse { + /// The updated member state + late Member channelMember; + + /// Create a new instance from a json + static PartialUpdateMemberResponse fromJson(Map json) => + _$PartialUpdateMemberResponseFromJson(json); +} + /// Model response for [StreamChatClient.queryUsers] api call @JsonSerializable(createToJson: false) class QueryUsersResponse extends _BaseResponse { diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index 2eb151b4de..ae42060264 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -54,6 +54,13 @@ QueryMembersResponse _$QueryMembersResponseFromJson( .toList() ?? []; +PartialUpdateMemberResponse _$PartialUpdateMemberResponseFromJson( + Map json) => + PartialUpdateMemberResponse() + ..duration = json['duration'] as String? + ..channelMember = + Member.fromJson(json['channel_member'] as Map); + QueryUsersResponse _$QueryUsersResponseFromJson(Map json) => QueryUsersResponse() ..duration = json['duration'] as String? diff --git a/packages/stream_chat/lib/src/core/models/channel_state.dart b/packages/stream_chat/lib/src/core/models/channel_state.dart index 588bf2e3d9..ae7b9062b6 100644 --- a/packages/stream_chat/lib/src/core/models/channel_state.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.dart @@ -97,6 +97,7 @@ class ChannelState implements ComparableFieldProvider { ChannelSortKey.updatedAt => channel?.updatedAt, ChannelSortKey.lastMessageAt => channel?.lastMessageAt, ChannelSortKey.memberCount => channel?.memberCount, + ChannelSortKey.pinnedAt => membership?.pinnedAt, // TODO: Support providing default value for hasUnread, unreadCount ChannelSortKey.hasUnread => null, ChannelSortKey.unreadCount => null, @@ -134,4 +135,7 @@ extension type const ChannelSortKey(String key) implements String { /// Sort channels by the count of unread messages. static const unreadCount = ChannelSortKey('unread_count'); + + /// Sort channels by the date they were pinned. + static const pinnedAt = ChannelSortKey('pinned_at'); } diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 2834611831..66b51a287a 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -24,6 +24,8 @@ class Member extends Equatable implements ComparableFieldProvider { this.banned = false, this.banExpires, this.shadowBanned = false, + this.pinnedAt, + this.archivedAt, this.extraData = const {}, }) : userId = userId ?? user?.id, createdAt = createdAt ?? DateTime.now(), @@ -50,6 +52,8 @@ class Member extends Equatable implements ComparableFieldProvider { 'shadow_banned', 'created_at', 'updated_at', + 'pinned_at', + 'archived_at' ]; /// The interested user @@ -82,6 +86,12 @@ class Member extends Equatable implements ComparableFieldProvider { /// True if the member is shadow banned from the channel final bool shadowBanned; + /// The date at which the channel was pinned by the member + final DateTime? pinnedAt; + + /// The date at which the channel was archived by the member + final DateTime? archivedAt; + /// The date of creation final DateTime createdAt; @@ -103,6 +113,8 @@ class Member extends Equatable implements ComparableFieldProvider { bool? isModerator, DateTime? createdAt, DateTime? updatedAt, + DateTime? pinnedAt, + DateTime? archivedAt, bool? banned, DateTime? banExpires, bool? shadowBanned, @@ -119,6 +131,8 @@ class Member extends Equatable implements ComparableFieldProvider { channelRole: channelRole ?? this.channelRole, userId: userId ?? this.userId, isModerator: isModerator ?? this.isModerator, + pinnedAt: pinnedAt ?? this.pinnedAt, + archivedAt: archivedAt ?? this.archivedAt, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, extraData: extraData ?? this.extraData, @@ -141,6 +155,8 @@ class Member extends Equatable implements ComparableFieldProvider { banned, banExpires, shadowBanned, + pinnedAt, + archivedAt, createdAt, updatedAt, extraData, diff --git a/packages/stream_chat/lib/src/core/models/member.g.dart b/packages/stream_chat/lib/src/core/models/member.g.dart index 93f8b4bca3..0cd677e72a 100644 --- a/packages/stream_chat/lib/src/core/models/member.g.dart +++ b/packages/stream_chat/lib/src/core/models/member.g.dart @@ -31,6 +31,12 @@ Member _$MemberFromJson(Map json) => Member( ? null : DateTime.parse(json['ban_expires'] as String), shadowBanned: json['shadow_banned'] as bool? ?? false, + pinnedAt: json['pinned_at'] == null + ? null + : DateTime.parse(json['pinned_at'] as String), + archivedAt: json['archived_at'] == null + ? null + : DateTime.parse(json['archived_at'] as String), extraData: json['extra_data'] as Map? ?? const {}, ); @@ -45,6 +51,8 @@ Map _$MemberToJson(Member instance) => { 'banned': instance.banned, 'ban_expires': instance.banExpires?.toIso8601String(), 'shadow_banned': instance.shadowBanned, + 'pinned_at': instance.pinnedAt?.toIso8601String(), + 'archived_at': instance.archivedAt?.toIso8601String(), 'created_at': instance.createdAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(), 'extra_data': instance.extraData, diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index 85e25989d0..5693c3fb3c 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:stream_chat/src/core/api/requests.dart'; import 'package:stream_chat/src/core/api/sort_order.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; @@ -90,8 +91,15 @@ abstract class ChatPersistenceClient { getMessagesByCid(cid, messagePagination: messagePagination), getPinnedMessagesByCid(cid, messagePagination: pinnedMessagePagination), ]); + + final members = data[0] as List?; + final membership = userId == null + ? null + : members?.firstWhereOrNull((it) => it.userId == userId); + return ChannelState( - members: data[0] as List?, + members: members, + membership: membership, read: data[1] as List?, channel: data[2] as ChannelModel?, messages: data[3] as List?, diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 49803e6122..8a8ba7942d 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -2858,6 +2858,63 @@ void main() { verify(() => client.showChannel(channelId, channelType)).called(1); }); + // testing archiving + test('`.archive`', () async { + when(() => client.archiveChannel( + channelId: channelId, channelType: channelType)).thenAnswer( + (_) async => FakePartialUpdateMemberResponse(), + ); + + final res = await channel.archive(); + + expect(res, isNotNull); + + verify(() => client.archiveChannel( + channelId: channelId, channelType: channelType)).called(1); + }); + + test('`.unarchive`', () async { + when(() => client.unarchiveChannel( + channelId: channelId, channelType: channelType)).thenAnswer( + (_) async => FakePartialUpdateMemberResponse(), + ); + + final res = await channel.unarchive(); + + expect(res, isNotNull); + + verify(() => client.unarchiveChannel( + channelId: channelId, channelType: channelType)).called(1); + }); + + // testing pinning + test('`.pin`', () async { + when(() => + client.pinChannel(channelId: channelId, channelType: channelType)) + .thenAnswer((_) async => FakePartialUpdateMemberResponse()); + + final res = await channel.pin(); + + expect(res, isNotNull); + + verify(() => + client.pinChannel(channelId: channelId, channelType: channelType)) + .called(1); + }); + + test('`.unpin`', () async { + when(() => client.unpinChannel( + channelId: channelId, channelType: channelType)) + .thenAnswer((_) async => FakePartialUpdateMemberResponse()); + + final res = await channel.unpin(); + + expect(res, isNotNull); + + verify(() => client.unpinChannel( + channelId: channelId, channelType: channelType)).called(1); + }); + test('`.on`', () async { const eventType = 'test.event'; final event = Event(type: eventType, cid: channelCid); @@ -3247,6 +3304,7 @@ void main() { () async { final currentUser = client.state.currentUser; final currentMember = Member(user: currentUser); + final now = DateTime.now(); // Setup initial membership channel.state?.updateChannelState( @@ -3260,11 +3318,15 @@ void main() { expect(channel.membership, isNotNull); expect(channel.membership?.channelRole, isNull); expect(channel.membership?.isModerator, false); + expect(channel.isPinned, isFalse); + expect(channel.isArchived, isFalse); // Create updated member with same userId but updated properties final updatedMember = currentMember.copyWith( channelRole: 'moderator', isModerator: true, + pinnedAt: now, + archivedAt: now, ); // Create member updated event @@ -3286,6 +3348,8 @@ void main() { expect(channel.membership?.userId, equals(currentUser?.id)); expect(channel.membership?.channelRole, equals('moderator')); expect(channel.membership?.isModerator, isTrue); + expect(channel.isPinned, isTrue); + expect(channel.isArchived, isTrue); }, ); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index b02741ca34..6d0618dbdd 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -813,10 +813,11 @@ void main() { group('Client with connected user without persistence', () { const apiKey = 'test-api-key'; + const userId = 'test-user-id'; late final api = FakeChatApi(); late final ws = FakeWebSocket(); - final user = User(id: 'test-user-id'); + final user = User(id: userId); final token = Token.development(user.id).rawValue; late StreamChatClient client; @@ -1604,6 +1605,182 @@ void main() { verifyNoMoreInteractions(api.moderation); }); + test('`.partialMemberUpdate with userId`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const otherUserId = 'test-other-user-id'; + const set = {'pinned': true}; + const unset = ['pinned']; + + when(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: set, + unset: unset, + )).thenAnswer((_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: otherUserId), + )); + + final res = await client.partialMemberUpdate( + channelId: channelId, + channelType: channelType, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.channelMember.userId, otherUserId); + + verify(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: set, + unset: unset, + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.partialMemberUpdate with current user`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + const set = {'pinned': true}; + const unset = ['pinned']; + + when(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: set, + unset: unset, + )).thenAnswer((_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: userId), + )); + + final res = await client.partialMemberUpdate( + channelId: channelId, + channelType: channelType, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.channelMember.userId, userId); + verify(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: set, + unset: unset, + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.pinChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(pinned: true).toJson(), + )).thenAnswer((_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: userId, pinnedAt: DateTime.now()), + )); + + final res = await client.pinChannel( + channelId: channelId, + channelType: channelType, + ); + + expect(res, isNotNull); + + verify(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(pinned: true).toJson(), + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.unpinChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.pinned.name], + )).thenAnswer((_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: userId, pinnedAt: DateTime.now()), + )); + + final res = await client.unpinChannel( + channelId: channelId, + channelType: channelType, + ); + + expect(res, isNotNull); + + verify(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.pinned.name], + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.archiveChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(archived: true).toJson(), + )).thenAnswer((_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: userId, archivedAt: DateTime.now()), + )); + + final res = await client.archiveChannel( + channelId: channelId, + channelType: channelType, + ); + + expect(res, isNotNull); + + verify(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(archived: true).toJson(), + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.unarchiveChannel`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + + when(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.archived.name], + )).thenAnswer((_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: userId, pinnedAt: DateTime.now()), + )); + + final res = await client.unarchiveChannel( + channelId: channelId, + channelType: channelType, + ); + + expect(res, isNotNull); + + verify(() => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.archived.name], + )).called(1); + verifyNoMoreInteractions(api.channel); + }); + test('`.acceptChannelInvite`', () 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 abe9de82c9..385757da58 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 @@ -605,6 +605,130 @@ void main() { verifyNoMoreInteractions(client); }); + test('archiveChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; + const archivedAt = '2025-04-10 10:27:03.150349'; + + when(() => client.patch( + path, + data: { + 'set': {'archived': true}, + }, + )).thenAnswer( + (_) async => successResponse(path, data: { + 'channel_member': { + 'archived_at': archivedAt, + } + }), + ); + + final res = await channelApi.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(archived: true).toJson(), + ); + + expect(res, isNotNull); + expect(res.channelMember.archivedAt, DateTime.parse(archivedAt)); + + verify(() => client.patch(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unarchiveChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; + + when(() => client.patch( + path, + data: { + 'unset': ['archived'], + }, + )).thenAnswer( + (_) async => successResponse(path, + data: {'channel_member': {}}), + ); + + final res = await channelApi.updateMemberPartial( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.archived.name], + ); + + expect(res, isNotNull); + expect(res.channelMember.archivedAt, null); + + verify(() => client.patch(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('pinChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; + const pinnedAt = '2025-04-10 10:27:03.150349'; + + when(() => client.patch( + path, + data: { + 'set': {'pinned': true}, + }, + )).thenAnswer( + (_) async => successResponse(path, data: { + 'channel_member': { + 'pinned_at': pinnedAt, + } + }), + ); + + final res = await channelApi.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(pinned: true).toJson(), + ); + + expect(res, isNotNull); + expect(res.channelMember.pinnedAt, DateTime.parse(pinnedAt)); + + verify(() => client.patch(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('unpinChannel', () async { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + + final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; + + when(() => client.patch( + path, + data: { + 'unset': ['pinned'], + }, + )).thenAnswer( + (_) async => successResponse(path, + data: {'channel_member': {}}), + ); + + final res = await channelApi.updateMemberPartial( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.pinned.name], + ); + + expect(res, isNotNull); + expect(res.channelMember.pinnedAt, null); + + verify(() => client.patch(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + test('stopWatching', () async { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; diff --git a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart index 78fd790034..675773194a 100644 --- a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart +++ b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart @@ -19,7 +19,7 @@ class TestPersistenceClient extends ChatPersistenceClient { bool get isConnected => throw UnimplementedError(); @override - String? get userId => throw UnimplementedError(); + String? get userId => 'test-user-id'; @override Future connect(String userId) => throw UnimplementedError(); diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index 5b11548746..72fd987461 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -204,3 +204,14 @@ class FakeWebSocketWithConnectionError extends Fake implements WebSocket { } class FakeChannelState extends Fake implements ChannelState {} + +class FakePartialUpdateMemberResponse extends Fake + implements PartialUpdateMemberResponse { + FakePartialUpdateMemberResponse({ + Member? channelMember, + }) : _channelMember = channelMember ?? Member(); + + final Member _channelMember; + @override + Member get channelMember => _channelMember; +} diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index ba4ffcc23a..8d64f9180d 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -3,6 +3,7 @@ ✅ Added - Added `StreamChannelState.getFirstUnreadMessage` to get the first unread message in the channel. +- Channel pinning and archiving. ## 9.7.0 diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart index 90c13ad55e..6530852c6b 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart @@ -272,6 +272,8 @@ class StreamChannelListController extends PagedValueNotifier { } else if (eventType == 'user.presence.changed' || eventType == EventType.userUpdated) { _eventHandler.onUserPresenceChanged(event, this); + } else if (eventType == EventType.memberUpdated) { + _eventHandler.onMemberUpdated(event, this); } }); } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart index 7af33b58be..b59d16e67c 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart @@ -49,8 +49,19 @@ mixin class StreamChannelListEventHandler { /// This event is fired when a channel is updated. /// /// By default, this updates the channel received in the event. - // ignore: no-empty-block - void onChannelUpdated(Event event, StreamChannelListController controller) {} + void onChannelUpdated(Event event, StreamChannelListController controller) { + controller.channels = [...controller.currentItems]; + } + + /// Function which gets called for the event + /// [EventType.memberUpdated]. + /// + /// This event is fired when a member is updated. + /// + /// By default, this sorts the channels. + void onMemberUpdated(Event event, StreamChannelListController controller) { + controller.channels = [...controller.currentItems]; + } /// Function which gets called for the event /// [EventType.channelVisible]. diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 2943bfbbc3..81de7651f8 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,7 @@ +## Upcoming + +- Added `pinnedAt` and `archivedAt` fields on `Member`. + ## 9.7.0 - Updated `stream_chat` dependency to [`9.7.0`](https://pub.dev/packages/stream_chat/changelog). diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart index a1d47c4251..8720560baf 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart @@ -53,7 +53,7 @@ class DriftChatDatabase extends _$DriftChatDatabase { // you should bump this number whenever you change or add a table definition. @override - int get schemaVersion => 18; + int get schemaVersion => 19; @override MigrationStrategy get migration => MigrationStrategy( diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart index 01db0ad6e9..2b59bcf8aa 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart @@ -6110,6 +6110,22 @@ class $MembersTable extends Members defaultConstraints: GeneratedColumn.constraintIsAlways( 'CHECK ("shadow_banned" IN (0, 1))'), defaultValue: const Constant(false)); + static const VerificationMeta _pinnedAtMeta = + const VerificationMeta('pinnedAt'); + @override + late final GeneratedColumn pinnedAt = GeneratedColumn( + 'pinned_at', aliasedName, true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const Constant(null)); + static const VerificationMeta _archivedAtMeta = + const VerificationMeta('archivedAt'); + @override + late final GeneratedColumn archivedAt = GeneratedColumn( + 'archived_at', aliasedName, true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const Constant(null)); static const VerificationMeta _isModeratorMeta = const VerificationMeta('isModerator'); @override @@ -6154,6 +6170,8 @@ class $MembersTable extends Members invited, banned, shadowBanned, + pinnedAt, + archivedAt, isModerator, extraData, createdAt, @@ -6215,6 +6233,16 @@ class $MembersTable extends Members shadowBanned.isAcceptableOrUnknown( data['shadow_banned']!, _shadowBannedMeta)); } + if (data.containsKey('pinned_at')) { + context.handle(_pinnedAtMeta, + pinnedAt.isAcceptableOrUnknown(data['pinned_at']!, _pinnedAtMeta)); + } + if (data.containsKey('archived_at')) { + context.handle( + _archivedAtMeta, + archivedAt.isAcceptableOrUnknown( + data['archived_at']!, _archivedAtMeta)); + } if (data.containsKey('is_moderator')) { context.handle( _isModeratorMeta, @@ -6255,6 +6283,10 @@ class $MembersTable extends Members .read(DriftSqlType.bool, data['${effectivePrefix}banned'])!, shadowBanned: attachedDatabase.typeMapping .read(DriftSqlType.bool, data['${effectivePrefix}shadow_banned'])!, + pinnedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}pinned_at']), + archivedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}archived_at']), isModerator: attachedDatabase.typeMapping .read(DriftSqlType.bool, data['${effectivePrefix}is_moderator'])!, extraData: $MembersTable.$converterextraDatan.fromSql(attachedDatabase @@ -6303,6 +6335,12 @@ class MemberEntity extends DataClass implements Insertable { /// True if the member is shadow banned from the channel final bool shadowBanned; + /// The date at which the channel was pinned by the member + final DateTime? pinnedAt; + + /// The date at which the channel was archived by the member + final DateTime? archivedAt; + /// True if the user is a moderator of the channel final bool isModerator; @@ -6323,6 +6361,8 @@ class MemberEntity extends DataClass implements Insertable { required this.invited, required this.banned, required this.shadowBanned, + this.pinnedAt, + this.archivedAt, required this.isModerator, this.extraData, required this.createdAt, @@ -6344,6 +6384,12 @@ class MemberEntity extends DataClass implements Insertable { map['invited'] = Variable(invited); map['banned'] = Variable(banned); map['shadow_banned'] = Variable(shadowBanned); + if (!nullToAbsent || pinnedAt != null) { + map['pinned_at'] = Variable(pinnedAt); + } + if (!nullToAbsent || archivedAt != null) { + map['archived_at'] = Variable(archivedAt); + } map['is_moderator'] = Variable(isModerator); if (!nullToAbsent || extraData != null) { map['extra_data'] = @@ -6368,6 +6414,8 @@ class MemberEntity extends DataClass implements Insertable { invited: serializer.fromJson(json['invited']), banned: serializer.fromJson(json['banned']), shadowBanned: serializer.fromJson(json['shadowBanned']), + pinnedAt: serializer.fromJson(json['pinnedAt']), + archivedAt: serializer.fromJson(json['archivedAt']), isModerator: serializer.fromJson(json['isModerator']), extraData: serializer.fromJson?>(json['extraData']), createdAt: serializer.fromJson(json['createdAt']), @@ -6386,6 +6434,8 @@ class MemberEntity extends DataClass implements Insertable { 'invited': serializer.toJson(invited), 'banned': serializer.toJson(banned), 'shadowBanned': serializer.toJson(shadowBanned), + 'pinnedAt': serializer.toJson(pinnedAt), + 'archivedAt': serializer.toJson(archivedAt), 'isModerator': serializer.toJson(isModerator), 'extraData': serializer.toJson?>(extraData), 'createdAt': serializer.toJson(createdAt), @@ -6402,6 +6452,8 @@ class MemberEntity extends DataClass implements Insertable { bool? invited, bool? banned, bool? shadowBanned, + Value pinnedAt = const Value.absent(), + Value archivedAt = const Value.absent(), bool? isModerator, Value?> extraData = const Value.absent(), DateTime? createdAt, @@ -6419,6 +6471,8 @@ class MemberEntity extends DataClass implements Insertable { invited: invited ?? this.invited, banned: banned ?? this.banned, shadowBanned: shadowBanned ?? this.shadowBanned, + pinnedAt: pinnedAt.present ? pinnedAt.value : this.pinnedAt, + archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, isModerator: isModerator ?? this.isModerator, extraData: extraData.present ? extraData.value : this.extraData, createdAt: createdAt ?? this.createdAt, @@ -6442,6 +6496,9 @@ class MemberEntity extends DataClass implements Insertable { shadowBanned: data.shadowBanned.present ? data.shadowBanned.value : this.shadowBanned, + pinnedAt: data.pinnedAt.present ? data.pinnedAt.value : this.pinnedAt, + archivedAt: + data.archivedAt.present ? data.archivedAt.value : this.archivedAt, isModerator: data.isModerator.present ? data.isModerator.value : this.isModerator, extraData: data.extraData.present ? data.extraData.value : this.extraData, @@ -6461,6 +6518,8 @@ class MemberEntity extends DataClass implements Insertable { ..write('invited: $invited, ') ..write('banned: $banned, ') ..write('shadowBanned: $shadowBanned, ') + ..write('pinnedAt: $pinnedAt, ') + ..write('archivedAt: $archivedAt, ') ..write('isModerator: $isModerator, ') ..write('extraData: $extraData, ') ..write('createdAt: $createdAt, ') @@ -6479,6 +6538,8 @@ class MemberEntity extends DataClass implements Insertable { invited, banned, shadowBanned, + pinnedAt, + archivedAt, isModerator, extraData, createdAt, @@ -6495,6 +6556,8 @@ class MemberEntity extends DataClass implements Insertable { other.invited == this.invited && other.banned == this.banned && other.shadowBanned == this.shadowBanned && + other.pinnedAt == this.pinnedAt && + other.archivedAt == this.archivedAt && other.isModerator == this.isModerator && other.extraData == this.extraData && other.createdAt == this.createdAt && @@ -6510,6 +6573,8 @@ class MembersCompanion extends UpdateCompanion { final Value invited; final Value banned; final Value shadowBanned; + final Value pinnedAt; + final Value archivedAt; final Value isModerator; final Value?> extraData; final Value createdAt; @@ -6524,6 +6589,8 @@ class MembersCompanion extends UpdateCompanion { this.invited = const Value.absent(), this.banned = const Value.absent(), this.shadowBanned = const Value.absent(), + this.pinnedAt = const Value.absent(), + this.archivedAt = const Value.absent(), this.isModerator = const Value.absent(), this.extraData = const Value.absent(), this.createdAt = const Value.absent(), @@ -6539,6 +6606,8 @@ class MembersCompanion extends UpdateCompanion { this.invited = const Value.absent(), this.banned = const Value.absent(), this.shadowBanned = const Value.absent(), + this.pinnedAt = const Value.absent(), + this.archivedAt = const Value.absent(), this.isModerator = const Value.absent(), this.extraData = const Value.absent(), this.createdAt = const Value.absent(), @@ -6555,6 +6624,8 @@ class MembersCompanion extends UpdateCompanion { Expression? invited, Expression? banned, Expression? shadowBanned, + Expression? pinnedAt, + Expression? archivedAt, Expression? isModerator, Expression? extraData, Expression? createdAt, @@ -6570,6 +6641,8 @@ class MembersCompanion extends UpdateCompanion { if (invited != null) 'invited': invited, if (banned != null) 'banned': banned, if (shadowBanned != null) 'shadow_banned': shadowBanned, + if (pinnedAt != null) 'pinned_at': pinnedAt, + if (archivedAt != null) 'archived_at': archivedAt, if (isModerator != null) 'is_moderator': isModerator, if (extraData != null) 'extra_data': extraData, if (createdAt != null) 'created_at': createdAt, @@ -6587,6 +6660,8 @@ class MembersCompanion extends UpdateCompanion { Value? invited, Value? banned, Value? shadowBanned, + Value? pinnedAt, + Value? archivedAt, Value? isModerator, Value?>? extraData, Value? createdAt, @@ -6601,6 +6676,8 @@ class MembersCompanion extends UpdateCompanion { invited: invited ?? this.invited, banned: banned ?? this.banned, shadowBanned: shadowBanned ?? this.shadowBanned, + pinnedAt: pinnedAt ?? this.pinnedAt, + archivedAt: archivedAt ?? this.archivedAt, isModerator: isModerator ?? this.isModerator, extraData: extraData ?? this.extraData, createdAt: createdAt ?? this.createdAt, @@ -6636,6 +6713,12 @@ class MembersCompanion extends UpdateCompanion { if (shadowBanned.present) { map['shadow_banned'] = Variable(shadowBanned.value); } + if (pinnedAt.present) { + map['pinned_at'] = Variable(pinnedAt.value); + } + if (archivedAt.present) { + map['archived_at'] = Variable(archivedAt.value); + } if (isModerator.present) { map['is_moderator'] = Variable(isModerator.value); } @@ -6666,6 +6749,8 @@ class MembersCompanion extends UpdateCompanion { ..write('invited: $invited, ') ..write('banned: $banned, ') ..write('shadowBanned: $shadowBanned, ') + ..write('pinnedAt: $pinnedAt, ') + ..write('archivedAt: $archivedAt, ') ..write('isModerator: $isModerator, ') ..write('extraData: $extraData, ') ..write('createdAt: $createdAt, ') @@ -7737,8 +7822,8 @@ final class $$ChannelsTableReferences db.channels.cid, db.messages.channelCid)); $$MessagesTableProcessedTableManager get messagesRefs { - final manager = $$MessagesTableTableManager($_db, $_db.messages) - .filter((f) => f.channelCid.cid($_item.cid)); + final manager = $$MessagesTableTableManager($_db, $_db.messages).filter( + (f) => f.channelCid.cid.sqlEquals($_itemColumn('cid')!)); final cache = $_typedResult.readTableOrNull(_messagesRefsTable($_db)); return ProcessedTableManager( @@ -7752,8 +7837,8 @@ final class $$ChannelsTableReferences $_aliasNameGenerator(db.channels.cid, db.members.channelCid)); $$MembersTableProcessedTableManager get membersRefs { - final manager = $$MembersTableTableManager($_db, $_db.members) - .filter((f) => f.channelCid.cid($_item.cid)); + final manager = $$MembersTableTableManager($_db, $_db.members).filter( + (f) => f.channelCid.cid.sqlEquals($_itemColumn('cid')!)); final cache = $_typedResult.readTableOrNull(_membersRefsTable($_db)); return ProcessedTableManager( @@ -7767,8 +7852,8 @@ final class $$ChannelsTableReferences $_aliasNameGenerator(db.channels.cid, db.reads.channelCid)); $$ReadsTableProcessedTableManager get readsRefs { - final manager = $$ReadsTableTableManager($_db, $_db.reads) - .filter((f) => f.channelCid.cid($_item.cid)); + final manager = $$ReadsTableTableManager($_db, $_db.reads).filter( + (f) => f.channelCid.cid.sqlEquals($_itemColumn('cid')!)); final cache = $_typedResult.readTableOrNull(_readsRefsTable($_db)); return ProcessedTableManager( @@ -8297,8 +8382,10 @@ final class $$MessagesTableReferences $_aliasNameGenerator(db.messages.channelCid, db.channels.cid)); $$ChannelsTableProcessedTableManager get channelCid { + final $_column = $_itemColumn('channel_cid')!; + final manager = $$ChannelsTableTableManager($_db, $_db.channels) - .filter((f) => f.cid($_item.channelCid!)); + .filter((f) => f.cid.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_channelCidTable($_db)); if (item == null) return manager; return ProcessedTableManager( @@ -8313,7 +8400,7 @@ final class $$MessagesTableReferences $$ReactionsTableProcessedTableManager get reactionsRefs { final manager = $$ReactionsTableTableManager($_db, $_db.reactions) - .filter((f) => f.messageId.id($_item.id)); + .filter((f) => f.messageId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull(_reactionsRefsTable($_db)); return ProcessedTableManager( @@ -9080,7 +9167,7 @@ final class $$PinnedMessagesTableReferences extends BaseReferences< get pinnedMessageReactionsRefs { final manager = $$PinnedMessageReactionsTableTableManager( $_db, $_db.pinnedMessageReactions) - .filter((f) => f.messageId.id($_item.id)); + .filter((f) => f.messageId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull(_pinnedMessageReactionsRefsTable($_db)); @@ -9747,7 +9834,7 @@ final class $$PollsTableReferences $$PollVotesTableProcessedTableManager get pollVotesRefs { final manager = $$PollVotesTableTableManager($_db, $_db.pollVotes) - .filter((f) => f.pollId.id($_item.id)); + .filter((f) => f.pollId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull(_pollVotesRefsTable($_db)); return ProcessedTableManager( @@ -10174,9 +10261,10 @@ final class $$PollVotesTableReferences extends BaseReferences< .createAlias($_aliasNameGenerator(db.pollVotes.pollId, db.polls.id)); $$PollsTableProcessedTableManager? get pollId { - if ($_item.pollId == null) return null; + final $_column = $_itemColumn('poll_id'); + if ($_column == null) return null; final manager = $$PollsTableTableManager($_db, $_db.polls) - .filter((f) => f.id($_item.pollId!)); + .filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_pollIdTable($_db)); if (item == null) return manager; return ProcessedTableManager( @@ -10479,8 +10567,10 @@ final class $$PinnedMessageReactionsTableReferences extends BaseReferences< db.pinnedMessageReactions.messageId, db.pinnedMessages.id)); $$PinnedMessagesTableProcessedTableManager get messageId { + final $_column = $_itemColumn('message_id')!; + final manager = $$PinnedMessagesTableTableManager($_db, $_db.pinnedMessages) - .filter((f) => f.id($_item.messageId!)); + .filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_messageIdTable($_db)); if (item == null) return manager; return ProcessedTableManager( @@ -10775,8 +10865,10 @@ final class $$ReactionsTableReferences extends BaseReferences< $_aliasNameGenerator(db.reactions.messageId, db.messages.id)); $$MessagesTableProcessedTableManager get messageId { + final $_column = $_itemColumn('message_id')!; + final manager = $$MessagesTableTableManager($_db, $_db.messages) - .filter((f) => f.id($_item.messageId!)); + .filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_messageIdTable($_db)); if (item == null) return manager; return ProcessedTableManager( @@ -11275,6 +11367,8 @@ typedef $$MembersTableCreateCompanionBuilder = MembersCompanion Function({ Value invited, Value banned, Value shadowBanned, + Value pinnedAt, + Value archivedAt, Value isModerator, Value?> extraData, Value createdAt, @@ -11290,6 +11384,8 @@ typedef $$MembersTableUpdateCompanionBuilder = MembersCompanion Function({ Value invited, Value banned, Value shadowBanned, + Value pinnedAt, + Value archivedAt, Value isModerator, Value?> extraData, Value createdAt, @@ -11306,8 +11402,10 @@ final class $$MembersTableReferences $_aliasNameGenerator(db.members.channelCid, db.channels.cid)); $$ChannelsTableProcessedTableManager get channelCid { + final $_column = $_itemColumn('channel_cid')!; + final manager = $$ChannelsTableTableManager($_db, $_db.channels) - .filter((f) => f.cid($_item.channelCid!)); + .filter((f) => f.cid.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_channelCidTable($_db)); if (item == null) return manager; return ProcessedTableManager( @@ -11347,6 +11445,12 @@ class $$MembersTableFilterComposer ColumnFilters get shadowBanned => $composableBuilder( column: $table.shadowBanned, builder: (column) => ColumnFilters(column)); + ColumnFilters get pinnedAt => $composableBuilder( + column: $table.pinnedAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get archivedAt => $composableBuilder( + column: $table.archivedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get isModerator => $composableBuilder( column: $table.isModerator, builder: (column) => ColumnFilters(column)); @@ -11416,6 +11520,12 @@ class $$MembersTableOrderingComposer column: $table.shadowBanned, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pinnedAt => $composableBuilder( + column: $table.pinnedAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get archivedAt => $composableBuilder( + column: $table.archivedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get isModerator => $composableBuilder( column: $table.isModerator, builder: (column) => ColumnOrderings(column)); @@ -11479,6 +11589,12 @@ class $$MembersTableAnnotationComposer GeneratedColumn get shadowBanned => $composableBuilder( column: $table.shadowBanned, builder: (column) => column); + GeneratedColumn get pinnedAt => + $composableBuilder(column: $table.pinnedAt, builder: (column) => column); + + GeneratedColumn get archivedAt => $composableBuilder( + column: $table.archivedAt, builder: (column) => column); + GeneratedColumn get isModerator => $composableBuilder( column: $table.isModerator, builder: (column) => column); @@ -11544,6 +11660,8 @@ class $$MembersTableTableManager extends RootTableManager< Value invited = const Value.absent(), Value banned = const Value.absent(), Value shadowBanned = const Value.absent(), + Value pinnedAt = const Value.absent(), + Value archivedAt = const Value.absent(), Value isModerator = const Value.absent(), Value?> extraData = const Value.absent(), Value createdAt = const Value.absent(), @@ -11559,6 +11677,8 @@ class $$MembersTableTableManager extends RootTableManager< invited: invited, banned: banned, shadowBanned: shadowBanned, + pinnedAt: pinnedAt, + archivedAt: archivedAt, isModerator: isModerator, extraData: extraData, createdAt: createdAt, @@ -11574,6 +11694,8 @@ class $$MembersTableTableManager extends RootTableManager< Value invited = const Value.absent(), Value banned = const Value.absent(), Value shadowBanned = const Value.absent(), + Value pinnedAt = const Value.absent(), + Value archivedAt = const Value.absent(), Value isModerator = const Value.absent(), Value?> extraData = const Value.absent(), Value createdAt = const Value.absent(), @@ -11589,6 +11711,8 @@ class $$MembersTableTableManager extends RootTableManager< invited: invited, banned: banned, shadowBanned: shadowBanned, + pinnedAt: pinnedAt, + archivedAt: archivedAt, isModerator: isModerator, extraData: extraData, createdAt: createdAt, @@ -11674,8 +11798,10 @@ final class $$ReadsTableReferences .createAlias($_aliasNameGenerator(db.reads.channelCid, db.channels.cid)); $$ChannelsTableProcessedTableManager get channelCid { + final $_column = $_itemColumn('channel_cid')!; + final manager = $$ChannelsTableTableManager($_db, $_db.channels) - .filter((f) => f.cid($_item.channelCid!)); + .filter((f) => f.cid.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_channelCidTable($_db)); if (item == null) return manager; return ProcessedTableManager( diff --git a/packages/stream_chat_persistence/lib/src/entity/members.dart b/packages/stream_chat_persistence/lib/src/entity/members.dart index 7f50c148bb..086effca7d 100644 --- a/packages/stream_chat_persistence/lib/src/entity/members.dart +++ b/packages/stream_chat_persistence/lib/src/entity/members.dart @@ -31,6 +31,14 @@ class Members extends Table { /// True if the member is shadow banned from the channel BoolColumn get shadowBanned => boolean().withDefault(const Constant(false))(); + /// The date at which the channel was pinned by the member + DateTimeColumn get pinnedAt => + dateTime().nullable().withDefault(const Constant(null))(); + + /// The date at which the channel was archived by the member + DateTimeColumn get archivedAt => + dateTime().nullable().withDefault(const Constant(null))(); + /// True if the user is a moderator of the channel BoolColumn get isModerator => boolean().withDefault(const Constant(false))(); diff --git a/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart index d1ab4e703f..6c15e54e5d 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart @@ -15,6 +15,8 @@ extension MemberEntityX on MemberEntity { inviteAcceptedAt: inviteAcceptedAt, invited: invited, inviteRejectedAt: inviteRejectedAt, + pinnedAt: pinnedAt, + archivedAt: archivedAt, isModerator: isModerator, extraData: extraData ?? {}, ); @@ -33,6 +35,8 @@ extension MemberX on Member { inviteRejectedAt: inviteRejectedAt, invited: invited, inviteAcceptedAt: inviteAcceptedAt, + pinnedAt: pinnedAt, + archivedAt: archivedAt, channelRole: channelRole, updatedAt: updatedAt, extraData: extraData, diff --git a/packages/stream_chat_persistence/test/mock_chat_database.dart b/packages/stream_chat_persistence/test/mock_chat_database.dart index 1bd48f5f9d..fa71ccd67b 100644 --- a/packages/stream_chat_persistence/test/mock_chat_database.dart +++ b/packages/stream_chat_persistence/test/mock_chat_database.dart @@ -3,6 +3,9 @@ import 'package:stream_chat_persistence/src/dao/dao.dart'; import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; class MockChatDatabase extends Mock implements DriftChatDatabase { + @override + String get userId => 'test-user-id'; + @override UserDao get userDao => _userDao ??= MockUserDao(); UserDao? _userDao; diff --git a/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart index 8193f784d7..936097fe8f 100644 --- a/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart @@ -27,6 +27,8 @@ void main() { banned: math.Random().nextBool(), shadowBanned: math.Random().nextBool(), createdAt: DateTime.now(), + pinnedAt: DateTime.now(), + archivedAt: DateTime.now(), isModerator: math.Random().nextBool(), invited: math.Random().nextBool(), inviteAcceptedAt: DateTime.now(), @@ -61,6 +63,8 @@ void main() { expect(fetchedMember.banned, member.banned); expect(fetchedMember.shadowBanned, member.shadowBanned); expect(fetchedMember.createdAt, isSameDateAs(member.createdAt)); + expect(fetchedMember.pinnedAt, isSameDateAs(member.pinnedAt)); + expect(fetchedMember.archivedAt, isSameDateAs(member.archivedAt)); expect(fetchedMember.isModerator, member.isModerator); expect(fetchedMember.invited, member.invited); expect(fetchedMember.channelRole, member.channelRole); @@ -89,6 +93,8 @@ void main() { expect(fetchedMember.banned, member.banned); expect(fetchedMember.shadowBanned, member.shadowBanned); expect(fetchedMember.createdAt, isSameDateAs(member.createdAt)); + expect(fetchedMember.pinnedAt, isSameDateAs(member.pinnedAt)); + expect(fetchedMember.archivedAt, isSameDateAs(member.archivedAt)); expect(fetchedMember.isModerator, member.isModerator); expect(fetchedMember.invited, member.invited); expect(fetchedMember.channelRole, member.channelRole); diff --git a/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart index 04e4c77bb9..0c2bea8fca 100644 --- a/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart @@ -21,6 +21,8 @@ void main() { invited: math.Random().nextBool(), banned: math.Random().nextBool(), shadowBanned: math.Random().nextBool(), + pinnedAt: DateTime.now(), + archivedAt: DateTime.now(), isModerator: math.Random().nextBool(), extraData: {'test_extra_data': 'testData'}, ); @@ -35,6 +37,8 @@ void main() { expect(member.invited, entity.invited); expect(member.banned, entity.banned); expect(member.shadowBanned, entity.shadowBanned); + expect(member.pinnedAt, isSameDateAs(entity.pinnedAt)); + expect(member.archivedAt, isSameDateAs(entity.archivedAt)); expect(member.isModerator, entity.isModerator); expect(member.extraData, entity.extraData); }); @@ -52,6 +56,8 @@ void main() { invited: math.Random().nextBool(), banned: math.Random().nextBool(), shadowBanned: math.Random().nextBool(), + pinnedAt: DateTime.now(), + archivedAt: DateTime.now(), isModerator: math.Random().nextBool(), extraData: const {'test_extra_data': 'testData'}, ); @@ -67,6 +73,8 @@ void main() { expect(entity.invited, member.invited); expect(entity.banned, member.banned); expect(entity.shadowBanned, member.shadowBanned); + expect(entity.pinnedAt, isSameDateAs(member.pinnedAt)); + expect(entity.archivedAt, isSameDateAs(member.archivedAt)); expect(entity.isModerator, member.isModerator); expect(entity.extraData, member.extraData); }); diff --git a/sample_app/lib/pages/group_info_screen.dart b/sample_app/lib/pages/group_info_screen.dart index 664e1fb667..a3ffef7d48 100644 --- a/sample_app/lib/pages/group_info_screen.dart +++ b/sample_app/lib/pages/group_info_screen.dart @@ -44,6 +44,10 @@ class _GroupInfoScreenState extends State { late ValueNotifier mutedBool = ValueNotifier(channel.isMuted); + late ValueNotifier isPinned = ValueNotifier(channel.isPinned); + + late ValueNotifier isArchived = ValueNotifier(channel.isArchived); + late final channel = StreamChannel.of(context).channel; late StreamUserListController _userListController; @@ -479,68 +483,35 @@ class _GroupInfoScreenState extends State { return Column( children: [ if (channel.ownCapabilities.contains(PermissionType.muteChannel)) - StreamBuilder( - stream: channel.isMutedStream, - builder: (context, snapshot) { - mutedBool.value = snapshot.data; - - return StreamOptionListTile( - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - separatorColor: - StreamChatTheme.of(context).colorTheme.disabled, - title: AppLocalizations.of(context).muteGroup, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StreamSvgIcon( - icon: StreamSvgIcons.mute, - size: 24, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - trailing: snapshot.data == null - ? const CircularProgressIndicator() - : ValueListenableBuilder( - valueListenable: mutedBool, - builder: (context, value, _) { - return CupertinoSwitch( - value: value!, - onChanged: (val) { - mutedBool.value = val; - - if (snapshot.data!) { - channel.unmute(); - } else { - channel.mute(); - } - }, - ); - }), - onTap: () {}, - ); - }), - StreamOptionListTile( - title: AppLocalizations.of(context).pinnedMessages, - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StreamSvgIcon( - icon: StreamSvgIcons.pin, - size: 24, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - trailing: StreamSvgIcon( - icon: StreamSvgIcons.right, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, + _GroupInfoToggle( + title: AppLocalizations.of(context).muteGroup, + icon: StreamSvgIcons.mute, + channelStream: channel.isMutedStream, + localNotifier: mutedBool, + onTurnOff: channel.unmute, + onTurnOn: channel.mute, ), + _GroupInfoToggle( + title: AppLocalizations.of(context).pinGroup, + icon: StreamSvgIcons.pin, + channelStream: channel.isPinnedStream, + localNotifier: isPinned, + onTurnOff: channel.unpin, + onTurnOn: channel.pin, + ), + _GroupInfoToggle( + title: AppLocalizations.of(context).archiveGroup, + icon: StreamSvgIcons.save, + channelStream: channel.isArchivedStream, + localNotifier: isArchived, + onTurnOff: channel.unarchive, + onTurnOn: channel.archive, + ), + _GroupInfoListTile( + title: AppLocalizations.of(context).pinnedMessages, + icon: StreamSvgIcons.pin, + iconSize: 24, + iconPadding: 16, onTap: () { final channel = StreamChannel.of(context).channel; @@ -555,26 +526,11 @@ class _GroupInfoScreenState extends State { ); }, ), - StreamOptionListTile( - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - separatorColor: StreamChatTheme.of(context).colorTheme.disabled, + _GroupInfoListTile( title: AppLocalizations.of(context).photosAndVideos, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: StreamSvgIcon( - icon: StreamSvgIcons.pictures, - size: 32, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - trailing: StreamSvgIcon( - icon: StreamSvgIcons.right, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), + icon: StreamSvgIcons.pictures, + iconSize: 32, + iconPadding: 12, onTap: () { final channel = StreamChannel.of(context).channel; @@ -591,26 +547,11 @@ class _GroupInfoScreenState extends State { ); }, ), - StreamOptionListTile( - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - separatorColor: StreamChatTheme.of(context).colorTheme.disabled, + _GroupInfoListTile( title: AppLocalizations.of(context).files, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: StreamSvgIcon( - icon: StreamSvgIcons.files, - size: 32, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - trailing: StreamSvgIcon( - icon: StreamSvgIcons.right, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), + icon: StreamSvgIcons.files, + iconSize: 32, + iconPadding: 12, onTap: () { final channel = StreamChannel.of(context).channel; @@ -1086,3 +1027,107 @@ class _GroupInfoScreenState extends State { } } } + +class _GroupInfoToggle extends StatelessWidget { + const _GroupInfoToggle({ + required this.title, + required this.icon, + required this.channelStream, + required this.localNotifier, + required this.onTurnOff, + required this.onTurnOn, + }); + + final String title; + final StreamSvgIconData icon; + final Stream channelStream; + final ValueNotifier localNotifier; + final VoidCallback onTurnOff; + final VoidCallback onTurnOn; + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: channelStream, + builder: (context, snapshot) { + localNotifier.value = snapshot.data; + + return StreamOptionListTile( + tileColor: StreamChatTheme.of(context).colorTheme.appBg, + separatorColor: StreamChatTheme.of(context).colorTheme.disabled, + title: title, + titleTextStyle: StreamChatTheme.of(context).textTheme.body, + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: StreamSvgIcon( + icon: icon, + size: 24, + color: StreamChatTheme.of(context) + .colorTheme + .textHighEmphasis + .withOpacity(0.5), + ), + ), + trailing: snapshot.data == null + ? const CircularProgressIndicator() + : ValueListenableBuilder( + valueListenable: localNotifier, + builder: (context, value, _) { + return CupertinoSwitch( + value: value!, + onChanged: (val) { + localNotifier.value = val; + if (snapshot.data!) { + onTurnOff(); + } else { + onTurnOn(); + } + }, + ); + }), + onTap: () {}, + ); + }); + } +} + +class _GroupInfoListTile extends StatelessWidget { + const _GroupInfoListTile({ + required this.title, + required this.icon, + required this.iconSize, + required this.iconPadding, + required this.onTap, + }); + + final String title; + final StreamSvgIconData icon; + final double iconSize; + final double iconPadding; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return StreamOptionListTile( + title: title, + tileColor: StreamChatTheme.of(context).colorTheme.appBg, + titleTextStyle: StreamChatTheme.of(context).textTheme.body, + leading: Padding( + padding: EdgeInsets.symmetric(horizontal: iconPadding), + child: StreamSvgIcon( + icon: icon, + size: iconSize, + color: StreamChatTheme.of(context) + .colorTheme + .textHighEmphasis + .withOpacity(0.5), + ), + ), + trailing: StreamSvgIcon( + icon: StreamSvgIcons.right, + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, + ), + onTap: onTap, + ); + } +} diff --git a/sample_app/lib/utils/localizations.dart b/sample_app/lib/utils/localizations.dart index 8a6bee4ed5..7ce1005291 100644 --- a/sample_app/lib/utils/localizations.dart +++ b/sample_app/lib/utils/localizations.dart @@ -9,6 +9,7 @@ class AppLocalizations { 'add_group_members': 'Add Group Members', 'advanced_options': 'Advanced Options', 'api_key_error': 'Please enter the Chat API Key', + 'archive_group': 'Archive group', 'attachment': 'attachment', 'attachments': 'attachments', 'cancel': 'Cancel', @@ -67,6 +68,7 @@ class AppLocalizations { 'operation_could_not_be_completed': "The operation couldn't be completed.", 'owner': 'Owner', + 'pin_group': 'Pin group', 'photos_and_videos': 'Photos & Videos', 'photos_or_videos_will_appear_here': 'Photos or videos sent in this chat will \nappear here', @@ -100,6 +102,7 @@ class AppLocalizations { 'add_group_members': 'Aggiungi un membro', 'advanced_options': 'Opzioni Avanzate', 'api_key_error': "Per favore inserisci l'API Key", + 'archive_group': 'Archivia gruppo', 'attachment': 'allegato', 'attachments': 'allegati', 'cancel': 'Annulla', @@ -162,6 +165,7 @@ class AppLocalizations { 'photos_or_videos_will_appear_here': 'Foto or video inviati in questa chat \ncompariranno qui', 'pinned_messages': 'Messaggi in evidenza', + 'pin_group': 'Gruppo di evidenziazione', 'pin_to_conversation': 'Metti in evidenza', 'reconnecting': 'Riconnessione...', 'remove': 'Rimuovi', @@ -207,6 +211,10 @@ class AppLocalizations { return _localizedValues[locale.languageCode]!['api_key_error']!; } + String get archiveGroup { + return _localizedValues[locale.languageCode]!['archive_group']!; + } + String get attachment { return _localizedValues[locale.languageCode]!['attachment']!; } @@ -438,6 +446,10 @@ class AppLocalizations { 'photos_or_videos_will_appear_here']!; } + String get pinGroup { + return _localizedValues[locale.languageCode]!['pin_group']!; + } + String get pinnedMessages { return _localizedValues[locale.languageCode]!['pinned_messages']!; } diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index 214c5fae15..3507a0d635 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -28,10 +28,8 @@ class _ChannelList extends State { limit: 5, searchQuery: '', sort: [ - const SortOption( - 'created_at', - direction: SortOption.ASC, - ), + const SortOption(ChannelSortKey.pinnedAt), + const SortOption(ChannelSortKey.createdAt, direction: SortOption.ASC), ], ); @@ -57,10 +55,11 @@ class _ChannelList extends State { late final _channelListController = StreamChannelListController( client: StreamChat.of(context).client, - filter: Filter.in_( - 'members', - [StreamChat.of(context).currentUser!.id], - ), + filter: Filter.in_('members', [StreamChat.of(context).currentUser!.id]), + channelStateSort: [ + const SortOption(ChannelSortKey.pinnedAt), + const SortOption(ChannelSortKey.lastMessageAt), + ], limit: 30, ); @@ -102,207 +101,223 @@ class _ChannelList extends State { ), ], body: _isSearchActive - ? StreamMessageSearchListView( - controller: _messageSearchListController, - emptyBuilder: (_) { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: Center( - child: Column( - children: [ - const Padding( - padding: EdgeInsets.all(24), - child: StreamSvgIcon( - icon: StreamSvgIcons.search, - size: 96, - color: Colors.grey, - ), - ), - Text( - AppLocalizations.of(context).noResults, - ), - ], - ), + ? _ChannelListSearch(_messageSearchListController) + : _ChannelListDefault(_channelListController), + ), + ), + ); + } +} + +class _ChannelListDefault extends StatelessWidget { + const _ChannelListDefault(this.channelListController); + + final StreamChannelListController channelListController; + + @override + Widget build(BuildContext context) { + return SlidableAutoCloseBehavior( + child: RefreshIndicator( + onRefresh: channelListController.refresh, + child: StreamChannelListView( + controller: channelListController, + itemBuilder: (context, channels, index, defaultWidget) { + final chatTheme = StreamChatTheme.of(context); + final channel = channels[index]; + final backgroundColor = chatTheme.colorTheme.inputBg; + final canDeleteChannel = channel.canDeleteChannel; + return Slidable( + groupTag: 'channels-actions', + endActionPane: ActionPane( + extentRatio: canDeleteChannel ? 0.40 : 0.20, + motion: const BehindMotion(), + children: [ + CustomSlidableAction( + backgroundColor: backgroundColor, + onPressed: (_) { + showChannelInfoModalBottomSheet( + context: context, + channel: channel, + onViewInfoTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + final isOneToOne = channel.memberCount == 2 && + channel.isDistinct; + return StreamChannel( + channel: channel, + child: isOneToOne + ? ChatInfoScreen( + messageTheme: + chatTheme.ownMessageTheme, + user: channel.state!.members + .where((m) => + m.userId != + channel.client.state + .currentUser!.id) + .first + .user, + ) + : GroupInfoScreen( + messageTheme: + chatTheme.ownMessageTheme, + ), + ); + }, ), + ); + }, + ); + }, + child: const Icon(Icons.more_horiz), + ), + if (canDeleteChannel) + CustomSlidableAction( + backgroundColor: backgroundColor, + child: StreamSvgIcon( + icon: StreamSvgIcons.delete, + color: chatTheme.colorTheme.accentError, + ), + onPressed: (_) async { + final res = await showConfirmationBottomSheet( + context, + title: 'Delete Conversation', + question: + 'Are you sure you want to delete this conversation?', + okText: 'Delete', + cancelText: 'Cancel', + icon: StreamSvgIcon( + icon: StreamSvgIcons.delete, + color: chatTheme.colorTheme.accentError, ), ); - }, - ); - }, - itemBuilder: ( - context, - messageResponses, - index, - defaultWidget, - ) { - return defaultWidget.copyWith( - onTap: () async { - final messageResponse = messageResponses[index]; - FocusScope.of(context).requestFocus(FocusNode()); - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - final message = messageResponse.message; - final channel = client.channel( - messageResponse.channel!.type, - id: messageResponse.channel!.id, - ); - if (channel.state == null) { - await channel.watch(); + if (res == true) { + await channelListController.deleteChannel(channel); } - router.pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: - Routes.CHANNEL_PAGE.queryParams(message), - ); - }, - ); - }, - ) - : SlidableAutoCloseBehavior( - child: RefreshIndicator( - onRefresh: _channelListController.refresh, - child: StreamChannelListView( - controller: _channelListController, - itemBuilder: (context, channels, index, defaultWidget) { - final chatTheme = StreamChatTheme.of(context); - final backgroundColor = chatTheme.colorTheme.inputBg; - final channel = channels[index]; - final canDeleteChannel = channel.canDeleteChannel; - return Slidable( - groupTag: 'channels-actions', - endActionPane: ActionPane( - extentRatio: canDeleteChannel ? 0.40 : 0.20, - motion: const BehindMotion(), - children: [ - CustomSlidableAction( - backgroundColor: backgroundColor, - onPressed: (_) { - showChannelInfoModalBottomSheet( - context: context, - channel: channel, - onViewInfoTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - final isOneToOne = - channel.memberCount == 2 && - channel.isDistinct; - return StreamChannel( - channel: channel, - child: isOneToOne - ? ChatInfoScreen( - messageTheme: chatTheme - .ownMessageTheme, - user: channel - .state!.members - .where((m) => - m.userId != - channel - .client - .state - .currentUser! - .id) - .first - .user, - ) - : GroupInfoScreen( - messageTheme: chatTheme - .ownMessageTheme, - ), - ); - }, - ), - ); - }, - ); - }, - child: const Icon(Icons.more_horiz), - ), - if (canDeleteChannel) - CustomSlidableAction( - backgroundColor: backgroundColor, - child: StreamSvgIcon( - icon: StreamSvgIcons.delete, - color: chatTheme.colorTheme.accentError, - ), - onPressed: (_) async { - final res = - await showConfirmationBottomSheet( - context, - title: 'Delete Conversation', - question: - 'Are you sure you want to delete this conversation?', - okText: 'Delete', - cancelText: 'Cancel', - icon: StreamSvgIcon( - icon: StreamSvgIcons.delete, - color: chatTheme.colorTheme.accentError, - ), - ); - if (res == true) { - await _channelListController - .deleteChannel(channel); - } - }, - ), - ], - ), - child: defaultWidget, - ); - }, - onChannelTap: (channel) { - GoRouter.of(context).pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - ); }, - emptyBuilder: (_) { - return Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - icon: StreamSvgIcons.message, - size: 148, - color: StreamChatTheme.of(context) - .colorTheme - .disabled, - ), - emptyTitle: TextButton( - onPressed: () { - GoRouter.of(context) - .pushNamed(Routes.NEW_CHAT.name); - }, - child: Text( - 'Start a chat', - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentPrimary, - ), - ), - ), - ), + ), + ], + ), + child: ColoredBox( + color: + channel.isPinned ? Colors.amberAccent : Colors.transparent, + child: defaultWidget, + ), + ); + }, + onChannelTap: (channel) { + GoRouter.of(context).pushNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + ); + }, + emptyBuilder: (_) { + return Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: StreamScrollViewEmptyWidget( + emptyIcon: StreamSvgIcon( + icon: StreamSvgIcons.message, + size: 148, + color: StreamChatTheme.of(context).colorTheme.disabled, + ), + emptyTitle: TextButton( + onPressed: () { + GoRouter.of(context).pushNamed(Routes.NEW_CHAT.name); + }, + child: Text( + 'Start a chat', + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentPrimary, ), - ); - }, ), ), ), + ), + ); + }, ), ), ); } } + +class _ChannelListSearch extends StatelessWidget { + const _ChannelListSearch(this.messageSearchListController); + + final StreamMessageSearchListController messageSearchListController; + + @override + Widget build(BuildContext context) { + return StreamMessageSearchListView( + controller: messageSearchListController, + emptyBuilder: (_) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: Column( + children: [ + const Padding( + padding: EdgeInsets.all(24), + child: StreamSvgIcon( + icon: StreamSvgIcons.search, + size: 96, + color: Colors.grey, + ), + ), + Text( + AppLocalizations.of(context).noResults, + ), + ], + ), + ), + ), + ); + }, + ); + }, + itemBuilder: ( + context, + messageResponses, + index, + defaultWidget, + ) { + final messageResponse = messageResponses[index]; + + return defaultWidget.copyWith( + onTap: () async { + FocusScope.of(context).requestFocus(FocusNode()); + final client = StreamChat.of(context).client; + final router = GoRouter.of(context); + final message = messageResponse.message; + final channel = client.channel( + messageResponse.channel!.type, + id: messageResponse.channel!.id, + ); + if (channel.state == null) { + await channel.watch(); + } + router.pushNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: Routes.CHANNEL_PAGE.queryParams(message), + ); + }, + ); + }, + ); + } +}