diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index db39628b..1693aeff 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -505,6 +505,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 @@ -552,6 +553,7 @@ class Channel { id!, type, skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, ); state!.addMessage(response.message); if (cooldown > 0) cooldownStartedAt = DateTime.now(); @@ -568,7 +570,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 @@ -606,7 +611,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, @@ -636,12 +644,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/test/src/core/api/message_api_test.dart b/packages/stream_chat/test/src/core/api/message_api_test.dart index e89491a2..ed7fefd6 100644 --- a/packages/stream_chat/test/src/core/api/message_api_test.dart +++ b/packages/stream_chat/test/src/core/api/message_api_test.dart @@ -32,6 +32,7 @@ void main() { data: { 'message': message, 'skip_push': false, + 'skip_enrich_url': false, }, )).thenAnswer((_) async => successResponse(path, data: { 'message': message.toJson(), @@ -58,6 +59,7 @@ void main() { data: { 'message': message, 'skip_push': true, + 'skip_enrich_url': false, }, )).thenAnswer((_) async => successResponse(path, data: { 'message': message.toJson(), @@ -137,7 +139,10 @@ void main() { when(() => client.post( path, - data: {'message': message}, + data: { + 'message': message, + 'skip_enrich_url': false, + }, )).thenAnswer( (_) async => successResponse(path, data: {'message': message.toJson()}), ); @@ -162,7 +167,11 @@ void main() { when(() => client.put( path, - data: {'set': set, 'unset': unset}, + data: { + 'set': set, + 'unset': unset, + 'skip_enrich_url': false, + }, )).thenAnswer( (_) async => successResponse(path, data: {'message': message.toJson()}), ); @@ -180,7 +189,11 @@ void main() { verify(() => client.put( path, - data: {'set': set, 'unset': unset}, + data: { + 'set': set, + 'unset': unset, + 'skip_enrich_url': false, + }, )).called(1); verifyNoMoreInteractions(client); }); diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 02f21d3a..130e1b8a 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -14,6 +14,10 @@ - Videos can now be auto-played in `FullScreenMedia` +🔄 Changed + +- Add `didUpdateWidget` override in `MessageInput` widget to handle changes to `focusNode`. + ## 3.3.2 - Updated `stream_chat_flutter_core` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat_flutter_core/changelog). 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 7ac6840e..53e75ad8 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 @@ -13,6 +13,7 @@ import 'package:stream_chat_flutter/src/commands_overlay.dart'; import 'package:stream_chat_flutter/src/emoji/emoji.dart'; import 'package:stream_chat_flutter/src/emoji_overlay.dart'; import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/src/message_input/tld.dart'; import 'package:stream_chat_flutter/src/multi_overlay.dart'; import 'package:stream_chat_flutter/src/quoted_message_widget.dart'; import 'package:stream_chat_flutter/src/user_mentions_overlay.dart'; @@ -335,25 +336,15 @@ class MessageInput extends StatefulWidget { @override MessageInputState createState() => MessageInputState(); - - /// Use this method to get the current [StreamChatState] instance - static MessageInputState of(BuildContext context) { - MessageInputState? messageInputState; - messageInputState = context.findAncestorStateOfType(); - assert( - messageInputState != null, - 'You must have a MessageInput widget as ancestor of your widget tree', - ); - return messageInputState!; - } } /// State of [MessageInput] class MessageInputState extends State with RestorationMixin { final _imagePicker = ImagePicker(); - late final _focusNode = widget.focusNode ?? FocusNode(); + late FocusNode _focusNode = widget.focusNode ?? FocusNode(); bool _inputEnabled = true; + bool get _commandEnabled => _effectiveController.value.command != null; bool _showCommandsOverlay = false; bool _showMentionsOverlay = false; @@ -371,6 +362,7 @@ class MessageInputState extends State _effectiveController.value.status != MessageSendingStatus.sending; RestorableMessageInputController? _controller; + MessageInputController get _effectiveController => widget.messageInputController ?? _controller!.value; @@ -398,11 +390,7 @@ class MessageInputState extends State if (widget.messageInputController == null) { _createLocalController(); } else { - _effectiveController.textEditingController - .removeListener(_onChangedDebounced); - _effectiveController.textEditingController - .addListener(_onChangedDebounced); - if (!_isEditing && _timeOut <= 0) _startSlowMode(); + _initialiseEffectiveController(); } _focusNode.addListener(_focusNodeListener); } @@ -418,6 +406,14 @@ class MessageInputState extends State unregisterFromRestoration(_controller!); _controller!.dispose(); _controller = null; + _initialiseEffectiveController(); + } + + // Update _focusNode + if (widget.focusNode != null && oldWidget.focusNode != widget.focusNode) { + _focusNode.removeListener(_focusNodeListener); + _focusNode = widget.focusNode!; + _focusNode.addListener(_focusNodeListener); } } @@ -440,6 +436,13 @@ class MessageInputState extends State int _timeOut = 0; Timer? _slowModeTimer; + void _initialiseEffectiveController() { + _effectiveController.textEditingController + .removeListener(_onChangedDebounced); + _effectiveController.textEditingController.addListener(_onChangedDebounced); + if (!_isEditing && _timeOut <= 0) _startSlowMode(); + } + void _startSlowMode() { if (!mounted) { return; @@ -487,110 +490,119 @@ class MessageInputState extends State ); } return MessageValueListenableBuilder( - valueListenable: _effectiveController, - builder: (context, value, _) { - Widget child = DecoratedBox( - decoration: BoxDecoration( - color: _messageInputTheme.inputBackgroundColor, - ), - child: SafeArea( - child: GestureDetector( - onPanUpdate: (details) { - if (details.delta.dy > 0) { - _focusNode.unfocus(); - if (_openFilePickerSection) { - setState(() { - _openFilePickerSection = false; - }); + valueListenable: _effectiveController, + builder: (context, value, _) { + Widget child = DecoratedBox( + decoration: BoxDecoration( + color: _messageInputTheme.inputBackgroundColor, + ), + child: SafeArea( + child: GestureDetector( + onPanUpdate: (details) { + if (details.delta.dy > 0) { + _focusNode.unfocus(); + if (_openFilePickerSection) { + setState(() { + _openFilePickerSection = false; + }); + } } - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_hasQuotedMessage) - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: StreamSvgIcon.reply( - color: _streamChatTheme.colorTheme.disabled, + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_hasQuotedMessage) + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: StreamSvgIcon.reply( + color: _streamChatTheme.colorTheme.disabled, + ), ), - ), - Text( - context.translations.replyToMessageLabel, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - IconButton( - visualDensity: VisualDensity.compact, - icon: StreamSvgIcon.closeSmall(), - onPressed: () { - _effectiveController.clearQuotedMessage(); - _focusNode.unfocus(); - }, - ), - ], + Text( + context.translations.replyToMessageLabel, + style: + const TextStyle(fontWeight: FontWeight.bold), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: StreamSvgIcon.closeSmall(), + onPressed: () { + _effectiveController.clearQuotedMessage(); + _focusNode.unfocus(); + }, + ), + ], + ), + ) + else if (_effectiveController.ogAttachment != null) + OGAttachmentPreview( + attachment: _effectiveController.ogAttachment!, + onDismissPreviewPressed: () { + _effectiveController.clearOGAttachment(); + _focusNode.unfocus(); + }, ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: _buildTextField(context), - ), - if (_effectiveController.value.parentId != null && - !widget.hideSendAsDm) Padding( - padding: const EdgeInsets.only( - right: 12, - left: 12, - bottom: 12, - ), - child: _buildDmCheckbox(), + padding: const EdgeInsets.symmetric(vertical: 8), + child: _buildTextField(context), ), - _buildFilePickerSection(), - ], + if (_effectiveController.value.parentId != null && + !widget.hideSendAsDm) + Padding( + padding: const EdgeInsets.only( + right: 12, + left: 12, + bottom: 12, + ), + child: _buildDmCheckbox(), + ), + _buildFilePickerSection(), + ], + ), ), ), - ), - ); - if (!_isEditing) { - child = Material( - elevation: 8, + ); + if (!_isEditing) { + child = Material( + elevation: 8, + child: child, + ); + } + return MultiOverlay( + childAnchor: Alignment.topCenter, + overlayAnchor: Alignment.bottomCenter, + overlayOptions: [ + OverlayOptions( + visible: _showCommandsOverlay, + widget: _buildCommandsOverlayEntry(), + ), + OverlayOptions( + visible: _focusNode.hasFocus && + _effectiveController.text.isNotEmpty && + _effectiveController.baseOffset > 0 && + _effectiveController.text + .substring( + 0, + _effectiveController.baseOffset, + ) + .contains(':'), + widget: _buildEmojiOverlay(), + ), + OverlayOptions( + visible: _showMentionsOverlay, + widget: _buildMentionsOverlayEntry(), + ), + ...widget.customOverlays, + ], child: child, ); - } - return MultiOverlay( - childAnchor: Alignment.topCenter, - overlayAnchor: Alignment.bottomCenter, - overlayOptions: [ - OverlayOptions( - visible: _showCommandsOverlay, - widget: _buildCommandsOverlayEntry(), - ), - OverlayOptions( - visible: _focusNode.hasFocus && - _effectiveController.text.isNotEmpty && - _effectiveController.baseOffset > 0 && - _effectiveController.text - .substring( - 0, - _effectiveController.baseOffset, - ) - .contains(':'), - widget: _buildEmojiOverlay(), - ), - OverlayOptions( - visible: _showMentionsOverlay, - widget: _buildMentionsOverlayEntry(), - ), - ...widget.customOverlays, - ], - child: child, - ); - }, - ); + }, + ); } Flex _buildTextField(BuildContext context) => Flex( @@ -917,6 +929,7 @@ class MessageInputState extends State _actionsShrunk = value.isNotEmpty && actionsLength > 1; }); + _checkContainsUrl(value, context); _checkCommands(value, context); _checkMentions(value, context); _checkEmoji(value, context); @@ -939,14 +952,75 @@ class MessageInputState extends State return context.translations.writeAMessageLabel; } - void _checkEmoji(String s, BuildContext context) { - if (s.isNotEmpty && + String? _lastSearchedContainsUrlText; + CancelableOperation? _enrichUrlOperation; + final _urlRegex = RegExp( + r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+', + ); + + void _checkContainsUrl(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 matchedUrls = _urlRegex.allMatches(value).toList() + ..removeWhere((it) => it.group(0)?.split('.').last.isValidTLD() == false); + + // Reset the og attachment if the text doesn't contain any url + if (matchedUrls.isEmpty) { + _effectiveController + ..text = value + ..clearOGAttachment(); + return; + } + + final firstMatchedUrl = matchedUrls.first.group(0)!; + + // If the parsed url matches the ogAttachment url, don't do anything + if (_effectiveController.ogAttachment?.titleLink == firstMatchedUrl) { + return; + } + + final client = StreamChat.of(context).client; + + _enrichUrlOperation = CancelableOperation.fromFuture( + _enrichUrl(firstMatchedUrl, client), + ).then( + (ogAttachment) { + final attachment = Attachment.fromOGAttachment(ogAttachment); + _effectiveController.setOGAttachment(attachment); + }, + onError: (error, stackTrace) { + // Reset the ogAttachment if there was an error + _effectiveController.clearOGAttachment(); + widget.onError?.call(error, stackTrace); + }, + ); + } + + final _ogAttachmentCache = {}; + + Future _enrichUrl( + String url, + StreamChatClient client, + ) async { + var response = _ogAttachmentCache[url]; + if (response == null) { + final client = StreamChat.of(context).client; + response = await client.enrichUrl(url); + _ogAttachmentCache[url] = response; + } + return response; + } + + 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, @@ -962,14 +1036,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('@')) { @@ -985,11 +1056,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) { @@ -1051,10 +1122,7 @@ class MessageInputState extends State } final splits = _effectiveController.text - .substring( - 0, - _effectiveController.selectionStart, - ) + .substring(0, _effectiveController.selectionStart) .split('@'); final query = splits.last.toLowerCase(); @@ -1103,10 +1171,7 @@ class MessageInputState extends State } final splits = _effectiveController.text - .substring( - 0, - _effectiveController.baseOffset, - ) + .substring(0, _effectiveController.baseOffset) .split(':'); final query = splits.last.toLowerCase(); @@ -1154,11 +1219,14 @@ class MessageInputState extends State } Widget _buildAttachments() { - if (_effectiveController.attachments.isEmpty) return const Offstage(); - final fileAttachments = _effectiveController.attachments + final nonOGAttachments = _effectiveController.attachments.where( + (it) => it.titleLink == null, + ); + if (nonOGAttachments.isEmpty) return const Offstage(); + final fileAttachments = nonOGAttachments .where((it) => it.type == 'file') .toList(growable: false); - final remainingAttachments = _effectiveController.attachments + final remainingAttachments = nonOGAttachments .where((it) => it.type != 'file') .toList(growable: false); return Column( @@ -1243,11 +1311,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), @@ -1449,14 +1513,6 @@ class MessageInputState extends State } } - /// Add an attachment to the sending message - /// Use this to add custom type attachments - /// - /// Note: Only meant to be used from outside the state. - void addAttachment(Attachment attachment) { - _addAttachments([attachment]); - } - /// Adds an attachment to the [messageInputController.attachments] map void _addAttachments(Iterable attachments) { final limit = widget.attachmentLimit; @@ -1591,6 +1647,8 @@ class MessageInputState extends State /// Sends the current message Future sendMessage() async { + final skipEnrichUrl = _effectiveController.ogAttachment == null; + var message = _effectiveController.value; var shouldKeepFocus = widget.shouldKeepFocusAfterMessage; @@ -1611,10 +1669,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) { @@ -1723,3 +1787,78 @@ class MessageInputState extends State super.didChangeDependencies(); } } + +/// Preview of an Open Graph attachment. +class OGAttachmentPreview extends StatelessWidget { + /// Returns a new instance of [OGAttachmentPreview] + const OGAttachmentPreview({ + Key? key, + required this.attachment, + this.onDismissPreviewPressed, + }) : super(key: key); + + /// The attachment to be rendered. + final Attachment attachment; + + /// Called when the dismiss button is pressed. + 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), + 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, + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart b/packages/stream_chat_flutter/lib/src/message_input/message_input_controller.dart similarity index 79% rename from packages/stream_chat_flutter_core/lib/src/message_input_controller.dart rename to packages/stream_chat_flutter/lib/src/message_input/message_input_controller.dart index 29bd2db2..185c875c 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/message_input_controller.dart @@ -1,8 +1,8 @@ import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// A value listenable builder related to a [Message]. /// @@ -17,33 +17,46 @@ class MessageInputController extends ValueNotifier { /// message. factory MessageInputController({ Message? message, + Map? textPatternStyle, }) => MessageInputController._( initialMessage: message ?? Message(), + textPatternStyle: textPatternStyle, ); /// Creates a controller for an editable text field from an initial [text]. - factory MessageInputController.fromText(String? text) => + factory MessageInputController.fromText( + String? text, { + Map? textPatternStyle, + }) => MessageInputController._( initialMessage: Message(text: text), + textPatternStyle: textPatternStyle, ); /// Creates a controller for an editable text field from initial /// [attachments]. factory MessageInputController.fromAttachments( - List attachments, - ) => + List attachments, { + Map? textPatternStyle, + }) => MessageInputController._( initialMessage: Message(attachments: attachments), + textPatternStyle: textPatternStyle, ); MessageInputController._({ required Message initialMessage, - }) : _textEditingController = - TextEditingController.fromValue(TextEditingValue( - text: initialMessage.text ?? '', - composing: TextRange.collapsed(initialMessage.text?.length ?? 0), - )), + Map? textPatternStyle, + }) : _textEditingController = MessageTextFieldController.fromValue( + initialMessage.text == null + ? const TextEditingValue() + : TextEditingValue( + text: initialMessage.text!, + composing: TextRange.collapsed(initialMessage.text!.length), + ), + textPatternStyle: textPatternStyle, + ), _initialMessage = initialMessage, super(initialMessage) { addListener(_textEditingSyncer); @@ -52,10 +65,7 @@ class MessageInputController extends ValueNotifier { void _textEditingSyncer() { final cleanText = value.command == null ? value.text - : value.text?.replaceFirst( - '/${value.command} ', - '', - ); + : value.text?.replaceFirst('/${value.command} ', ''); if (cleanText != _textEditingController.text) { final previousOffset = _textEditingController.value.selection.start; @@ -73,8 +83,9 @@ class MessageInputController extends ValueNotifier { Message get message => value; /// Returns the controller of the text field linked to this controller. - TextEditingController get textEditingController => _textEditingController; - final TextEditingController _textEditingController; + MessageTextFieldController get textEditingController => + _textEditingController; + final MessageTextFieldController _textEditingController; /// Returns the text of the message. String get text => _textEditingController.text; @@ -174,6 +185,30 @@ class MessageInputController extends ValueNotifier { attachments = []; } + // Only used to store the value locally in order to remove it if we call + // [clearOGAttachment] or [setOGAttachment] again. + Attachment? _ogAttachment; + + /// Returns the og attachment of the message if set + Attachment? get ogAttachment => + attachments.firstWhereOrNull((it) => it.id == _ogAttachment?.id); + + /// Sets the og attachment in the message. + void setOGAttachment(Attachment attachment) { + attachments = [...attachments] + ..remove(_ogAttachment) + ..insert(0, attachment); + _ogAttachment = attachment; + } + + /// Removes the og attachment. + void clearOGAttachment() { + if (_ogAttachment != null) { + removeAttachment(_ogAttachment!); + } + _ogAttachment = null; + } + /// Returns the list of mentioned users in the message. List get mentionedUsers => value.mentionedUsers; @@ -220,9 +255,8 @@ class MessageInputController extends ValueNotifier { /// Sets the [value] to the initial [Message] value. void reset({bool resetId = true}) { if (resetId) { - _initialMessage = _initialMessage.copyWith( - id: const Uuid().v4(), - ); + final newId = const Uuid().v4(); + _initialMessage = _initialMessage.copyWith(id: newId); } value = _initialMessage; } diff --git a/packages/stream_chat_flutter/lib/src/message_input/message_text_field_controller.dart b/packages/stream_chat_flutter/lib/src/message_input/message_text_field_controller.dart new file mode 100644 index 00000000..4a6c3708 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/message_text_field_controller.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/tld.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A function that takes a [BuildContext] and returns a [TextStyle]. +typedef TextStyleBuilder = TextStyle? Function( + BuildContext context, + String text, +); + +/// Controller for the [StreamTextField] widget. +class MessageTextFieldController extends TextEditingController { + /// Returns a new MessageTextFieldController + MessageTextFieldController({ + String? text, + this.textPatternStyle, + }) : super(text: text); + + /// Returns a new MessageTextFieldController with the given text [value]. + MessageTextFieldController.fromValue( + TextEditingValue? value, { + this.textPatternStyle, + }) : super.fromValue(value); + + /// A map of style to apply to the text matching the RegExp patterns. + final Map? textPatternStyle; + + /// Builds a [TextSpan] from the current text, + /// highlighting the matches for [textPatternStyle]. + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + final pattern = textPatternStyle ?? + { + RegExp(r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+'): + (context, text) { + if (!text.split('.').last.isValidTLD()) return null; + return TextStyle( + color: MessageInputTheme.of(context).linkHighlightColor, + ); + }, + }; + if (pattern.isEmpty) { + return super.buildTextSpan( + context: context, + style: style, + withComposing: withComposing, + ); + } + + return TextSpan(text: text, style: style).splitMapJoin( + RegExp(pattern.keys.map((it) => it.pattern).join('|')), + onMatch: (match) { + final text = match[0]!; + final key = pattern.keys.firstWhere((it) => it.hasMatch(text)); + return TextSpan( + text: text, + style: pattern[key]?.call( + context, + text, + ), + ); + }, + ); + } +} + +extension _TextSpanX on TextSpan { + TextSpan splitMapJoin( + Pattern pattern, { + TextSpan Function(Match)? onMatch, + TextSpan Function(TextSpan)? onNonMatch, + }) { + final children = []; + + toPlainText().splitMapJoin( + pattern, + onMatch: (match) { + final span = TextSpan(text: match.group(0), style: style); + final updated = onMatch?.call(match); + children.add(updated ?? span); + return span.toPlainText(); + }, + onNonMatch: (text) { + final span = TextSpan(text: text, style: style); + final updatedSpan = onNonMatch?.call(span); + children.add(updatedSpan ?? span); + return span.toPlainText(); + }, + ); + + return TextSpan(style: style, children: children); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart index 61466aaf..8e4a34eb 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart @@ -8,7 +8,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; export 'package:flutter/services.dart' show diff --git a/packages/stream_chat_flutter/lib/src/message_input/tld.dart b/packages/stream_chat_flutter/lib/src/message_input/tld.dart new file mode 100644 index 00000000..2bfa5257 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/tld.dart @@ -0,0 +1,1553 @@ +/// Extension on String adding utilities checking TLD validity. +extension TLDString on String { + /// Returns true if the string is a valid TLD. + bool isValidTLD() => + isNotEmpty && + tlds.containsKey(this[0].toUpperCase()) && + tlds[this[0].toUpperCase()]!.contains(toUpperCase()); +} + +/// List of valid TLDs. +/// https://data.iana.org/TLD/tlds-alpha-by-domain.txt +const tlds = { + 'A': [ + 'AAA', + 'AARP', + 'ABARTH', + 'ABB', + 'ABBOTT', + 'ABBVIE', + 'ABC', + 'ABLE', + 'ABOGADO', + 'ABUDHABI', + 'AC', + 'ACADEMY', + 'ACCENTURE', + 'ACCOUNTANT', + 'ACCOUNTANTS', + 'ACO', + 'ACTOR', + 'AD', + 'ADAC', + 'ADS', + 'ADULT', + 'AE', + 'AEG', + 'AERO', + 'AETNA', + 'AF', + 'AFL', + 'AFRICA', + 'AG', + 'AGAKHAN', + 'AGENCY', + 'AI', + 'AIG', + 'AIRBUS', + 'AIRFORCE', + 'AIRTEL', + 'AKDN', + 'AL', + 'ALFAROMEO', + 'ALIBABA', + 'ALIPAY', + 'ALLFINANZ', + 'ALLSTATE', + 'ALLY', + 'ALSACE', + 'ALSTOM', + 'AM', + 'AMAZON', + 'AMERICANEXPRESS', + 'AMERICANFAMILY', + 'AMEX', + 'AMFAM', + 'AMICA', + 'AMSTERDAM', + 'ANALYTICS', + 'ANDROID', + 'ANQUAN', + 'ANZ', + 'AO', + 'AOL', + 'APARTMENTS', + 'APP', + 'APPLE', + 'AQ', + 'AQUARELLE', + 'AR', + 'ARAB', + 'ARAMCO', + 'ARCHI', + 'ARMY', + 'ARPA', + 'ART', + 'ARTE', + 'AS', + 'ASDA', + 'ASIA', + 'ASSOCIATES', + 'AT', + 'ATHLETA', + 'ATTORNEY', + 'AU', + 'AUCTION', + 'AUDI', + 'AUDIBLE', + 'AUDIO', + 'AUSPOST', + 'AUTHOR', + 'AUTO', + 'AUTOS', + 'AVIANCA', + 'AW', + 'AWS', + 'AX', + 'AXA', + 'AZ', + 'AZURE', + ], + 'B': [ + 'BA', + 'BABY', + 'BAIDU', + 'BANAMEX', + 'BANANAREPUBLIC', + 'BAND', + 'BANK', + 'BAR', + 'BARCELONA', + 'BARCLAYCARD', + 'BARCLAYS', + 'BAREFOOT', + 'BARGAINS', + 'BASEBALL', + 'BASKETBALL', + 'BAUHAUS', + 'BAYERN', + 'BB', + 'BBC', + 'BBT', + 'BBVA', + 'BCG', + 'BCN', + 'BD', + 'BE', + 'BEATS', + 'BEAUTY', + 'BEER', + 'BENTLEY', + 'BERLIN', + 'BEST', + 'BESTBUY', + 'BET', + 'BF', + 'BG', + 'BH', + 'BHARTI', + 'BI', + 'BIBLE', + 'BID', + 'BIKE', + 'BING', + 'BINGO', + 'BIO', + 'BIZ', + 'BJ', + 'BLACK', + 'BLACKFRIDAY', + 'BLOCKBUSTER', + 'BLOG', + 'BLOOMBERG', + 'BLUE', + 'BM', + 'BMS', + 'BMW', + 'BN', + 'BNPPARIBAS', + 'BO', + 'BOATS', + 'BOEHRINGER', + 'BOFA', + 'BOM', + 'BOND', + 'BOO', + 'BOOK', + 'BOOKING', + 'BOSCH', + 'BOSTIK', + 'BOSTON', + 'BOT', + 'BOUTIQUE', + 'BOX', + 'BR', + 'BRADESCO', + 'BRIDGESTONE', + 'BROADWAY', + 'BROKER', + 'BROTHER', + 'BRUSSELS', + 'BS', + 'BT', + 'BUDAPEST', + 'BUGATTI', + 'BUILD', + 'BUILDERS', + 'BUSINESS', + 'BUY', + 'BUZZ', + 'BV', + 'BW', + 'BY', + 'BZ', + 'BZH', + ], + 'C': [ + 'CA', + 'CAB', + 'CAFE', + 'CAL', + 'CALL', + 'CALVINKLEIN', + 'CAM', + 'CAMERA', + 'CAMP', + 'CANCERRESEARCH', + 'CANON', + 'CAPETOWN', + 'CAPITAL', + 'CAPITALONE', + 'CAR', + 'CARAVAN', + 'CARDS', + 'CARE', + 'CAREER', + 'CAREERS', + 'CARS', + 'CASA', + 'CASE', + 'CASH', + 'CASINO', + 'CAT', + 'CATERING', + 'CATHOLIC', + 'CBA', + 'CBN', + 'CBRE', + 'CBS', + 'CC', + 'CD', + 'CENTER', + 'CEO', + 'CERN', + 'CF', + 'CFA', + 'CFD', + 'CG', + 'CH', + 'CHANEL', + 'CHANNEL', + 'CHARITY', + 'CHASE', + 'CHAT', + 'CHEAP', + 'CHINTAI', + 'CHRISTMAS', + 'CHROME', + 'CHURCH', + 'CI', + 'CIPRIANI', + 'CIRCLE', + 'CISCO', + 'CITADEL', + 'CITI', + 'CITIC', + 'CITY', + 'CITYEATS', + 'CK', + 'CL', + 'CLAIMS', + 'CLEANING', + 'CLICK', + 'CLINIC', + 'CLINIQUE', + 'CLOTHING', + 'CLOUD', + 'CLUB', + 'CLUBMED', + 'CM', + 'CN', + 'CO', + 'COACH', + 'CODES', + 'COFFEE', + 'COLLEGE', + 'COLOGNE', + 'COM', + 'COMCAST', + 'COMMBANK', + 'COMMUNITY', + 'COMPANY', + 'COMPARE', + 'COMPUTER', + 'COMSEC', + 'CONDOS', + 'CONSTRUCTION', + 'CONSULTING', + 'CONTACT', + 'CONTRACTORS', + 'COOKING', + 'COOKINGCHANNEL', + 'COOL', + 'COOP', + 'CORSICA', + 'COUNTRY', + 'COUPON', + 'COUPONS', + 'COURSES', + 'CPA', + 'CR', + 'CREDIT', + 'CREDITCARD', + 'CREDITUNION', + 'CRICKET', + 'CROWN', + 'CRS', + 'CRUISE', + 'CRUISES', + 'CSC', + 'CU', + 'CUISINELLA', + 'CV', + 'CW', + 'CX', + 'CY', + 'CYMRU', + 'CYOU', + 'CZ', + ], + 'D': [ + 'DABUR', + 'DAD', + 'DANCE', + 'DATA', + 'DATE', + 'DATING', + 'DATSUN', + 'DAY', + 'DCLK', + 'DDS', + 'DE', + 'DEAL', + 'DEALER', + 'DEALS', + 'DEGREE', + 'DELIVERY', + 'DELL', + 'DELOITTE', + 'DELTA', + 'DEMOCRAT', + 'DENTAL', + 'DENTIST', + 'DESI', + 'DESIGN', + 'DEV', + 'DHL', + 'DIAMONDS', + 'DIET', + 'DIGITAL', + 'DIRECT', + 'DIRECTORY', + 'DISCOUNT', + 'DISCOVER', + 'DISH', + 'DIY', + 'DJ', + 'DK', + 'DM', + 'DNP', + 'DO', + 'DOCS', + 'DOCTOR', + 'DOG', + 'DOMAINS', + 'DOT', + 'DOWNLOAD', + 'DRIVE', + 'DTV', + 'DUBAI', + 'DUNLOP', + 'DUPONT', + 'DURBAN', + 'DVAG', + 'DVR', + 'DZ', + ], + 'E': [ + 'EARTH', + 'EAT', + 'EC', + 'ECO', + 'EDEKA', + 'EDU', + 'EDUCATION', + 'EE', + 'EG', + 'EMAIL', + 'EMERCK', + 'ENERGY', + 'ENGINEER', + 'ENGINEERING', + 'ENTERPRISES', + 'EPSON', + 'EQUIPMENT', + 'ER', + 'ERICSSON', + 'ERNI', + 'ES', + 'ESQ', + 'ESTATE', + 'ET', + 'ETISALAT', + 'EU', + 'EUROVISION', + 'EUS', + 'EVENTS', + 'EXCHANGE', + 'EXPERT', + 'EXPOSED', + 'EXPRESS', + 'EXTRASPACE', + ], + 'F': [ + 'FAGE', + 'FAIL', + 'FAIRWINDS', + 'FAITH', + 'FAMILY', + 'FAN', + 'FANS', + 'FARM', + 'FARMERS', + 'FASHION', + 'FAST', + 'FEDEX', + 'FEEDBACK', + 'FERRARI', + 'FERRERO', + 'FI', + 'FIAT', + 'FIDELITY', + 'FIDO', + 'FILM', + 'FINAL', + 'FINANCE', + 'FINANCIAL', + 'FIRE', + 'FIRESTONE', + 'FIRMDALE', + 'FISH', + 'FISHING', + 'FIT', + 'FITNESS', + 'FJ', + 'FK', + 'FLICKR', + 'FLIGHTS', + 'FLIR', + 'FLORIST', + 'FLOWERS', + 'FLY', + 'FM', + 'FO', + 'FOO', + 'FOOD', + 'FOODNETWORK', + 'FOOTBALL', + 'FORD', + 'FOREX', + 'FORSALE', + 'FORUM', + 'FOUNDATION', + 'FOX', + 'FR', + 'FREE', + 'FRESENIUS', + 'FRL', + 'FROGANS', + 'FRONTDOOR', + 'FRONTIER', + 'FTR', + 'FUJITSU', + 'FUN', + 'FUND', + 'FURNITURE', + 'FUTBOL', + 'FYI', + ], + 'G': [ + 'GA', + 'GAL', + 'GALLERY', + 'GALLO', + 'GALLUP', + 'GAME', + 'GAMES', + 'GAP', + 'GARDEN', + 'GAY', + 'GB', + 'GBIZ', + 'GD', + 'GDN', + 'GE', + 'GEA', + 'GENT', + 'GENTING', + 'GEORGE', + 'GF', + 'GG', + 'GGEE', + 'GH', + 'GI', + 'GIFT', + 'GIFTS', + 'GIVES', + 'GIVING', + 'GL', + 'GLASS', + 'GLE', + 'GLOBAL', + 'GLOBO', + 'GM', + 'GMAIL', + 'GMBH', + 'GMO', + 'GMX', + 'GN', + 'GODADDY', + 'GOLD', + 'GOLDPOINT', + 'GOLF', + 'GOO', + 'GOODYEAR', + 'GOOG', + 'GOOGLE', + 'GOP', + 'GOT', + 'GOV', + 'GP', + 'GQ', + 'GR', + 'GRAINGER', + 'GRAPHICS', + 'GRATIS', + 'GREEN', + 'GRIPE', + 'GROCERY', + 'GROUP', + 'GS', + 'GT', + 'GU', + 'GUARDIAN', + 'GUCCI', + 'GUGE', + 'GUIDE', + 'GUITARS', + 'GURU', + 'GW', + 'GY', + ], + 'H': [ + 'HAIR', + 'HAMBURG', + 'HANGOUT', + 'HAUS', + 'HBO', + 'HDFC', + 'HDFCBANK', + 'HEALTH', + 'HEALTHCARE', + 'HELP', + 'HELSINKI', + 'HERE', + 'HERMES', + 'HGTV', + 'HIPHOP', + 'HISAMITSU', + 'HITACHI', + 'HIV', + 'HK', + 'HKT', + 'HM', + 'HN', + 'HOCKEY', + 'HOLDINGS', + 'HOLIDAY', + 'HOMEDEPOT', + 'HOMEGOODS', + 'HOMES', + 'HOMESENSE', + 'HONDA', + 'HORSE', + 'HOSPITAL', + 'HOST', + 'HOSTING', + 'HOT', + 'HOTELES', + 'HOTELS', + 'HOTMAIL', + 'HOUSE', + 'HOW', + 'HR', + 'HSBC', + 'HT', + 'HU', + 'HUGHES', + 'HYATT', + 'HYUNDAI', + ], + 'I': [ + 'IBM', + 'ICBC', + 'ICE', + 'ICU', + 'ID', + 'IE', + 'IEEE', + 'IFM', + 'IKANO', + 'IL', + 'IM', + 'IMAMAT', + 'IMDB', + 'IMMO', + 'IMMOBILIEN', + 'IN', + 'INC', + 'INDUSTRIES', + 'INFINITI', + 'INFO', + 'ING', + 'INK', + 'INSTITUTE', + 'INSURANCE', + 'INSURE', + 'INT', + 'INTERNATIONAL', + 'INTUIT', + 'INVESTMENTS', + 'IO', + 'IPIRANGA', + 'IQ', + 'IR', + 'IRISH', + 'IS', + 'ISMAILI', + 'IST', + 'ISTANBUL', + 'IT', + 'ITAU', + 'ITV', + ], + 'J': [ + 'JAGUAR', + 'JAVA', + 'JCB', + 'JE', + 'JEEP', + 'JETZT', + 'JEWELRY', + 'JIO', + 'JLL', + 'JM', + 'JMP', + 'JNJ', + 'JO', + 'JOBS', + 'JOBURG', + 'JOT', + 'JOY', + 'JP', + 'JPMORGAN', + 'JPRS', + 'JUEGOS', + 'JUNIPER', + ], + 'K': [ + 'KAUFEN', + 'KDDI', + 'KE', + 'KERRYHOTELS', + 'KERRYLOGISTICS', + 'KERRYPROPERTIES', + 'KFH', + 'KG', + 'KH', + 'KI', + 'KIA', + 'KIM', + 'KINDER', + 'KINDLE', + 'KITCHEN', + 'KIWI', + 'KM', + 'KN', + 'KOELN', + 'KOMATSU', + 'KOSHER', + 'KP', + 'KPMG', + 'KPN', + 'KR', + 'KRD', + 'KRED', + 'KUOKGROUP', + 'KW', + 'KY', + 'KYOTO', + 'KZ', + ], + 'L': [ + 'LA', + 'LACAIXA', + 'LAMBORGHINI', + 'LAMER', + 'LANCASTER', + 'LANCIA', + 'LAND', + 'LANDROVER', + 'LANXESS', + 'LASALLE', + 'LAT', + 'LATINO', + 'LATROBE', + 'LAW', + 'LAWYER', + 'LB', + 'LC', + 'LDS', + 'LEASE', + 'LECLERC', + 'LEFRAK', + 'LEGAL', + 'LEGO', + 'LEXUS', + 'LGBT', + 'LI', + 'LIDL', + 'LIFE', + 'LIFEINSURANCE', + 'LIFESTYLE', + 'LIGHTING', + 'LIKE', + 'LILLY', + 'LIMITED', + 'LIMO', + 'LINCOLN', + 'LINDE', + 'LINK', + 'LIPSY', + 'LIVE', + 'LIVING', + 'LK', + 'LLC', + 'LLP', + 'LOAN', + 'LOANS', + 'LOCKER', + 'LOCUS', + 'LOFT', + 'LOL', + 'LONDON', + 'LOTTE', + 'LOTTO', + 'LOVE', + 'LPL', + 'LPLFINANCIAL', + 'LR', + 'LS', + 'LT', + 'LTD', + 'LTDA', + 'LU', + 'LUNDBECK', + 'LUXE', + 'LUXURY', + 'LV', + 'LY', + ], + 'M': [ + 'MA', + 'MACYS', + 'MADRID', + 'MAIF', + 'MAISON', + 'MAKEUP', + 'MAN', + 'MANAGEMENT', + 'MANGO', + 'MAP', + 'MARKET', + 'MARKETING', + 'MARKETS', + 'MARRIOTT', + 'MARSHALLS', + 'MASERATI', + 'MATTEL', + 'MBA', + 'MC', + 'MCKINSEY', + 'MD', + 'ME', + 'MED', + 'MEDIA', + 'MEET', + 'MELBOURNE', + 'MEME', + 'MEMORIAL', + 'MEN', + 'MENU', + 'MERCKMSD', + 'MG', + 'MH', + 'MIAMI', + 'MICROSOFT', + 'MIL', + 'MINI', + 'MINT', + 'MIT', + 'MITSUBISHI', + 'MK', + 'ML', + 'MLB', + 'MLS', + 'MM', + 'MMA', + 'MN', + 'MO', + 'MOBI', + 'MOBILE', + 'MODA', + 'MOE', + 'MOI', + 'MOM', + 'MONASH', + 'MONEY', + 'MONSTER', + 'MORMON', + 'MORTGAGE', + 'MOSCOW', + 'MOTO', + 'MOTORCYCLES', + 'MOV', + 'MOVIE', + 'MP', + 'MQ', + 'MR', + 'MS', + 'MSD', + 'MT', + 'MTN', + 'MTR', + 'MU', + 'MUSEUM', + 'MUSIC', + 'MUTUAL', + 'MV', + 'MW', + 'MX', + 'MY', + 'MZ', + ], + 'N': [ + 'NA', + 'NAB', + 'NAGOYA', + 'NAME', + 'NATURA', + 'NAVY', + 'NBA', + 'NC', + 'NE', + 'NEC', + 'NET', + 'NETBANK', + 'NETFLIX', + 'NETWORK', + 'NEUSTAR', + 'NEW', + 'NEWS', + 'NEXT', + 'NEXTDIRECT', + 'NEXUS', + 'NF', + 'NFL', + 'NG', + 'NGO', + 'NHK', + 'NI', + 'NICO', + 'NIKE', + 'NIKON', + 'NINJA', + 'NISSAN', + 'NISSAY', + 'NL', + 'NO', + 'NOKIA', + 'NORTHWESTERNMUTUAL', + 'NORTON', + 'NOW', + 'NOWRUZ', + 'NOWTV', + 'NP', + 'NR', + 'NRA', + 'NRW', + 'NTT', + 'NU', + 'NYC', + 'NZ', + ], + 'O': [ + 'OBI', + 'OBSERVER', + 'OFFICE', + 'OKINAWA', + 'OLAYAN', + 'OLAYANGROUP', + 'OLDNAVY', + 'OLLO', + 'OM', + 'OMEGA', + 'ONE', + 'ONG', + 'ONL', + 'ONLINE', + 'OOO', + 'OPEN', + 'ORACLE', + 'ORANGE', + 'ORG', + 'ORGANIC', + 'ORIGINS', + 'OSAKA', + 'OTSUKA', + 'OTT', + 'OVH', + ], + 'P': [ + 'PA', + 'PAGE', + 'PANASONIC', + 'PARIS', + 'PARS', + 'PARTNERS', + 'PARTS', + 'PARTY', + 'PASSAGENS', + 'PAY', + 'PCCW', + 'PE', + 'PET', + 'PF', + 'PFIZER', + 'PG', + 'PH', + 'PHARMACY', + 'PHD', + 'PHILIPS', + 'PHONE', + 'PHOTO', + 'PHOTOGRAPHY', + 'PHOTOS', + 'PHYSIO', + 'PICS', + 'PICTET', + 'PICTURES', + 'PID', + 'PIN', + 'PING', + 'PINK', + 'PIONEER', + 'PIZZA', + 'PK', + 'PL', + 'PLACE', + 'PLAY', + 'PLAYSTATION', + 'PLUMBING', + 'PLUS', + 'PM', + 'PN', + 'PNC', + 'POHL', + 'POKER', + 'POLITIE', + 'PORN', + 'POST', + 'PR', + 'PRAMERICA', + 'PRAXI', + 'PRESS', + 'PRIME', + 'PRO', + 'PROD', + 'PRODUCTIONS', + 'PROF', + 'PROGRESSIVE', + 'PROMO', + 'PROPERTIES', + 'PROPERTY', + 'PROTECTION', + 'PRU', + 'PRUDENTIAL', + 'PS', + 'PT', + 'PUB', + 'PW', + 'PWC', + 'PY', + ], + 'Q': [ + 'QA', + 'QPON', + 'QUEBEC', + 'QUEST', + ], + 'R': [ + 'RACING', + 'RADIO', + 'RE', + 'READ', + 'REALESTATE', + 'REALTOR', + 'REALTY', + 'RECIPES', + 'RED', + 'REDSTONE', + 'REDUMBRELLA', + 'REHAB', + 'REISE', + 'REISEN', + 'REIT', + 'RELIANCE', + 'REN', + 'RENT', + 'RENTALS', + 'REPAIR', + 'REPORT', + 'REPUBLICAN', + 'REST', + 'RESTAURANT', + 'REVIEW', + 'REVIEWS', + 'REXROTH', + 'RICH', + 'RICHARDLI', + 'RICOH', + 'RIL', + 'RIO', + 'RIP', + 'RO', + 'ROCHER', + 'ROCKS', + 'RODEO', + 'ROGERS', + 'ROOM', + 'RS', + 'RSVP', + 'RU', + 'RUGBY', + 'RUHR', + 'RUN', + 'RW', + 'RWE', + 'RYUKYU', + ], + 'S': [ + 'SA', + 'SAARLAND', + 'SAFE', + 'SAFETY', + 'SAKURA', + 'SALE', + 'SALON', + 'SAMSCLUB', + 'SAMSUNG', + 'SANDVIK', + 'SANDVIKCOROMANT', + 'SANOFI', + 'SAP', + 'SARL', + 'SAS', + 'SAVE', + 'SAXO', + 'SB', + 'SBI', + 'SBS', + 'SC', + 'SCA', + 'SCB', + 'SCHAEFFLER', + 'SCHMIDT', + 'SCHOLARSHIPS', + 'SCHOOL', + 'SCHULE', + 'SCHWARZ', + 'SCIENCE', + 'SCOT', + 'SD', + 'SE', + 'SEARCH', + 'SEAT', + 'SECURE', + 'SECURITY', + 'SEEK', + 'SELECT', + 'SENER', + 'SERVICES', + 'SES', + 'SEVEN', + 'SEW', + 'SEX', + 'SEXY', + 'SFR', + 'SG', + 'SH', + 'SHANGRILA', + 'SHARP', + 'SHAW', + 'SHELL', + 'SHIA', + 'SHIKSHA', + 'SHOES', + 'SHOP', + 'SHOPPING', + 'SHOUJI', + 'SHOW', + 'SHOWTIME', + 'SI', + 'SILK', + 'SINA', + 'SINGLES', + 'SITE', + 'SJ', + 'SK', + 'SKI', + 'SKIN', + 'SKY', + 'SKYPE', + 'SL', + 'SLING', + 'SM', + 'SMART', + 'SMILE', + 'SN', + 'SNCF', + 'SO', + 'SOCCER', + 'SOCIAL', + 'SOFTBANK', + 'SOFTWARE', + 'SOHU', + 'SOLAR', + 'SOLUTIONS', + 'SONG', + 'SONY', + 'SOY', + 'SPA', + 'SPACE', + 'SPORT', + 'SPOT', + 'SR', + 'SRL', + 'SS', + 'ST', + 'STADA', + 'STAPLES', + 'STAR', + 'STATEBANK', + 'STATEFARM', + 'STC', + 'STCGROUP', + 'STOCKHOLM', + 'STORAGE', + 'STORE', + 'STREAM', + 'STUDIO', + 'STUDY', + 'STYLE', + 'SU', + 'SUCKS', + 'SUPPLIES', + 'SUPPLY', + 'SUPPORT', + 'SURF', + 'SURGERY', + 'SUZUKI', + 'SV', + 'SWATCH', + 'SWISS', + 'SX', + 'SY', + 'SYDNEY', + 'SYSTEMS', + 'SZ', + ], + 'T': [ + 'TAB', + 'TAIPEI', + 'TALK', + 'TAOBAO', + 'TARGET', + 'TATAMOTORS', + 'TATAR', + 'TATTOO', + 'TAX', + 'TAXI', + 'TC', + 'TCI', + 'TD', + 'TDK', + 'TEAM', + 'TECH', + 'TECHNOLOGY', + 'TEL', + 'TEMASEK', + 'TENNIS', + 'TEVA', + 'TF', + 'TG', + 'TH', + 'THD', + 'THEATER', + 'THEATRE', + 'TIAA', + 'TICKETS', + 'TIENDA', + 'TIFFANY', + 'TIPS', + 'TIRES', + 'TIROL', + 'TJ', + 'TJMAXX', + 'TJX', + 'TK', + 'TKMAXX', + 'TL', + 'TM', + 'TMALL', + 'TN', + 'TO', + 'TODAY', + 'TOKYO', + 'TOOLS', + 'TOP', + 'TORAY', + 'TOSHIBA', + 'TOTAL', + 'TOURS', + 'TOWN', + 'TOYOTA', + 'TOYS', + 'TR', + 'TRADE', + 'TRADING', + 'TRAINING', + 'TRAVEL', + 'TRAVELCHANNEL', + 'TRAVELERS', + 'TRAVELERSINSURANCE', + 'TRUST', + 'TRV', + 'TT', + 'TUBE', + 'TUI', + 'TUNES', + 'TUSHU', + 'TV', + 'TVS', + 'TW', + 'TZ', + ], + 'U': [ + 'UA', + 'UBANK', + 'UBS', + 'UG', + 'UK', + 'UNICOM', + 'UNIVERSITY', + 'UNO', + 'UOL', + 'UPS', + 'US', + 'UY', + 'UZ', + ], + 'V': [ + 'VA', + 'VACATIONS', + 'VANA', + 'VANGUARD', + 'VC', + 'VE', + 'VEGAS', + 'VENTURES', + 'VERISIGN', + 'VERSICHERUNG', + 'VET', + 'VG', + 'VI', + 'VIAJES', + 'VIDEO', + 'VIG', + 'VIKING', + 'VILLAS', + 'VIN', + 'VIP', + 'VIRGIN', + 'VISA', + 'VISION', + 'VIVA', + 'VIVO', + 'VLAANDEREN', + 'VN', + 'VODKA', + 'VOLKSWAGEN', + 'VOLVO', + 'VOTE', + 'VOTING', + 'VOTO', + 'VOYAGE', + 'VU', + 'VUELOS', + ], + 'W': [ + 'WALES', + 'WALMART', + 'WALTER', + 'WANG', + 'WANGGOU', + 'WATCH', + 'WATCHES', + 'WEATHER', + 'WEATHERCHANNEL', + 'WEBCAM', + 'WEBER', + 'WEBSITE', + 'WED', + 'WEDDING', + 'WEIBO', + 'WEIR', + 'WF', + 'WHOSWHO', + 'WIEN', + 'WIKI', + 'WILLIAMHILL', + 'WIN', + 'WINDOWS', + 'WINE', + 'WINNERS', + 'WME', + 'WOLTERSKLUWER', + 'WOODSIDE', + 'WORK', + 'WORKS', + 'WORLD', + 'WOW', + 'WS', + 'WTC', + 'WTF', + ], + 'X': [ + 'XBOX', + 'XEROX', + 'XFINITY', + 'XIHUAN', + 'XIN', + 'XN--11B4C3D', + 'XN--1CK2E1B', + 'XN--1QQW23A', + 'XN--2SCRJ9C', + 'XN--30RR7Y', + 'XN--3BST00M', + 'XN--3DS443G', + 'XN--3E0B707E', + 'XN--3HCRJ9C', + 'XN--3PXU8K', + 'XN--42C2D9A', + 'XN--45BR5CYL', + 'XN--45BRJ9C', + 'XN--45Q11C', + 'XN--4DBRK0CE', + 'XN--4GBRIM', + 'XN--54B7FTA0CC', + 'XN--55QW42G', + 'XN--55QX5D', + 'XN--5SU34J936BGSG', + 'XN--5TZM5G', + 'XN--6FRZ82G', + 'XN--6QQ986B3XL', + 'XN--80ADXHKS', + 'XN--80AO21A', + 'XN--80AQECDR1A', + 'XN--80ASEHDB', + 'XN--80ASWG', + 'XN--8Y0A063A', + 'XN--90A3AC', + 'XN--90AE', + 'XN--90AIS', + 'XN--9DBQ2A', + 'XN--9ET52U', + 'XN--9KRT00A', + 'XN--B4W605FERD', + 'XN--BCK1B9A5DRE4C', + 'XN--C1AVG', + 'XN--C2BR7G', + 'XN--CCK2B3B', + 'XN--CCKWCXETD', + 'XN--CG4BKI', + 'XN--CLCHC0EA0B2G2A9GCD', + 'XN--CZR694B', + 'XN--CZRS0T', + 'XN--CZRU2D', + 'XN--D1ACJ3B', + 'XN--D1ALF', + 'XN--E1A4C', + 'XN--ECKVDTC9D', + 'XN--EFVY88H', + 'XN--FCT429K', + 'XN--FHBEI', + 'XN--FIQ228C5HS', + 'XN--FIQ64B', + 'XN--FIQS8S', + 'XN--FIQZ9S', + 'XN--FJQ720A', + 'XN--FLW351E', + 'XN--FPCRJ9C3D', + 'XN--FZC2C9E2C', + 'XN--FZYS8D69UVGM', + 'XN--G2XX48C', + 'XN--GCKR3F0F', + 'XN--GECRJ9C', + 'XN--GK3AT1E', + 'XN--H2BREG3EVE', + 'XN--H2BRJ9C', + 'XN--H2BRJ9C8C', + 'XN--HXT814E', + 'XN--I1B6B1A6A2E', + 'XN--IMR513N', + 'XN--IO0A7I', + 'XN--J1AEF', + 'XN--J1AMH', + 'XN--J6W193G', + 'XN--JLQ480N2RG', + 'XN--JLQ61U9W7B', + 'XN--JVR189M', + 'XN--KCRX77D1X4A', + 'XN--KPRW13D', + 'XN--KPRY57D', + 'XN--KPUT3I', + 'XN--L1ACC', + 'XN--LGBBAT1AD8J', + 'XN--MGB9AWBF', + 'XN--MGBA3A3EJT', + 'XN--MGBA3A4F16A', + 'XN--MGBA7C0BBN0A', + 'XN--MGBAAKC7DVF', + 'XN--MGBAAM7A8H', + 'XN--MGBAB2BD', + 'XN--MGBAH1A3HJKRD', + 'XN--MGBAI9AZGQP6J', + 'XN--MGBAYH7GPA', + 'XN--MGBBH1A', + 'XN--MGBBH1A71E', + 'XN--MGBC0A9AZCG', + 'XN--MGBCA7DZDO', + 'XN--MGBCPQ6GPA1A', + 'XN--MGBERP4A5D4AR', + 'XN--MGBGU82A', + 'XN--MGBI4ECEXP', + 'XN--MGBPL2FH', + 'XN--MGBT3DHD', + 'XN--MGBTX2B', + 'XN--MGBX4CD0AB', + 'XN--MIX891F', + 'XN--MK1BU44C', + 'XN--MXTQ1M', + 'XN--NGBC5AZD', + 'XN--NGBE9E0A', + 'XN--NGBRX', + 'XN--NODE', + 'XN--NQV7F', + 'XN--NQV7FS00EMA', + 'XN--NYQY26A', + 'XN--O3CW4H', + 'XN--OGBPF8FL', + 'XN--OTU796D', + 'XN--P1ACF', + 'XN--P1AI', + 'XN--PGBS0DH', + 'XN--PSSY2U', + 'XN--Q7CE6A', + 'XN--Q9JYB4C', + 'XN--QCKA1PMC', + 'XN--QXA6A', + 'XN--QXAM', + 'XN--RHQV96G', + 'XN--ROVU88B', + 'XN--RVC1E0AM3E', + 'XN--S9BRJ9C', + 'XN--SES554G', + 'XN--T60B56A', + 'XN--TCKWE', + 'XN--TIQ49XQYJ', + 'XN--UNUP4Y', + 'XN--VERMGENSBERATER-CTB', + 'XN--VERMGENSBERATUNG-PWB', + 'XN--VHQUV', + 'XN--VUQ861B', + 'XN--W4R85EL8FHU5DNRA', + 'XN--W4RS40L', + 'XN--WGBH1C', + 'XN--WGBL6A', + 'XN--XHQ521B', + 'XN--XKC2AL3HYE2A', + 'XN--XKC2DL3A5EE0H', + 'XN--Y9A3AQ', + 'XN--YFRO4I67O', + 'XN--YGBI2AMMX', + 'XN--ZFR164B', + 'XXX', + 'XYZ', + ], + 'Y': [ + 'YACHTS', + 'YAHOO', + 'YAMAXUN', + 'YANDEX', + 'YE', + 'YODOBASHI', + 'YOGA', + 'YOKOHAMA', + 'YOU', + 'YOUTUBE', + 'YT', + 'YUN', + ], + 'Z': [ + 'ZA', + 'ZAPPOS', + 'ZARA', + 'ZERO', + 'ZIP', + 'ZM', + 'ZONE', + 'ZUERICH', + 'ZW', + ], +}; diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart index f3818b2e..8a210b96 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -259,6 +259,7 @@ class StreamChatThemeData { sendButtonIdleColor: colorTheme.disabled, inputBackgroundColor: colorTheme.barsBg, inputTextStyle: textTheme.body, + linkHighlightColor: colorTheme.accentPrimary, idleBorderGradient: LinearGradient( colors: [ colorTheme.disabled, diff --git a/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart b/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart index 26a995a9..1348af37 100644 --- a/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart @@ -65,6 +65,7 @@ class MessageInputThemeData with Diagnosticable { this.idleBorderGradient, this.borderRadius, this.expandButtonColor, + this.linkHighlightColor, }); /// Duration of the [MessageInput] send button animation @@ -73,6 +74,9 @@ class MessageInputThemeData with Diagnosticable { /// Background color of [MessageInput] send button final Color? sendButtonColor; + /// Color of a link + final Color? linkHighlightColor; + /// Background color of [MessageInput] action buttons final Color? actionButtonColor; @@ -110,6 +114,7 @@ class MessageInputThemeData with Diagnosticable { Color? actionButtonColor, Color? sendButtonColor, Color? actionButtonIdleColor, + Color? linkHighlightColor, Color? sendButtonIdleColor, Color? expandButtonColor, TextStyle? inputTextStyle, @@ -133,6 +138,7 @@ class MessageInputThemeData with Diagnosticable { activeBorderGradient: activeBorderGradient ?? this.activeBorderGradient, idleBorderGradient: idleBorderGradient ?? this.idleBorderGradient, borderRadius: borderRadius ?? this.borderRadius, + linkHighlightColor: linkHighlightColor ?? this.linkHighlightColor, ); /// Linearly interpolate from one [MessageInputThemeData] to another. @@ -161,6 +167,8 @@ class MessageInputThemeData with Diagnosticable { Color.lerp(a.sendButtonIdleColor, b.sendButtonIdleColor, t), sendAnimationDuration: a.sendAnimationDuration, inputDecoration: a.inputDecoration, + linkHighlightColor: + Color.lerp(a.linkHighlightColor, b.linkHighlightColor, t), ); /// Merges [this] [MessageInputThemeData] with the [other] @@ -181,6 +189,7 @@ class MessageInputThemeData with Diagnosticable { idleBorderGradient: other.idleBorderGradient, borderRadius: other.borderRadius, expandButtonColor: other.expandButtonColor, + linkHighlightColor: other.linkHighlightColor, ); } @@ -200,7 +209,8 @@ class MessageInputThemeData with Diagnosticable { inputDecoration == other.inputDecoration && idleBorderGradient == other.idleBorderGradient && activeBorderGradient == other.activeBorderGradient && - borderRadius == other.borderRadius; + borderRadius == other.borderRadius && + linkHighlightColor == other.linkHighlightColor; @override int get hashCode => @@ -215,7 +225,8 @@ class MessageInputThemeData with Diagnosticable { inputDecoration.hashCode ^ idleBorderGradient.hashCode ^ activeBorderGradient.hashCode ^ - borderRadius.hashCode; + borderRadius.hashCode ^ + linkHighlightColor.hashCode; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -232,6 +243,7 @@ class MessageInputThemeData with Diagnosticable { ..add(DiagnosticsProperty('activeBorderGradient', activeBorderGradient)) ..add(DiagnosticsProperty('idleBorderGradient', idleBorderGradient)) ..add(DiagnosticsProperty('borderRadius', borderRadius)) - ..add(ColorProperty('expandButtonColor', expandButtonColor)); + ..add(ColorProperty('expandButtonColor', expandButtonColor)) + ..add(ColorProperty('linkHighlightColor', linkHighlightColor)); } } diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index a4e9d715..ad3f968d 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -25,6 +25,8 @@ export 'src/mention_tile.dart'; export 'src/message_action.dart'; export 'src/message_input/countdown_button.dart'; export 'src/message_input/message_input.dart'; +export 'src/message_input/message_input_controller.dart'; +export 'src/message_input/message_text_field_controller.dart'; export 'src/message_input/stream_attachment_picker.dart'; export 'src/message_input/stream_message_send_button.dart'; export 'src/message_input/stream_message_text_field.dart'; diff --git a/packages/stream_chat_flutter_core/test/message_input_controller_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_controller_test.dart similarity index 82% rename from packages/stream_chat_flutter_core/test/message_input_controller_test.dart rename to packages/stream_chat_flutter/test/src/message_input/message_input_controller_test.dart index 5e7934bd..1b496784 100644 --- a/packages/stream_chat_flutter_core/test/message_input_controller_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_controller_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; void main() { testWidgets( diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index bf191ace..8bd41c76 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -7,7 +7,6 @@ export 'src/better_stream_builder.dart'; export 'src/channel_list_core.dart' hide ChannelListCoreState; export 'src/channels_bloc.dart'; export 'src/lazy_load_scroll_view.dart'; -export 'src/message_input_controller.dart'; export 'src/message_list_core.dart' hide MessageListCoreState; export 'src/message_search_bloc.dart'; export 'src/message_search_list_core.dart' hide MessageSearchListCoreState;