[LLC] Add pin message feature

Signed-off-by: Sahil Kumar <xdsahil@gmail.com>
This commit is contained in:
Sahil Kumar
2021-02-19 13:33:40 +05:30
parent 24ce5333ce
commit e9ecd1cc35
8 changed files with 302 additions and 31 deletions
+37 -2
View File
@@ -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<Message>();
_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<Message>();
_messageAttachmentsUploadCompleter[message.id] =
attachmentsUploadCompleter;
@@ -449,6 +449,41 @@ class Channel {
}
}
/// Pins provided message
Future<UpdateMessageResponse> 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<UpdateMessageResponse> unpinMessage(Message message) {
return updateMessage(message.copyWith(pinned: false));
}
/// Send a file to this channel
Future<SendFileResponse> sendFile(
AttachmentFile file, {
+33 -1
View File
@@ -1295,7 +1295,7 @@ class StreamChatClient {
Future<UpdateMessageResponse> 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<UpdateMessageResponse> 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<UpdateMessageResponse> unpinMessage(Message message) {
return updateMessage(message.copyWith(pinned: false));
}
}
/// The class that handles the state of the channel listening to the events
@@ -20,6 +20,9 @@ class ChannelState {
/// A paginated list of channel members
final List<Member> members;
/// A paginated list of pinned messages
final List<Message> 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<Message> messages,
List<Member> members,
List<Message> pinnedMessages,
int watcherCount,
List<User> watchers,
List<Read> 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,
@@ -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<String, dynamic> _$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(),
@@ -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<String, dynamic> 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<String, dynamic> json) => _$MessageFromJson(
@@ -222,35 +251,52 @@ class Message {
DateTime updatedAt,
DateTime deletedAt,
User user,
bool pinned,
DateTime pinnedAt,
Object pinExpires = _pinExpires,
User pinnedBy,
Map<String, dynamic> 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,
);
}
}
@@ -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<String, dynamic> _$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;
@@ -273,6 +273,79 @@ void main() {
verify(mockDio.delete<String>('/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<String>(
'/messages/${message.id}',
data: anything,
)).thenAnswer((_) async => Response(data: '{}', statusCode: 200));
await channelClient.pinMessage(message, 30);
verify(mockDio.post<String>('/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<String>(
'/messages/${message.id}',
data: anything,
)).thenAnswer((_) async => Response(data: '{}', statusCode: 200));
await channelClient.unpinMessage(message);
verify(mockDio.post<String>('/messages/${message.id}', data: anything))
.called(1);
});
});
test('sendEvent', () async {
@@ -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<String>(
'/messages/${message.id}',
data: anything,
)).thenAnswer((_) async => Response(data: '{}', statusCode: 200));
await client.pinMessage(message, timeout);
verify(mockDio.post<String>('/messages/${message.id}',
data: {'message': anything})).called(1);
});
test('should unpin message successfully', () async {
final message = Message(text: 'Hello');
when(mockDio.post<String>(
'/messages/${message.id}',
data: anything,
)).thenAnswer((_) async => Response(data: '{}', statusCode: 200));
await client.unpinMessage(message);
verify(mockDio.post<String>('/messages/${message.id}',
data: anything))
.called(1);
});
});
});
});
}