diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index eb0dde7f..a2fc4ed3 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -354,9 +354,13 @@ class Channel { } /// Send a [message] to this channel. + /// If [skipPush] is true the message will not send a push notification /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually sending the message. - Future sendMessage(Message message) async { + Future sendMessage( + Message message, { + bool skipPush = false, + }) async { _checkInitialized(); // Cancelling previous completer in case it's called again in the process // Eg. Updating the message while the previous call is in progress. @@ -399,7 +403,12 @@ class Channel { message = await attachmentsUploadCompleter.future; } - final response = await _client.sendMessage(message, id!, type); + final response = await _client.sendMessage( + message, + id!, + type, + skipPush: skipPush, + ); state!.addMessage(response.message); return response; } catch (error) { @@ -414,6 +423,9 @@ class Channel { /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually updating the message. Future updateMessage(Message message) async { + final currentMessage = + state?.messages.firstWhere((e) => e.id == message.id); + // Cancelling previous completer in case it's called again in the process // Eg. Updating the message while the previous call is in progress. _messageAttachmentsUploadCompleter @@ -458,6 +470,31 @@ class Channel { state?.addMessage(m); + return response; + } catch (error) { + if (error is DioError && error.type != DioErrorType.response) { + state?.retryQueue?.add([message]); + } else if (error is ApiError) { + if (currentMessage != null) { + state?.addMessage(currentMessage); + } + } + rethrow; + } + } + + /// Partially updates the [message] in this channel. + Future partiallyUpdateMessage( + Message message, Map data) async { + try { + final response = await _client.partiallyUpdateMessage(message.id, data); + + final m = response.message.copyWith( + ownReactions: message.ownReactions, + ); + + state?.addMessage(m); + return response; } catch (error) { if (error is DioError && error.type != DioErrorType.response) { @@ -530,17 +567,21 @@ class Channel { Duration(seconds: timeoutOrExpirationDate.toInt()), ); } - return updateMessage( - message.copyWith( - pinned: true, - pinExpires: pinExpires, - ), - ); + return partiallyUpdateMessage(message, { + 'set': { + 'pinned': true, + if (pinExpires != null) 'pin_expires': pinExpires.toIso8601String(), + } + }); } /// Unpins provided message Future unpinMessage(Message message) => - updateMessage(message.copyWith(pinned: false)); + partiallyUpdateMessage(message, { + 'set': { + 'pinned': false, + } + }); /// Send a file to this channel Future sendFile( diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index 12629dae..1b98762d 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -315,7 +315,7 @@ class StreamChatClient { await connectUser(User(id: userId), newToken); try { - handler.resolve( + return handler.resolve( await httpClient.request( err.requestOptions.path, cancelToken: err.requestOptions.cancelToken, @@ -343,10 +343,12 @@ class StreamChatClient { ), ); } on DioError { - handler.reject(err); + return handler.reject(err); } } } + + return handler.next(err); } LogHandlerFunction _getDefaultLogHandler() { @@ -1327,11 +1329,15 @@ class StreamChatClient { Future sendMessage( Message message, String channelId, - String channelType, - ) async { + String channelType, { + bool skipPush = false, + }) async { final response = await post( '/channels/$channelType/$channelId/message', - data: {'message': message.toJson()}, + data: { + 'message': message.toJson(), + 'skip_push': skipPush, + }, ); return decode(response.data, SendMessageResponse.fromJson); } @@ -1345,6 +1351,20 @@ class StreamChatClient { return decode(response.data, UpdateMessageResponse.fromJson); } + /// Partially update the given message + /// Use 'set' in map to set values + /// User 'unset' in map to unset values + Future partiallyUpdateMessage( + String id, + Map data, + ) async { + final response = await put( + '/messages/$id', + data: data, + ); + return decode(response.data, UpdateMessageResponse.fromJson); + } + /// Deletes the given message Future deleteMessage(Message message) async { final response = await delete('/messages/${message.id}'); @@ -1383,20 +1403,21 @@ class StreamChatClient { ) .toUtc(); } - return updateMessage( - message.copyWith( - pinned: true, - pinExpires: pinExpires, - ), - ); + return partiallyUpdateMessage(message.id, { + 'set': { + 'pinned': true, + if (pinExpires != null) 'pin_expires': pinExpires.toIso8601String(), + } + }); } /// Unpins provided message - Future unpinMessage(Message message) => updateMessage( - message.copyWith( - pinned: false, - ), - ); + Future unpinMessage(Message message) => + partiallyUpdateMessage(message.id, { + 'set': { + 'pinned': false, + } + }); } /// The class that handles the state of the channel listening to the events diff --git a/packages/stream_chat/lib/src/models/message.dart b/packages/stream_chat/lib/src/models/message.dart index 327845dd..6df17497 100644 --- a/packages/stream_chat/lib/src/models/message.dart +++ b/packages/stream_chat/lib/src/models/message.dart @@ -73,7 +73,6 @@ class Message extends Equatable { this.extraData = const {}, this.deletedAt, this.status = MessageSendingStatus.sent, - this.skipPush = false, }) : id = id ?? const Uuid().v4(), pinExpires = pinExpires?.toUtc(), createdAt = createdAt ?? DateTime.now(), @@ -158,10 +157,6 @@ class Message extends Equatable { @JsonKey(defaultValue: false) final bool silent; - /// If true the message will not send a push notification - @JsonKey(defaultValue: false) - final bool skipPush; - /// If true the message is shadowed @JsonKey( includeIfNull: false, @@ -253,7 +248,6 @@ class Message extends Equatable { 'pinned_at', 'pin_expires', 'pinned_by', - 'skip_push', ]; /// Serialize to json @@ -291,7 +285,6 @@ class Message extends Equatable { User? pinnedBy, Map? extraData, MessageSendingStatus? status, - bool? skipPush, }) { assert(() { if (pinExpires is! DateTime && @@ -331,7 +324,6 @@ class Message extends Equatable { pinnedBy: pinnedBy ?? this.pinnedBy, pinExpires: pinExpires == _pinExpires ? this.pinExpires : pinExpires as DateTime?, - skipPush: skipPush ?? this.skipPush, ); } @@ -366,7 +358,6 @@ class Message extends Equatable { pinnedAt: other.pinnedAt, pinExpires: other.pinExpires, pinnedBy: other.pinnedBy, - skipPush: other.skipPush, ); @override @@ -399,7 +390,6 @@ class Message extends Equatable { pinnedBy, extraData, status, - skipPush, ]; } diff --git a/packages/stream_chat/lib/src/models/message.g.dart b/packages/stream_chat/lib/src/models/message.g.dart index ca094a83..8792035a 100644 --- a/packages/stream_chat/lib/src/models/message.g.dart +++ b/packages/stream_chat/lib/src/models/message.g.dart @@ -67,7 +67,6 @@ Message _$MessageFromJson(Map json) { deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), - skipPush: json['skip_push'] as bool? ?? false, ); } @@ -97,7 +96,6 @@ Map _$MessageToJson(Message instance) { writeNotNull('thread_participants', readonly(instance.threadParticipants)); val['show_in_channel'] = instance.showInChannel; val['silent'] = instance.silent; - val['skip_push'] = instance.skipPush; writeNotNull('shadowed', readonly(instance.shadowed)); writeNotNull('command', readonly(instance.command)); writeNotNull('created_at', readonly(instance.createdAt)); diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/api/channel_test.dart index f92b05bb..18a1a3e7 100644 --- a/packages/stream_chat/test/src/api/channel_test.dart +++ b/packages/stream_chat/test/src/api/channel_test.dart @@ -52,7 +52,10 @@ void main() { when( () => mockDio.post( '/channels/messaging/testid/message', - data: {'message': message.toJson()}, + data: { + 'message': message.toJson(), + 'skip_push': false, + }, ), ).thenAnswer( (_) async => Response( @@ -67,6 +70,7 @@ void main() { verify(() => mockDio.post('/channels/messaging/testid/message', data: { 'message': message.toJson(), + 'skip_push': false, })).called(1); }); @@ -430,7 +434,7 @@ void main() { await channelClient.watch(); when( - () => mockDio.post( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -445,7 +449,7 @@ void main() { await channelClient.pinMessage(message, 30); verify(() => - mockDio.post('/messages/${message.id}', data: anything)) + mockDio.put('/messages/${message.id}', data: anything)) .called(1); }); @@ -475,7 +479,7 @@ void main() { await channelClient.watch(); when( - () => mockDio.post( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -490,7 +494,7 @@ void main() { await channelClient.pinMessage(message); verify(() => - mockDio.post('/messages/${message.id}', data: anything)) + mockDio.put('/messages/${message.id}', data: anything)) .called(1); }); @@ -520,7 +524,7 @@ void main() { await channelClient.watch(); when( - () => mockDio.post( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -535,7 +539,7 @@ void main() { await channelClient.unpinMessage(message); verify(() => - mockDio.post('/messages/${message.id}', data: anything)) + mockDio.put('/messages/${message.id}', data: anything)) .called(1); }); }); diff --git a/packages/stream_chat/test/src/client_test.dart b/packages/stream_chat/test/src/client_test.dart index 53792764..2450de7c 100644 --- a/packages/stream_chat/test/src/client_test.dart +++ b/packages/stream_chat/test/src/client_test.dart @@ -745,6 +745,39 @@ void main() { data: {'message': anything})).called(1); }); + test('partiallyUpdateMessage', () async { + final mockDio = MockDio(); + + when(() => mockDio.options).thenReturn(BaseOptions()); + when(() => mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient('api-key', httpClient: mockDio); + final message = Message( + id: 'test', + text: 'demo', + ); + + when( + () => mockDio.put( + '/messages/${message.id}', + data: {'set': anything}, + ), + ).thenAnswer( + (_) async => Response( + data: jsonEncode({'message': message}), + statusCode: 200, + requestOptions: FakeRequestOptions(), + ), + ); + + await client.partiallyUpdateMessage(message.id, { + 'set': {'text': message.text} + }); + + verify(() => mockDio.put('/messages/${message.id}', + data: {'set': anything})).called(1); + }); + test('deleteMessage', () async { final mockDio = MockDio(); @@ -1094,7 +1127,7 @@ void main() { final message = Message(text: 'Hello'); when( - () => mockDio.post( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -1108,15 +1141,15 @@ void main() { await client.pinMessage(message, timeout); - verify(() => mockDio.post('/messages/${message.id}', - data: {'message': anything})).called(1); + verify(() => mockDio.put('/messages/${message.id}', + data: {'set': anything})).called(1); }); test('should complete successfully with a null value', () async { final message = Message(text: 'Hello'); when( - () => mockDio.post( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -1130,15 +1163,15 @@ void main() { await client.pinMessage(message); - verify(() => mockDio.post('/messages/${message.id}', - data: {'message': anything})).called(1); + verify(() => mockDio.put('/messages/${message.id}', + data: {'set': anything})).called(1); }); test('should unpin message successfully', () async { final message = Message(text: 'Hello'); when( - () => mockDio.post( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -1152,7 +1185,7 @@ void main() { await client.unpinMessage(message); - verify(() => mockDio.post('/messages/${message.id}', + verify(() => mockDio.put('/messages/${message.id}', data: anything)).called(1); }); }); diff --git a/packages/stream_chat/test/src/models/channel_state_test.dart b/packages/stream_chat/test/src/models/channel_state_test.dart index a93166b4..df078583 100644 --- a/packages/stream_chat/test/src/models/channel_state_test.dart +++ b/packages/stream_chat/test/src/models/channel_state_test.dart @@ -902,7 +902,6 @@ void main() { "show_in_channel": null, "mentioned_users": [], "status": "SENT", - "skip_push": false, "silent": false, "pinned": false, "pinned_at": null, @@ -919,7 +918,6 @@ void main() { "show_in_channel": null, "mentioned_users": [], "status": "SENT", - "skip_push": false, "silent": false, "pinned": false, "pinned_at": null, @@ -929,7 +927,6 @@ void main() { { "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", "text": "test message", - "skip_push": false, "attachments": [], "parent_id": null, "quoted_message": null, @@ -952,7 +949,6 @@ void main() { "quoted_message_id": null, "show_in_channel": null, "mentioned_users": [], - "skip_push": false, "status": "SENT", "silent": false, "pinned": false, @@ -964,7 +960,6 @@ void main() { "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", "text": "test", "attachments": [], - "skip_push": false, "parent_id": null, "quoted_message": null, "quoted_message_id": null, @@ -982,7 +977,6 @@ void main() { "text": "hi", "attachments": [], "parent_id": null, - "skip_push": false, "quoted_message": null, "quoted_message_id": null, "show_in_channel": null, @@ -1000,7 +994,6 @@ void main() { "attachments": [], "parent_id": null, "quoted_message": null, - "skip_push": false, "quoted_message_id": null, "show_in_channel": null, "mentioned_users": [], @@ -1023,7 +1016,6 @@ void main() { "status": "SENT", "silent": false, "pinned": false, - "skip_push": false, "pinned_at": null, "pin_expires": null, "pinned_by": null @@ -1041,7 +1033,6 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, - "skip_push": false, "pin_expires": null, "pinned_by": null }, @@ -1053,7 +1044,6 @@ void main() { "quoted_message": null, "quoted_message_id": null, "show_in_channel": null, - "skip_push": false, "mentioned_users": [], "status": "SENT", "silent": false, @@ -1071,7 +1061,6 @@ void main() { "quoted_message_id": null, "show_in_channel": null, "mentioned_users": [], - "skip_push": false, "status": "SENT", "silent": false, "pinned": false, @@ -1090,7 +1079,6 @@ void main() { "mentioned_users": [], "status": "SENT", "silent": false, - "skip_push": false, "pinned": false, "pinned_at": null, "pin_expires": null, @@ -1100,7 +1088,6 @@ void main() { "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", "text": "what are you doing?", "attachments": [], - "skip_push": false, "parent_id": null, "quoted_message": null, "quoted_message_id": null, @@ -1118,7 +1105,6 @@ void main() { "text": "👍", "attachments": [], "parent_id": null, - "skip_push": false, "quoted_message": null, "quoted_message_id": null, "show_in_channel": null, @@ -1134,7 +1120,6 @@ void main() { "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", "text": "sdasas", "attachments": [], - "skip_push": false, "parent_id": null, "quoted_message": null, "quoted_message_id": null, @@ -1155,7 +1140,6 @@ void main() { "quoted_message": null, "quoted_message_id": null, "show_in_channel": null, - "skip_push": false, "mentioned_users": [], "status": "SENT", "silent": false, @@ -1168,7 +1152,6 @@ void main() { "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", "text": "nhisagdhsadz", "attachments": [], - "skip_push": false, "parent_id": null, "quoted_message": null, "quoted_message_id": null, @@ -1187,7 +1170,6 @@ void main() { "attachments": [], "parent_id": null, "quoted_message": null, - "skip_push": false, "quoted_message_id": null, "show_in_channel": null, "mentioned_users": [], @@ -1204,7 +1186,6 @@ void main() { "attachments": [], "parent_id": null, "quoted_message": null, - "skip_push": false, "quoted_message_id": null, "show_in_channel": null, "mentioned_users": [], @@ -1212,7 +1193,6 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, - "skip_push": false, "pin_expires": null, "pinned_by": null }, @@ -1229,7 +1209,6 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, - "skip_push": false, "pin_expires": null, "pinned_by": null }, @@ -1246,7 +1225,6 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, - "skip_push": false, "pin_expires": null, "pinned_by": null }, @@ -1263,7 +1241,6 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, - "skip_push": false, "pin_expires": null, "pinned_by": null }, @@ -1280,7 +1257,6 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, - "skip_push": false, "pin_expires": null, "pinned_by": null }, @@ -1297,7 +1273,6 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, - "skip_push": false, "pin_expires": null, "pinned_by": null }, @@ -1314,7 +1289,6 @@ void main() { "silent": false, "pinned": false, "pinned_at": null, - "skip_push": false, "pin_expires": null, "pinned_by": null } diff --git a/packages/stream_chat/test/src/models/message_test.dart b/packages/stream_chat/test/src/models/message_test.dart index 1219e137..84ae616e 100644 --- a/packages/stream_chat/test/src/models/message_test.dart +++ b/packages/stream_chat/test/src/models/message_test.dart @@ -133,7 +133,6 @@ void main() { "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", "silent": false, - "skip_push": false, "attachments": [ { "type": "video", diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart index 5336ed48..1da4fcea 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart @@ -27,6 +27,8 @@ class MessageActionsModal extends StatefulWidget { this.showResendMessage = true, this.showThreadReplyMessage = true, this.showFlagButton = true, + this.showPinButton = true, + this.showPinHighlight = false, this.showUserAvatar = DisplayWidget.show, this.editMessageInputBuilder, this.messageShape, @@ -35,6 +37,7 @@ class MessageActionsModal extends StatefulWidget { this.customActions = const [], this.attachmentBorderRadiusGeometry, this.onCopyTap, + this.textBuilder, }) : super(key: key); /// Builder for edit message @@ -79,6 +82,12 @@ class MessageActionsModal extends StatefulWidget { /// Flag for showing flag action final bool showFlagButton; + /// Flag for showing pin action + final bool showPinButton; + + /// Display Pin Highlight + final bool showPinHighlight; + /// Flag for reversing message final bool reverse; @@ -97,6 +106,9 @@ class MessageActionsModal extends StatefulWidget { /// List of custom actions final List customActions; + /// Customize the MessageWidget textBuilder + final Widget Function(BuildContext context, Message message)? textBuilder; + @override _MessageActionsModalState createState() => _MessageActionsModalState(); } @@ -222,6 +234,8 @@ class _MessageActionsModalState extends State { showSendingIndicator: false, shape: widget.messageShape, attachmentShape: widget.attachmentShape, + showPinHighlight: false, + textBuilder: widget.textBuilder, ), ), const SizedBox(height: 8), @@ -258,6 +272,8 @@ class _MessageActionsModalState extends State { _buildCopyButton(context), if (widget.showFlagButton) _buildFlagButton(context), + if (widget.showPinButton) + _buildPinButton(context), if (widget.showDeleteMessage) _buildDeleteButton(context), ...widget.customActions @@ -359,6 +375,21 @@ class _MessageActionsModalState extends State { } } + void _togglePin() async { + final channel = StreamChannel.of(context).channel; + + try { + if (!widget.message.pinned) { + await channel.pinMessage(widget.message); + } else { + await channel.unpinMessage(widget.message); + } + Navigator.pop(context); + } catch (e) { + _showErrorAlert(); + } + } + void _showDeleteDialog() async { setState(() { _showActions = false; @@ -451,6 +482,29 @@ class _MessageActionsModalState extends State { ); } + Widget _buildPinButton(BuildContext context) { + final streamChatThemeData = StreamChatTheme.of(context); + return InkWell( + onTap: _togglePin, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), + child: Row( + children: [ + StreamSvgIcon.pin( + color: streamChatThemeData.primaryIconTheme.color, + size: 24, + ), + const SizedBox(width: 16), + Text( + '${widget.message.pinned ? 'Unpin from' : 'Pin to'} Conversation', + style: streamChatThemeData.textTheme.body, + ), + ], + ), + ), + ); + } + Widget _buildDeleteButton(BuildContext context) { final isDeleteFailed = widget.message.status == MessageSendingStatus.failed_delete; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 148ccfca..cb1f66b7 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -156,8 +156,10 @@ class MessageListView extends StatefulWidget { this.onMessageTap, this.onSystemMessageTap, this.onAttachmentTap, - this.textBuilder, this.onLinkTap, + this.pinPermissions = const [], + this.textBuilder, + this.usernameBuilder, }) : super(key: key); /// Function used to build a custom message widget @@ -259,11 +261,17 @@ class MessageListView extends StatefulWidget { final void Function(Message message, Attachment attachment)? onAttachmentTap; /// Customize the MessageWidget textBuilder - final void Function(BuildContext context, Message message)? textBuilder; + final Widget Function(BuildContext context, Message message)? textBuilder; + + /// Customize the MessageWidget usernameBuilder + final Widget Function(BuildContext context, Message message)? usernameBuilder; /// Callback for when link is tapped final void Function(String link)? onLinkTap; + /// A List of user types that have permission to pin messages + final List pinPermissions; + @override _MessageListViewState createState() => _MessageListViewState(); } @@ -805,6 +813,10 @@ class _MessageListViewState extends State { ) { final isMyMessage = message.user!.id == StreamChat.of(context).user!.id; final isOnlyEmoji = message.text!.isOnlyEmoji; + final currentUser = StreamChat.of(context).user; + final members = StreamChannel.of(context).channel.state?.members ?? []; + final currentUserMember = + members.firstWhere((e) => e.user!.id == currentUser!.id); final chatThemeData = StreamChatTheme.of(context); return MessageWidget( @@ -855,9 +867,10 @@ class _MessageListViewState extends State { } FocusScope.of(context).unfocus(); }, - textBuilder: - widget.textBuilder as Widget Function(BuildContext, Message)?, + textBuilder: widget.textBuilder, + usernameBuilder: widget.usernameBuilder, onLinkTap: widget.onLinkTap, + showPinButton: widget.pinPermissions.contains(currentUserMember.role), ); } @@ -944,6 +957,11 @@ class _MessageListViewState extends State { ? BorderSide.none : null; + final currentUser = StreamChat.of(context).user; + final members = StreamChannel.of(context).channel.state?.members ?? []; + final currentUserMember = + members.firstWhere((e) => e.user!.id == currentUser!.id); + final chatThemeData = StreamChatTheme.of(context); Widget child = MessageWidget( key: ValueKey('MESSAGE-${message.id}'), @@ -1054,9 +1072,10 @@ class _MessageListViewState extends State { FocusScope.of(context).unfocus(); }, onAttachmentTap: widget.onAttachmentTap, - textBuilder: - widget.textBuilder as Widget Function(BuildContext, Message)?, + textBuilder: widget.textBuilder, + usernameBuilder: widget.usernameBuilder, onLinkTap: widget.onLinkTap, + showPinButton: widget.pinPermissions.contains(currentUserMember.role), ); if (!message.isDeleted && diff --git a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart index 5f3bc5a4..9d78e3d9 100644 --- a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart @@ -23,6 +23,7 @@ class MessageReactionsModal extends StatelessWidget { this.showUserAvatar = DisplayWidget.show, this.onUserAvatarTap, this.attachmentBorderRadiusGeometry, + this.textBuilder, }) : super(key: key); /// Message to display reactions of @@ -52,6 +53,9 @@ class MessageReactionsModal extends StatelessWidget { /// [BorderRadius] to apply to attachments final BorderRadius? attachmentBorderRadiusGeometry; + /// Customize the MessageWidget textBuilder + final Widget Function(BuildContext context, Message message)? textBuilder; + @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; @@ -157,6 +161,8 @@ class MessageReactionsModal extends StatelessWidget { ), showReactionPickerIndicator: showReactions && (message.status == MessageSendingStatus.sent), + textBuilder: textBuilder, + showPinHighlight: false, ), ), if (message.latestReactions?.isNotEmpty == true) ...[ diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index 97e061ae..f1b5428d 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -83,6 +83,8 @@ class MessageWidget extends StatefulWidget { this.showResendMessage = true, this.showCopyMessage = true, this.showFlagButton = true, + this.showPinButton = true, + this.showPinHighlight = true, this.onUserAvatarTap, this.onLinkTap, this.onMessageActions, @@ -102,6 +104,7 @@ class MessageWidget extends StatefulWidget { this.onQuotedMessageTap, this.customActions = const [], this.onAttachmentTap, + this.usernameBuilder, }) : attachmentBuilders = { 'image': (context, message, attachments) { final border = RoundedRectangleBorder( @@ -265,6 +268,9 @@ class MessageWidget extends StatefulWidget { /// Widget builder for building text final Widget Function(BuildContext, Message)? textBuilder; + /// Widget builder for building username + final Widget Function(BuildContext, Message)? usernameBuilder; + /// Function called on long press final void Function(BuildContext, Message)? onMessageActions; @@ -367,6 +373,12 @@ class MessageWidget extends StatefulWidget { /// Show flag action final bool showFlagButton; + /// Show flag action + final bool showPinButton; + + /// Display Pin Highlight + final bool showPinHighlight; + /// Builder for respective attachment types final Map attachmentBuilders; @@ -450,7 +462,12 @@ class _MessageWidgetState extends State widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; return Material( - type: MaterialType.transparency, + type: widget.message.pinned && widget.showPinHighlight + ? MaterialType.card + : MaterialType.transparency, + color: widget.message.pinned && widget.showPinHighlight + ? StreamChatTheme.of(context).colorTheme.highlight + : null, child: Portal( child: InkWell( onTap: () { @@ -477,148 +494,165 @@ class _MessageWidgetState extends State ? AlignmentDirectional.bottomEnd : AlignmentDirectional.bottomStart, children: [ - Column( - crossAxisAlignment: widget.reverse - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.showUserAvatar == DisplayWidget.show && - widget.message.user != null) ...[ - _buildUserAvatar(), - const SizedBox(width: 4), - ], - if (widget.showUserAvatar == DisplayWidget.hide) - SizedBox(width: avatarWidth + 4), - Flexible( - child: PortalEntry( - portal: Container( - transform: Matrix4.translationValues( - widget.reverse ? 12 : -12, 0, 0), - constraints: const BoxConstraints( - maxWidth: 22 * 6.0), - child: _buildReactionIndicator(context), - ), - portalAnchor: - Alignment(widget.reverse ? 1 : -1, -1), - childAnchor: - Alignment(widget.reverse ? -1 : 1, -1), - child: Stack( - clipBehavior: Clip.none, - children: [ - Padding( - padding: widget.showReactions - ? EdgeInsets.only( - top: widget - .message - .reactionCounts - ?.isNotEmpty == - true - ? 18 - : 0, - ) - : EdgeInsets.zero, - child: (widget.message.isDeleted && - !isFailedState) - ? Container( - // ignore: lines_longer_than_80_chars - margin: EdgeInsets.symmetric( - horizontal: + Padding( + padding: EdgeInsets.only( + bottom: + isPinned && widget.showPinHighlight ? 8.0 : 0.0, + ), + child: Column( + crossAxisAlignment: widget.reverse + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.message.pinned && + widget.message.pinnedBy != null && + widget.showPinHighlight) + _buildPinnedMessage(widget.message), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showUserAvatar == + DisplayWidget.show && + widget.message.user != null) ...[ + _buildUserAvatar(), + const SizedBox(width: 4), + ], + if (widget.showUserAvatar == DisplayWidget.hide) + SizedBox(width: avatarWidth + 4), + Flexible( + child: PortalEntry( + portal: Container( + transform: Matrix4.translationValues( + widget.reverse ? 12 : -12, 0, 0), + constraints: const BoxConstraints( + maxWidth: 22 * 6.0), + child: _buildReactionIndicator(context), + ), + portalAnchor: + Alignment(widget.reverse ? 1 : -1, -1), + childAnchor: + Alignment(widget.reverse ? -1 : 1, -1), + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: widget.showReactions + ? EdgeInsets.only( + top: widget + .message + .reactionCounts + ?.isNotEmpty == + true + ? 18 + : 0, + ) + : EdgeInsets.zero, + child: (widget.message.isDeleted && + !isFailedState) + ? Container( + // ignore: lines_longer_than_80_chars + margin: EdgeInsets.symmetric( + horizontal: + // ignore: lines_longer_than_80_chars + widget.showUserAvatar == + // ignore: lines_longer_than_80_chars + DisplayWidget.gone + ? 0 + : 4.0), + child: DeletedMessage( + borderRadiusGeometry: widget + .borderRadiusGeometry, + borderSide: + widget.borderSide, + shape: widget.shape, + messageTheme: + widget.messageTheme, + ), + ) + : Card( + clipBehavior: Clip.antiAlias, + elevation: 0, + margin: EdgeInsets.symmetric( + horizontal: (isFailedState + ? 15.0 + : 0.0) + // ignore: lines_longer_than_80_chars - widget.showUserAvatar == - // ignore: lines_longer_than_80_chars + (widget.showUserAvatar == DisplayWidget .gone ? 0 : 4.0), - child: DeletedMessage( - borderRadiusGeometry: widget - .borderRadiusGeometry, - borderSide: widget.borderSide, - shape: widget.shape, - messageTheme: - widget.messageTheme, - ), - ) - : Card( - clipBehavior: Clip.antiAlias, - elevation: 0, - margin: EdgeInsets.symmetric( - horizontal: (isFailedState - ? 15.0 - : 0.0) + - // ignore: lines_longer_than_80_chars - (widget.showUserAvatar == - DisplayWidget.gone - ? 0 - : 4.0), - ), - shape: widget.shape ?? - RoundedRectangleBorder( - side: widget.borderSide ?? - BorderSide( - color: widget - // ignore: lines_longer_than_80_chars - .messageTheme - // ignore: lines_longer_than_80_chars - .messageBorderColor ?? - Colors.grey, - ), - borderRadius: widget - // ignore: lines_longer_than_80_chars - .borderRadiusGeometry ?? - BorderRadius.zero, - ), - color: _getBackgroundColor(), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.end, - mainAxisSize: - MainAxisSize.min, - children: [ - if (hasQuotedMessage) - _buildQuotedMessage(), - if (hasNonUrlAttachments) - _parseAttachments(), - if (!isGiphy) - _buildTextBubble(), - ], + ), + shape: widget.shape ?? + RoundedRectangleBorder( + side: widget + .borderSide ?? + BorderSide( + color: widget + // ignore: lines_longer_than_80_chars + .messageTheme + // ignore: lines_longer_than_80_chars + .messageBorderColor ?? + Colors.grey, + ), + borderRadius: widget + // ignore: lines_longer_than_80_chars + .borderRadiusGeometry ?? + BorderRadius.zero, + ), + color: _getBackgroundColor(), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.end, + mainAxisSize: + MainAxisSize.min, + children: [ + if (hasQuotedMessage) + _buildQuotedMessage(), + if (hasNonUrlAttachments) + _parseAttachments(), + if (!isGiphy) + _buildTextBubble(), + ], + ), ), + ), + if (widget.showReactionPickerIndicator) + Positioned( + right: widget.reverse ? null : 4, + left: widget.reverse ? 4 : null, + top: -8, + child: CustomPaint( + painter: ReactionBubblePainter( + StreamChatTheme.of(context) + .colorTheme + .white, + Colors.transparent, + Colors.transparent, + tailCirclesSpace: 1, ), - ), - if (widget.showReactionPickerIndicator) - Positioned( - right: widget.reverse ? null : 4, - left: widget.reverse ? 4 : null, - top: -8, - child: CustomPaint( - painter: ReactionBubblePainter( - StreamChatTheme.of(context) - .colorTheme - .white, - Colors.transparent, - Colors.transparent, - tailCirclesSpace: 1, ), ), - ), - ], + ], + ), ), ), - ), - ], - ), - if (showBottomRow) - SizedBox(height: context.textScaleFactor * 18.0), - ], + ], + ), + if (showBottomRow) + SizedBox(height: context.textScaleFactor * 18.0), + ], + ), ), if (showBottomRow) Padding( - padding: EdgeInsets.only(left: leftPadding), + padding: EdgeInsets.only( + left: leftPadding, + bottom: + isPinned && widget.showPinHighlight ? 6.0 : 0.0, + ), child: _bottomRow, ), if (isFailedState) @@ -721,14 +755,7 @@ class _MessageWidgetState extends State child: Text(msg, style: widget.messageTheme.replies), ), ], - if (showUsername) - Text( - widget.message.user!.name, - maxLines: 1, - key: usernameKey, - style: widget.messageTheme.messageAuthor, - overflow: TextOverflow.ellipsis, - ), + if (showUsername) _buildUsername(usernameKey), if (showTimeStamp) Text( Jiffy(widget.message.createdAt.toLocal()).jm, @@ -791,6 +818,19 @@ class _MessageWidgetState extends State ); } + Widget _buildUsername(Key usernameKey) { + if (widget.usernameBuilder != null) { + return widget.usernameBuilder!(context, widget.message); + } + return Text( + widget.message.user!.name, + maxLines: 1, + key: usernameKey, + style: widget.messageTheme.messageAuthor, + overflow: TextOverflow.ellipsis, + ); + } + Widget _buildUrlAttachment() { final urlAttachment = widget.message.attachments .firstWhere((element) => element.ogScrapeUrl != null); @@ -882,6 +922,7 @@ class _MessageWidgetState extends State builder: (context) => StreamChannel( channel: channel, child: MessageActionsModal( + textBuilder: widget.textBuilder, onCopyTap: (message) => Clipboard.setData(ClipboardData(text: message.text)), attachmentBorderRadiusGeometry: @@ -918,6 +959,7 @@ class _MessageWidgetState extends State !isFailedState && widget.onThreadTap != null, showFlagButton: widget.showFlagButton, + showPinButton: widget.showPinButton, customActions: widget.customActions, ), )); @@ -931,6 +973,7 @@ class _MessageWidgetState extends State builder: (context) => StreamChannel( channel: channel, child: MessageReactionsModal( + textBuilder: widget.textBuilder, attachmentBorderRadiusGeometry: widget.attachmentBorderRadiusGeometry as BorderRadius?, showUserAvatar: @@ -1113,8 +1156,38 @@ class _MessageWidgetState extends State ); } + Widget _buildPinnedMessage(Message message) { + final pinnedBy = message.pinnedBy; + final pinnedByMe = StreamChat.of(context).user!.id == pinnedBy!.id; + + return Padding( + padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSvgIcon.pin( + size: 16, + ), + const SizedBox( + width: 4, + ), + Text( + 'Pinned by ${pinnedByMe ? 'You' : pinnedBy.name}', + style: TextStyle( + color: StreamChatTheme.of(context).colorTheme.grey, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ) + ], + ), + ); + } + bool get isOnlyEmoji => widget.message.text!.isOnlyEmoji; + bool get isPinned => widget.message.pinned; + Color? _getBackgroundColor() { if (hasQuotedMessage) { return widget.messageTheme.messageBackgroundColor; diff --git a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart index 78dfba2c..ae344a89 100644 --- a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart @@ -889,6 +889,18 @@ class StreamSvgIcon extends StatelessWidget { height: size, ); + /// [StreamSvgIcon] type + factory StreamSvgIcon.pin({ + double? size, + Color? color, + }) => + StreamSvgIcon( + assetName: 'icon_pin.svg', + color: color, + width: size, + height: size, + ); + /// Name of icon asset final String? assetName; diff --git a/packages/stream_chat_flutter/lib/svgs/icon_pin.svg b/packages/stream_chat_flutter/lib/svgs/icon_pin.svg new file mode 100644 index 00000000..0f494729 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/icon_pin.svg @@ -0,0 +1,3 @@ + + +