From e9ecd1cc351a8fcbd42f7cd8f0de263bdf473415 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 19 Feb 2021 13:33:40 +0530 Subject: [PATCH] [LLC] Add pin message feature Signed-off-by: Sahil Kumar --- packages/stream_chat/lib/src/api/channel.dart | 39 ++++++- packages/stream_chat/lib/src/client.dart | 34 +++++- .../lib/src/models/channel_state.dart | 6 + .../lib/src/models/channel_state.g.dart | 9 ++ .../stream_chat/lib/src/models/message.dart | 106 +++++++++++++----- .../stream_chat/lib/src/models/message.g.dart | 16 +++ .../test/src/api/channel_test.dart | 73 ++++++++++++ .../stream_chat/test/src/client_test.dart | 50 +++++++++ 8 files changed, 302 insertions(+), 31 deletions(-) diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index d269762e..00df74a7 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -330,7 +330,7 @@ class Channel { state?.addMessage(message); try { - if (message.attachments?.isNotEmpty == true) { + if (message.attachments?.any((it) => !it.uploadState.isSuccess) == true) { final attachmentsUploadCompleter = Completer(); _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; @@ -383,7 +383,7 @@ class Channel { state?.addMessage(message); try { - if (message.attachments?.isNotEmpty == true) { + if (message.attachments?.any((it) => !it.uploadState.isSuccess) == true) { final attachmentsUploadCompleter = Completer(); _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; @@ -449,6 +449,41 @@ class Channel { } } + /// Pins provided message + Future pinMessage( + Message message, + Object timeoutOrExpirationDate, + ) { + assert(() { + if (timeoutOrExpirationDate is! DateTime && + timeoutOrExpirationDate is! num && + timeoutOrExpirationDate != null) { + throw ArgumentError('Invalid timeout or Expiration date'); + } + return true; + }()); + + DateTime pinExpires; + if (timeoutOrExpirationDate is DateTime) { + pinExpires = timeoutOrExpirationDate; + } else if (timeoutOrExpirationDate is num) { + pinExpires = DateTime.now().add( + Duration(seconds: timeoutOrExpirationDate.toInt()), + ); + } + return updateMessage( + message.copyWith( + pinned: true, + pinExpires: pinExpires, + ), + ); + } + + /// Unpins provided message + Future unpinMessage(Message message) { + return updateMessage(message.copyWith(pinned: false)); + } + /// Send a file to this channel Future sendFile( AttachmentFile file, { diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index 2cc3fd62..20f3f0bd 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -1295,7 +1295,7 @@ class StreamChatClient { Future updateMessage(Message message) async { final response = await post( '/messages/${message.id}', - data: {'message': message}, + data: {'message': message.toJson()}, ); return decode(response.data, UpdateMessageResponse.fromJson); } @@ -1311,6 +1311,38 @@ class StreamChatClient { final response = await get('/messages/$messageId'); return decode(response.data, GetMessageResponse.fromJson); } + + /// Pins provided message + Future pinMessage( + Message message, + Object timeoutOrExpirationDate, + ) { + assert(() { + if (timeoutOrExpirationDate is! DateTime && + timeoutOrExpirationDate is! num && + timeoutOrExpirationDate != null) { + throw ArgumentError('Invalid timeout or Expiration date'); + } + return true; + }()); + + DateTime pinExpires; + if (timeoutOrExpirationDate is DateTime) { + pinExpires = timeoutOrExpirationDate.toUtc(); + } else if (timeoutOrExpirationDate is num) { + pinExpires = DateTime.now().add( + Duration(seconds: timeoutOrExpirationDate.toInt()), + ); + } + return updateMessage( + message.copyWith(pinned: true, pinExpires: pinExpires), + ); + } + + /// Unpins provided message + Future unpinMessage(Message message) { + return updateMessage(message.copyWith(pinned: false)); + } } /// The class that handles the state of the channel listening to the events diff --git a/packages/stream_chat/lib/src/models/channel_state.dart b/packages/stream_chat/lib/src/models/channel_state.dart index 59e7a4f1..36d68a0c 100644 --- a/packages/stream_chat/lib/src/models/channel_state.dart +++ b/packages/stream_chat/lib/src/models/channel_state.dart @@ -20,6 +20,9 @@ class ChannelState { /// A paginated list of channel members final List members; + /// A paginated list of pinned messages + final List pinnedMessages; + /// The count of users watching the channel final int watcherCount; @@ -34,6 +37,7 @@ class ChannelState { this.channel, this.messages = const [], this.members = const [], + this.pinnedMessages = const [], this.watcherCount, this.watchers = const [], this.read = const [], @@ -51,6 +55,7 @@ class ChannelState { ChannelModel channel, List messages, List members, + List pinnedMessages, int watcherCount, List watchers, List read, @@ -59,6 +64,7 @@ class ChannelState { channel: channel ?? this.channel, messages: messages ?? this.messages, members: members ?? this.members, + pinnedMessages: pinnedMessages ?? this.pinnedMessages, watcherCount: watcherCount ?? this.watcherCount, watchers: watchers ?? this.watchers, read: read ?? this.read, diff --git a/packages/stream_chat/lib/src/models/channel_state.g.dart b/packages/stream_chat/lib/src/models/channel_state.g.dart index d053768e..a66899a4 100644 --- a/packages/stream_chat/lib/src/models/channel_state.g.dart +++ b/packages/stream_chat/lib/src/models/channel_state.g.dart @@ -27,6 +27,13 @@ ChannelState _$ChannelStateFromJson(Map json) { (k, e) => MapEntry(k as String, e), ))) ?.toList(), + pinnedMessages: (json['pinned_messages'] as List) + ?.map((e) => e == null + ? null + : Message.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), watcherCount: json['watcher_count'] as int, watchers: (json['watchers'] as List) ?.map((e) => e == null @@ -50,6 +57,8 @@ Map _$ChannelStateToJson(ChannelState instance) => 'channel': instance.channel?.toJson(), 'messages': instance.messages?.map((e) => e?.toJson())?.toList(), 'members': instance.members?.map((e) => e?.toJson())?.toList(), + 'pinned_messages': + instance.pinnedMessages?.map((e) => e?.toJson())?.toList(), 'watcher_count': instance.watcherCount, 'watchers': instance.watchers?.map((e) => e?.toJson())?.toList(), 'read': instance.read?.map((e) => e?.toJson())?.toList(), diff --git a/packages/stream_chat/lib/src/models/message.dart b/packages/stream_chat/lib/src/models/message.dart index 3127bba3..8a65b978 100644 --- a/packages/stream_chat/lib/src/models/message.dart +++ b/packages/stream_chat/lib/src/models/message.dart @@ -8,6 +8,12 @@ import 'user.dart'; part 'message.g.dart'; +class _PinExpires { + const _PinExpires(); +} + +const _pinExpires = _PinExpires(); + /// Enum defining the status of a sending message enum MessageSendingStatus { /// Message is being sent @@ -117,6 +123,20 @@ class Message { @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) final User user; + /// + final bool pinned; + + /// Reserved field indicating when the message was created. + @JsonKey(toJson: Serialization.readOnly) + final DateTime pinnedAt; + + /// Reserved field indicating when the message was created. + final DateTime pinExpires; + + /// + @JsonKey(toJson: Serialization.readOnly) + final User pinnedBy; + /// Message custom extraData @JsonKey(includeIfNull: false) final Map extraData; @@ -160,6 +180,10 @@ class Message { 'updated_at', 'deleted_at', 'user', + 'pinned', + 'pinned_at', + 'pin_expires', + 'pinned_by', ]; /// Constructor used for json serialization @@ -185,10 +209,15 @@ class Message { this.createdAt, this.updatedAt, this.user, + this.pinned = false, + this.pinnedAt, + DateTime pinExpires, + this.pinnedBy, this.extraData, this.deletedAt, this.status = MessageSendingStatus.sent, - }) : id = id ?? Uuid().v4(); + }) : id = id ?? Uuid().v4(), + pinExpires = pinExpires?.toUtc(); /// Create a new instance from a json factory Message.fromJson(Map json) => _$MessageFromJson( @@ -222,35 +251,52 @@ class Message { DateTime updatedAt, DateTime deletedAt, User user, + bool pinned, + DateTime pinnedAt, + Object pinExpires = _pinExpires, + User pinnedBy, Map extraData, MessageSendingStatus status, - }) => - Message( - id: id ?? this.id, - text: text ?? this.text, - type: type ?? this.type, - attachments: attachments ?? this.attachments, - mentionedUsers: mentionedUsers ?? this.mentionedUsers, - reactionCounts: reactionCounts ?? this.reactionCounts, - reactionScores: reactionScores ?? this.reactionScores, - latestReactions: latestReactions ?? this.latestReactions, - ownReactions: ownReactions ?? this.ownReactions, - parentId: parentId ?? this.parentId, - quotedMessage: quotedMessage ?? this.quotedMessage, - quotedMessageId: quotedMessageId ?? this.quotedMessageId, - replyCount: replyCount ?? this.replyCount, - threadParticipants: threadParticipants ?? this.threadParticipants, - showInChannel: showInChannel ?? this.showInChannel, - command: command ?? this.command, - createdAt: createdAt ?? this.createdAt, - silent: silent ?? this.silent, - extraData: extraData ?? this.extraData, - user: user ?? this.user, - shadowed: shadowed ?? this.shadowed, - updatedAt: updatedAt ?? this.updatedAt, - deletedAt: deletedAt ?? this.deletedAt, - status: status ?? this.status, - ); + }) { + assert(() { + if (pinExpires is! DateTime && + pinExpires != null && + pinExpires is! _PinExpires) { + throw ArgumentError('`pinExpires` can only be set as DateTime or null'); + } + return true; + }()); + return Message( + id: id ?? this.id, + text: text ?? this.text, + type: type ?? this.type, + attachments: attachments ?? this.attachments, + mentionedUsers: mentionedUsers ?? this.mentionedUsers, + reactionCounts: reactionCounts ?? this.reactionCounts, + reactionScores: reactionScores ?? this.reactionScores, + latestReactions: latestReactions ?? this.latestReactions, + ownReactions: ownReactions ?? this.ownReactions, + parentId: parentId ?? this.parentId, + quotedMessage: quotedMessage ?? this.quotedMessage, + quotedMessageId: quotedMessageId ?? this.quotedMessageId, + replyCount: replyCount ?? this.replyCount, + threadParticipants: threadParticipants ?? this.threadParticipants, + showInChannel: showInChannel ?? this.showInChannel, + command: command ?? this.command, + createdAt: createdAt ?? this.createdAt, + silent: silent ?? this.silent, + extraData: extraData ?? this.extraData, + user: user ?? this.user, + shadowed: shadowed ?? this.shadowed, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + status: status ?? this.status, + pinned: pinned ?? this.pinned, + pinnedAt: pinnedAt ?? this.pinnedAt, + pinnedBy: pinnedBy ?? this.pinnedBy, + pinExpires: pinExpires == _pinExpires ? this.pinExpires : pinExpires, + ); + } /// Returns a new [Message] that is a combination of this message and the given /// [other] message. @@ -281,6 +327,10 @@ class Message { updatedAt: other.updatedAt, deletedAt: other.deletedAt, status: other.status, + pinned: other.pinned, + pinnedAt: other.pinnedAt, + pinExpires: other.pinExpires, + pinnedBy: other.pinnedBy, ); } } diff --git a/packages/stream_chat/lib/src/models/message.g.dart b/packages/stream_chat/lib/src/models/message.g.dart index c30d8fe5..f8ac0c3d 100644 --- a/packages/stream_chat/lib/src/models/message.g.dart +++ b/packages/stream_chat/lib/src/models/message.g.dart @@ -75,6 +75,18 @@ Message _$MessageFromJson(Map json) { : User.fromJson((json['user'] as Map)?.map( (k, e) => MapEntry(k as String, e), )), + pinned: json['pinned'] as bool, + pinnedAt: json['pinned_at'] == null + ? null + : DateTime.parse(json['pinned_at'] as String), + pinExpires: json['pin_expires'] == null + ? null + : DateTime.parse(json['pin_expires'] as String), + pinnedBy: json['pinned_by'] == null + ? null + : User.fromJson((json['pinned_by'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), extraData: (json['extra_data'] as Map)?.map( (k, e) => MapEntry(k as String, e), ), @@ -116,6 +128,10 @@ Map _$MessageToJson(Message instance) { writeNotNull('created_at', readonly(instance.createdAt)); writeNotNull('updated_at', readonly(instance.updatedAt)); writeNotNull('user', readonly(instance.user)); + val['pinned'] = instance.pinned; + val['pinned_at'] = readonly(instance.pinnedAt); + val['pin_expires'] = instance.pinExpires?.toIso8601String(); + val['pinned_by'] = readonly(instance.pinnedBy); writeNotNull('extra_data', instance.extraData); writeNotNull('deleted_at', readonly(instance.deletedAt)); return val; diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/api/channel_test.dart index 937113da..755a3f3b 100644 --- a/packages/stream_chat/test/src/api/channel_test.dart +++ b/packages/stream_chat/test/src/api/channel_test.dart @@ -273,6 +273,79 @@ void main() { verify(mockDio.delete('/channels/messaging/testid/image', queryParameters: {'url': url})).called(1); }); + + test('pinMessage should throw argument error', () { + final client = StreamChatClient('api-key'); + + final channelClient = client.channel('messaging', id: 'testid'); + + final message = Message(text: 'Hello'); + + expect( + () => channelClient.pinMessage(message, 'InvalidType'), + throwsArgumentError, + ); + }); + + test('should be pinned successfully', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + + final channelClient = client.channel('messaging', id: 'testid'); + + final message = Message( + text: 'Hello', + id: 'test', + ); + + when(mockDio.post( + '/messages/${message.id}', + data: anything, + )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.pinMessage(message, 30); + + verify(mockDio.post('/messages/${message.id}', data: anything)) + .called(1); + }); + + test('should be unpinned successfully', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + + final channelClient = client.channel('messaging', id: 'testid'); + + final message = Message( + text: 'Hello', + id: 'test', + ); + + when(mockDio.post( + '/messages/${message.id}', + data: anything, + )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.unpinMessage(message); + + verify(mockDio.post('/messages/${message.id}', data: anything)) + .called(1); + }); }); test('sendEvent', () async { diff --git a/packages/stream_chat/test/src/client_test.dart b/packages/stream_chat/test/src/client_test.dart index bbc7aef4..815b3e5e 100644 --- a/packages/stream_chat/test/src/client_test.dart +++ b/packages/stream_chat/test/src/client_test.dart @@ -934,6 +934,56 @@ void main() { expect(client.delete('/test'), throwsA(ApiError('test error', 400))); }); }); + + group('pin message', () { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + test('should throw argument error', () { + final message = Message(text: 'Hello'); + expect( + () => client.pinMessage(message, 'InvalidType'), + throwsArgumentError, + ); + }); + + test('should complete successfully', () async { + final timeout = 30; + final message = Message(text: 'Hello'); + + when(mockDio.post( + '/messages/${message.id}', + data: anything, + )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.pinMessage(message, timeout); + + verify(mockDio.post('/messages/${message.id}', + data: {'message': anything})).called(1); + }); + + test('should unpin message successfully', () async { + final message = Message(text: 'Hello'); + + when(mockDio.post( + '/messages/${message.id}', + data: anything, + )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.unpinMessage(message); + + verify(mockDio.post('/messages/${message.id}', + data: anything)) + .called(1); + }); + }); }); }); }