diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 51034d3a..1925e325 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -493,6 +493,7 @@ class Channel { Future sendMessage( Message message, { bool skipPush = false, + bool skipEnrichUrl = false, }) async { _checkInitialized(); // Cancelling previous completer in case it's called again in the process @@ -540,6 +541,7 @@ class Channel { id!, type, skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, ); state!.addMessage(response.message); if (cooldown > 0) cooldownStartedAt = DateTime.now(); @@ -556,7 +558,10 @@ class Channel { /// /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually updating the message. - Future updateMessage(Message message) async { + Future updateMessage( + Message message, { + bool skipEnrichUrl = false, + }) async { final originalMessage = message; // Cancelling previous completer in case it's called again in the process @@ -594,7 +599,10 @@ class Channel { message = await attachmentsUploadCompleter.future; } - final response = await _client.updateMessage(message); + final response = await _client.updateMessage( + message, + skipEnrichUrl: skipEnrichUrl, + ); final m = response.message.copyWith( ownReactions: message.ownReactions, @@ -624,12 +632,14 @@ class Channel { Message message, { Map? set, List? unset, + bool skipEnrichUrl = false, }) async { try { final response = await _client.partialUpdateMessage( message.id, set: set, unset: unset, + skipEnrichUrl: skipEnrichUrl, ); final updatedMessage = response.message.copyWith( diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index b2ae3657..141bad48 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -1169,12 +1169,14 @@ class StreamChatClient { String channelId, String channelType, { bool skipPush = false, + bool skipEnrichUrl = false, }) => _chatApi.message.sendMessage( channelId, channelType, message, skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, ); /// Lists all the message replies for the [parentId] @@ -1198,8 +1200,14 @@ class StreamChatClient { ); /// Update the given message - Future updateMessage(Message message) => - _chatApi.message.updateMessage(message); + Future updateMessage( + Message message, { + bool skipEnrichUrl = false, + }) => + _chatApi.message.updateMessage( + message, + skipEnrichUrl: skipEnrichUrl, + ); /// Partially update the given [messageId] /// Use [set] to define values to be set @@ -1208,11 +1216,13 @@ class StreamChatClient { String messageId, { Map? set, List? unset, + bool skipEnrichUrl = false, }) => _chatApi.message.partialUpdateMessage( messageId, set: set, unset: unset, + skipEnrichUrl: skipEnrichUrl, ); /// Deletes the given message diff --git a/packages/stream_chat/lib/src/core/api/message_api.dart b/packages/stream_chat/lib/src/core/api/message_api.dart index 8894e14c..20cdfc51 100644 --- a/packages/stream_chat/lib/src/core/api/message_api.dart +++ b/packages/stream_chat/lib/src/core/api/message_api.dart @@ -16,12 +16,14 @@ class MessageApi { String channelType, Message message, { bool skipPush = false, + bool skipEnrichUrl = false, }) async { final response = await _client.post( '/channels/$channelType/$channelId/message', data: { 'message': message, 'skip_push': skipPush, + 'skip_enrich_url': skipEnrichUrl, }, ); return SendMessageResponse.fromJson(response.data); @@ -51,11 +53,15 @@ class MessageApi { /// Updates the given [message] Future updateMessage( - Message message, - ) async { + Message message, { + bool skipEnrichUrl = false, + }) async { final response = await _client.post( '/messages/${message.id}', - data: {'message': message}, + data: { + 'message': message, + 'skip_enrich_url': skipEnrichUrl, + }, ); return UpdateMessageResponse.fromJson(response.data); } @@ -67,12 +73,14 @@ class MessageApi { String messageId, { Map? set, List? unset, + bool skipEnrichUrl = false, }) async { final response = await _client.put( '/messages/$messageId', data: { if (set != null) 'set': set, if (unset != null) 'unset': unset, + 'skip_enrich_url': skipEnrichUrl, }, ); return UpdateMessageResponse.fromJson(response.data); diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index 09fe1083..325dbe11 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; import 'package:stream_chat/src/core/models/action.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; import 'package:stream_chat/src/core/util/serializer.dart'; @@ -66,6 +67,21 @@ class Attachment extends Equatable { topLevelFields + dbSpecificTopLevelFields, )); + factory Attachment.fromOGAttachment(OGAttachmentResponse ogAttachment) => + Attachment( + type: ogAttachment.type, + title: ogAttachment.title, + titleLink: ogAttachment.titleLink, + text: ogAttachment.text, + imageUrl: ogAttachment.imageUrl, + thumbUrl: ogAttachment.thumbUrl, + authorName: ogAttachment.authorName, + authorLink: ogAttachment.authorLink, + assetUrl: ogAttachment.assetUrl, + ogScrapeUrl: ogAttachment.ogScrapeUrl, + uploadState: const UploadState.success(), + ); + ///The attachment type based on the URL resource. This can be: audio, ///image or video final String? type; @@ -229,6 +245,33 @@ class Attachment extends Equatable { extraData: extraData ?? this.extraData, ); + Attachment merge(Attachment? other) { + if (other == null) return this; + return copyWith( + type: other.type, + titleLink: other.titleLink, + title: other.title, + thumbUrl: other.thumbUrl, + text: other.text, + pretext: other.pretext, + ogScrapeUrl: other.ogScrapeUrl, + imageUrl: other.imageUrl, + footerIcon: other.footerIcon, + footer: other.footer, + fields: other.fields, + fallback: other.fallback, + color: other.color, + authorName: other.authorName, + authorLink: other.authorLink, + authorIcon: other.authorIcon, + assetUrl: other.assetUrl, + actions: other.actions, + file: other.file, + uploadState: other.uploadState, + extraData: other.extraData, + ); + } + @override List get props => [ id, diff --git a/packages/stream_chat_flutter/lib/src/message_input/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/message_input.dart index 2ba5c038..46072348 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/message_input.dart @@ -354,6 +354,7 @@ class MessageInputState extends State final _imagePicker = ImagePicker(); late final _focusNode = widget.focusNode ?? FocusNode(); bool _inputEnabled = true; + bool get _commandEnabled => _effectiveController.value.command != null; bool _showCommandsOverlay = false; bool _showMentionsOverlay = false; @@ -371,6 +372,7 @@ class MessageInputState extends State _effectiveController.value.status != MessageSendingStatus.sending; RestorableMessageInputController? _controller; + MessageInputController get _effectiveController => widget.messageInputController ?? _controller!.value; @@ -517,6 +519,14 @@ class MessageInputState extends State ), ], ), + ) + else if (_ogAttachment != null) + OGAttachmentPreview( + attachment: _ogAttachment!, + onDismissPreviewPressed: () { + setState(() => _ogAttachment = null); + _focusNode.unfocus(); + }, ), Padding( padding: const EdgeInsets.symmetric(vertical: 8), @@ -538,7 +548,7 @@ class MessageInputState extends State ), ), ); - if (_isEditing) { + if (!_isEditing) { child = Material( elevation: 8, child: child, @@ -896,6 +906,7 @@ class MessageInputState extends State _actionsShrunk = value.isNotEmpty && actionsLength > 1; }); + _checkContainsUrlDebounced.call([value, context]); _checkCommands(value, context); _checkMentions(value, context); _checkEmoji(value, context); @@ -918,14 +929,52 @@ class MessageInputState extends State return context.translations.writeAMessageLabel; } - void _checkEmoji(String s, BuildContext context) { - if (s.isNotEmpty && + Attachment? _ogAttachment; + String? _lastSearchedContainsUrlText; + CancelableOperation? _enrichUrlOperation; + + late final _checkContainsUrlDebounced = debounce( + (String value, BuildContext context) async { + // Cancel the previous operation if it's still running + _enrichUrlOperation?.cancel(); + + // If the text is same as the last time, don't do anything + if (_lastSearchedContainsUrlText == value) return; + _lastSearchedContainsUrlText = value; + + final regex = + RegExp(r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+'); + final matchedUrls = regex.allMatches(value); + if (matchedUrls.isEmpty) return; + + final firstMatchedUrl = matchedUrls.first.group(0)!; + + // If the parsed url matches the ogAttachment url, don't do anything + if (_ogAttachment?.titleLink == firstMatchedUrl) return; + + final client = StreamChat.of(context).client; + + _enrichUrlOperation = CancelableOperation.fromFuture( + client.enrichUrl(firstMatchedUrl).then((ogAttachment) { + final attachment = Attachment.fromOGAttachment(ogAttachment); + setState(() => _ogAttachment = attachment); + }).onError((error, stackTrace) { + // Reset the ogAttachment if there was an error + setState(() => _ogAttachment = null); + if (error != null) { + widget.onError?.call(error, stackTrace); + } + }), + ); + }, + const Duration(seconds: 1), + ); + + void _checkEmoji(String value, BuildContext context) { + if (value.isNotEmpty && _effectiveController.baseOffset > 0 && _effectiveController.text - .substring( - 0, - _effectiveController.baseOffset, - ) + .substring(0, _effectiveController.baseOffset) .contains(':')) { final textToSelection = _effectiveController.text.substring( 0, @@ -941,14 +990,11 @@ class MessageInputState extends State } } - void _checkMentions(String s, BuildContext context) { - if (s.isNotEmpty && + void _checkMentions(String value, BuildContext context) { + if (value.isNotEmpty && _effectiveController.baseOffset > 0 && _effectiveController.text - .substring( - 0, - _effectiveController.baseOffset, - ) + .substring(0, _effectiveController.baseOffset) .split(' ') .last .contains('@')) { @@ -964,11 +1010,11 @@ class MessageInputState extends State } } - void _checkCommands(String s, BuildContext context) { - if (s.startsWith('/')) { + void _checkCommands(String value, BuildContext context) { + if (value.startsWith('/')) { final allCommands = StreamChannel.of(context).channel.config?.commands; final command = - allCommands?.firstWhereOrNull((it) => it.name == s.substring(1)); + allCommands?.firstWhereOrNull((it) => it.name == value.substring(1)); if (command != null) { return _setCommand(command); } else if (!_showCommandsOverlay) { @@ -1030,10 +1076,7 @@ class MessageInputState extends State } final splits = _effectiveController.text - .substring( - 0, - _effectiveController.selectionStart, - ) + .substring(0, _effectiveController.selectionStart) .split('@'); final query = splits.last.toLowerCase(); @@ -1082,10 +1125,7 @@ class MessageInputState extends State } final splits = _effectiveController.text - .substring( - 0, - _effectiveController.baseOffset, - ) + .substring(0, _effectiveController.baseOffset) .split(':'); final query = splits.last.toLowerCase(); @@ -1222,11 +1262,7 @@ class MessageInputState extends State focusElevation: 0, hoverElevation: 0, onPressed: () { - _effectiveController.value = _effectiveController.value.copyWith( - attachments: _effectiveController.attachments - .where((it) => it.id != attachment.id) - .toList(), - ); + _effectiveController.removeAttachmentById(attachment.id); }, fillColor: _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5), @@ -1572,10 +1608,19 @@ class MessageInputState extends State Future sendMessage() async { var message = _effectiveController.value; + // Add ogAttachment if present + final skipEnrichUrl = _ogAttachment == null; + if (!skipEnrichUrl) { + message = message.copyWith( + attachments: [...message.attachments, _ogAttachment!], + ); + } + var shouldKeepFocus = widget.shouldKeepFocusAfterMessage; shouldKeepFocus ??= !_commandEnabled; + _ogAttachment = null; _effectiveController.reset(); if (widget.preMessageSending != null) { @@ -1590,10 +1635,16 @@ class MessageInputState extends State try { Future sendingFuture; - if (!_isEditing) { - sendingFuture = channel.sendMessage(message); + if (_isEditing) { + sendingFuture = channel.updateMessage( + message, + skipEnrichUrl: skipEnrichUrl, + ); } else { - sendingFuture = channel.updateMessage(message); + sendingFuture = channel.sendMessage( + message, + skipEnrichUrl: skipEnrichUrl, + ); } if (shouldKeepFocus) { @@ -1702,3 +1753,73 @@ class MessageInputState extends State super.didChangeDependencies(); } } + +class OGAttachmentPreview extends StatelessWidget { + const OGAttachmentPreview({ + Key? key, + required this.attachment, + this.onDismissPreviewPressed, + }) : super(key: key); + + final Attachment attachment; + final VoidCallback? onDismissPreviewPressed; + + @override + Widget build(BuildContext context) { + final chatTheme = StreamChatTheme.of(context); + final textTheme = chatTheme.textTheme; + final colorTheme = chatTheme.colorTheme; + + final attachmentTitle = attachment.title; + final attachmentText = attachment.text; + + return Row( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + Icons.link, + color: colorTheme.accentPrimary, + ), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: colorTheme.accentPrimary, + width: 2, + ), + ), + ), + padding: const EdgeInsets.only(left: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (attachmentTitle != null) + Text( + attachmentTitle.trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.body.copyWith(fontWeight: FontWeight.w700), + ), + if (attachmentText != null) + Text( + attachmentText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.body.copyWith(fontWeight: FontWeight.w400), + ), + ], + ), + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: StreamSvgIcon.closeSmall(), + onPressed: onDismissPreviewPressed, + ), + ], + ); + } +}