Merge branch 'v4' of https://github.com/GetStream/stream-chat-flutter into feat/capabilities

 Conflicts:
	packages/stream_chat_flutter/lib/src/message_input/message_input.dart
This commit is contained in:
Deven Joshi
2022-01-13 17:25:53 +05:30
16 changed files with 2114 additions and 189 deletions
@@ -505,6 +505,7 @@ class Channel {
Future<SendMessageResponse> sendMessage( Future<SendMessageResponse> sendMessage(
Message message, { Message message, {
bool skipPush = false, bool skipPush = false,
bool skipEnrichUrl = false,
}) async { }) async {
_checkInitialized(); _checkInitialized();
// Cancelling previous completer in case it's called again in the process // Cancelling previous completer in case it's called again in the process
@@ -552,6 +553,7 @@ class Channel {
id!, id!,
type, type,
skipPush: skipPush, skipPush: skipPush,
skipEnrichUrl: skipEnrichUrl,
); );
state!.addMessage(response.message); state!.addMessage(response.message);
if (cooldown > 0) cooldownStartedAt = DateTime.now(); if (cooldown > 0) cooldownStartedAt = DateTime.now();
@@ -568,7 +570,10 @@ class Channel {
/// ///
/// Waits for a [_messageAttachmentsUploadCompleter] to complete /// Waits for a [_messageAttachmentsUploadCompleter] to complete
/// before actually updating the message. /// before actually updating the message.
Future<UpdateMessageResponse> updateMessage(Message message) async { Future<UpdateMessageResponse> updateMessage(
Message message, {
bool skipEnrichUrl = false,
}) async {
final originalMessage = message; final originalMessage = message;
// Cancelling previous completer in case it's called again in the process // Cancelling previous completer in case it's called again in the process
@@ -606,7 +611,10 @@ class Channel {
message = await attachmentsUploadCompleter.future; message = await attachmentsUploadCompleter.future;
} }
final response = await _client.updateMessage(message); final response = await _client.updateMessage(
message,
skipEnrichUrl: skipEnrichUrl,
);
final m = response.message.copyWith( final m = response.message.copyWith(
ownReactions: message.ownReactions, ownReactions: message.ownReactions,
@@ -636,12 +644,14 @@ class Channel {
Message message, { Message message, {
Map<String, Object?>? set, Map<String, Object?>? set,
List<String>? unset, List<String>? unset,
bool skipEnrichUrl = false,
}) async { }) async {
try { try {
final response = await _client.partialUpdateMessage( final response = await _client.partialUpdateMessage(
message.id, message.id,
set: set, set: set,
unset: unset, unset: unset,
skipEnrichUrl: skipEnrichUrl,
); );
final updatedMessage = response.message.copyWith( final updatedMessage = response.message.copyWith(
@@ -1169,12 +1169,14 @@ class StreamChatClient {
String channelId, String channelId,
String channelType, { String channelType, {
bool skipPush = false, bool skipPush = false,
bool skipEnrichUrl = false,
}) => }) =>
_chatApi.message.sendMessage( _chatApi.message.sendMessage(
channelId, channelId,
channelType, channelType,
message, message,
skipPush: skipPush, skipPush: skipPush,
skipEnrichUrl: skipEnrichUrl,
); );
/// Lists all the message replies for the [parentId] /// Lists all the message replies for the [parentId]
@@ -1198,8 +1200,14 @@ class StreamChatClient {
); );
/// Update the given message /// Update the given message
Future<UpdateMessageResponse> updateMessage(Message message) => Future<UpdateMessageResponse> updateMessage(
_chatApi.message.updateMessage(message); Message message, {
bool skipEnrichUrl = false,
}) =>
_chatApi.message.updateMessage(
message,
skipEnrichUrl: skipEnrichUrl,
);
/// Partially update the given [messageId] /// Partially update the given [messageId]
/// Use [set] to define values to be set /// Use [set] to define values to be set
@@ -1208,11 +1216,13 @@ class StreamChatClient {
String messageId, { String messageId, {
Map<String, Object?>? set, Map<String, Object?>? set,
List<String>? unset, List<String>? unset,
bool skipEnrichUrl = false,
}) => }) =>
_chatApi.message.partialUpdateMessage( _chatApi.message.partialUpdateMessage(
messageId, messageId,
set: set, set: set,
unset: unset, unset: unset,
skipEnrichUrl: skipEnrichUrl,
); );
/// Deletes the given message /// Deletes the given message
@@ -16,12 +16,14 @@ class MessageApi {
String channelType, String channelType,
Message message, { Message message, {
bool skipPush = false, bool skipPush = false,
bool skipEnrichUrl = false,
}) async { }) async {
final response = await _client.post( final response = await _client.post(
'/channels/$channelType/$channelId/message', '/channels/$channelType/$channelId/message',
data: { data: {
'message': message, 'message': message,
'skip_push': skipPush, 'skip_push': skipPush,
'skip_enrich_url': skipEnrichUrl,
}, },
); );
return SendMessageResponse.fromJson(response.data); return SendMessageResponse.fromJson(response.data);
@@ -51,11 +53,15 @@ class MessageApi {
/// Updates the given [message] /// Updates the given [message]
Future<UpdateMessageResponse> updateMessage( Future<UpdateMessageResponse> updateMessage(
Message message, Message message, {
) async { bool skipEnrichUrl = false,
}) async {
final response = await _client.post( final response = await _client.post(
'/messages/${message.id}', '/messages/${message.id}',
data: {'message': message}, data: {
'message': message,
'skip_enrich_url': skipEnrichUrl,
},
); );
return UpdateMessageResponse.fromJson(response.data); return UpdateMessageResponse.fromJson(response.data);
} }
@@ -67,12 +73,14 @@ class MessageApi {
String messageId, { String messageId, {
Map<String, Object?>? set, Map<String, Object?>? set,
List<String>? unset, List<String>? unset,
bool skipEnrichUrl = false,
}) async { }) async {
final response = await _client.put( final response = await _client.put(
'/messages/$messageId', '/messages/$messageId',
data: { data: {
if (set != null) 'set': set, if (set != null) 'set': set,
if (unset != null) 'unset': unset, if (unset != null) 'unset': unset,
'skip_enrich_url': skipEnrichUrl,
}, },
); );
return UpdateMessageResponse.fromJson(response.data); return UpdateMessageResponse.fromJson(response.data);
@@ -2,6 +2,7 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.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/action.dart';
import 'package:stream_chat/src/core/models/attachment_file.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart';
import 'package:stream_chat/src/core/util/serializer.dart'; import 'package:stream_chat/src/core/util/serializer.dart';
@@ -66,6 +67,21 @@ class Attachment extends Equatable {
topLevelFields + dbSpecificTopLevelFields, 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, ///The attachment type based on the URL resource. This can be: audio,
///image or video ///image or video
final String? type; final String? type;
@@ -229,6 +245,33 @@ class Attachment extends Equatable {
extraData: extraData ?? this.extraData, 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 @override
List<Object?> get props => [ List<Object?> get props => [
id, id,
@@ -32,6 +32,7 @@ void main() {
data: { data: {
'message': message, 'message': message,
'skip_push': false, 'skip_push': false,
'skip_enrich_url': false,
}, },
)).thenAnswer((_) async => successResponse(path, data: { )).thenAnswer((_) async => successResponse(path, data: {
'message': message.toJson(), 'message': message.toJson(),
@@ -58,6 +59,7 @@ void main() {
data: { data: {
'message': message, 'message': message,
'skip_push': true, 'skip_push': true,
'skip_enrich_url': false,
}, },
)).thenAnswer((_) async => successResponse(path, data: { )).thenAnswer((_) async => successResponse(path, data: {
'message': message.toJson(), 'message': message.toJson(),
@@ -137,7 +139,10 @@ void main() {
when(() => client.post( when(() => client.post(
path, path,
data: {'message': message}, data: {
'message': message,
'skip_enrich_url': false,
},
)).thenAnswer( )).thenAnswer(
(_) async => successResponse(path, data: {'message': message.toJson()}), (_) async => successResponse(path, data: {'message': message.toJson()}),
); );
@@ -162,7 +167,11 @@ void main() {
when(() => client.put( when(() => client.put(
path, path,
data: {'set': set, 'unset': unset}, data: {
'set': set,
'unset': unset,
'skip_enrich_url': false,
},
)).thenAnswer( )).thenAnswer(
(_) async => successResponse(path, data: {'message': message.toJson()}), (_) async => successResponse(path, data: {'message': message.toJson()}),
); );
@@ -180,7 +189,11 @@ void main() {
verify(() => client.put( verify(() => client.put(
path, path,
data: {'set': set, 'unset': unset}, data: {
'set': set,
'unset': unset,
'skip_enrich_url': false,
},
)).called(1); )).called(1);
verifyNoMoreInteractions(client); verifyNoMoreInteractions(client);
}); });
@@ -14,6 +14,10 @@
- Videos can now be auto-played in `FullScreenMedia` - Videos can now be auto-played in `FullScreenMedia`
🔄 Changed
- Add `didUpdateWidget` override in `MessageInput` widget to handle changes to `focusNode`.
## 3.3.2 ## 3.3.2
- Updated `stream_chat_flutter_core` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat_flutter_core/changelog). - Updated `stream_chat_flutter_core` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
@@ -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/emoji.dart';
import 'package:stream_chat_flutter/src/emoji_overlay.dart'; import 'package:stream_chat_flutter/src/emoji_overlay.dart';
import 'package:stream_chat_flutter/src/extension.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/multi_overlay.dart';
import 'package:stream_chat_flutter/src/quoted_message_widget.dart'; import 'package:stream_chat_flutter/src/quoted_message_widget.dart';
import 'package:stream_chat_flutter/src/user_mentions_overlay.dart'; import 'package:stream_chat_flutter/src/user_mentions_overlay.dart';
@@ -335,25 +336,15 @@ class MessageInput extends StatefulWidget {
@override @override
MessageInputState createState() => MessageInputState(); MessageInputState createState() => MessageInputState();
/// Use this method to get the current [StreamChatState] instance
static MessageInputState of(BuildContext context) {
MessageInputState? messageInputState;
messageInputState = context.findAncestorStateOfType<MessageInputState>();
assert(
messageInputState != null,
'You must have a MessageInput widget as ancestor of your widget tree',
);
return messageInputState!;
}
} }
/// State of [MessageInput] /// State of [MessageInput]
class MessageInputState extends State<MessageInput> class MessageInputState extends State<MessageInput>
with RestorationMixin<MessageInput> { with RestorationMixin<MessageInput> {
final _imagePicker = ImagePicker(); final _imagePicker = ImagePicker();
late final _focusNode = widget.focusNode ?? FocusNode(); late FocusNode _focusNode = widget.focusNode ?? FocusNode();
bool _inputEnabled = true; bool _inputEnabled = true;
bool get _commandEnabled => _effectiveController.value.command != null; bool get _commandEnabled => _effectiveController.value.command != null;
bool _showCommandsOverlay = false; bool _showCommandsOverlay = false;
bool _showMentionsOverlay = false; bool _showMentionsOverlay = false;
@@ -371,6 +362,7 @@ class MessageInputState extends State<MessageInput>
_effectiveController.value.status != MessageSendingStatus.sending; _effectiveController.value.status != MessageSendingStatus.sending;
RestorableMessageInputController? _controller; RestorableMessageInputController? _controller;
MessageInputController get _effectiveController => MessageInputController get _effectiveController =>
widget.messageInputController ?? _controller!.value; widget.messageInputController ?? _controller!.value;
@@ -398,11 +390,7 @@ class MessageInputState extends State<MessageInput>
if (widget.messageInputController == null) { if (widget.messageInputController == null) {
_createLocalController(); _createLocalController();
} else { } else {
_effectiveController.textEditingController _initialiseEffectiveController();
.removeListener(_onChangedDebounced);
_effectiveController.textEditingController
.addListener(_onChangedDebounced);
if (!_isEditing && _timeOut <= 0) _startSlowMode();
} }
_focusNode.addListener(_focusNodeListener); _focusNode.addListener(_focusNodeListener);
} }
@@ -418,6 +406,14 @@ class MessageInputState extends State<MessageInput>
unregisterFromRestoration(_controller!); unregisterFromRestoration(_controller!);
_controller!.dispose(); _controller!.dispose();
_controller = null; _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<MessageInput>
int _timeOut = 0; int _timeOut = 0;
Timer? _slowModeTimer; Timer? _slowModeTimer;
void _initialiseEffectiveController() {
_effectiveController.textEditingController
.removeListener(_onChangedDebounced);
_effectiveController.textEditingController.addListener(_onChangedDebounced);
if (!_isEditing && _timeOut <= 0) _startSlowMode();
}
void _startSlowMode() { void _startSlowMode() {
if (!mounted) { if (!mounted) {
return; return;
@@ -487,110 +490,119 @@ class MessageInputState extends State<MessageInput>
); );
} }
return MessageValueListenableBuilder( return MessageValueListenableBuilder(
valueListenable: _effectiveController, valueListenable: _effectiveController,
builder: (context, value, _) { builder: (context, value, _) {
Widget child = DecoratedBox( Widget child = DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: _messageInputTheme.inputBackgroundColor, color: _messageInputTheme.inputBackgroundColor,
), ),
child: SafeArea( child: SafeArea(
child: GestureDetector( child: GestureDetector(
onPanUpdate: (details) { onPanUpdate: (details) {
if (details.delta.dy > 0) { if (details.delta.dy > 0) {
_focusNode.unfocus(); _focusNode.unfocus();
if (_openFilePickerSection) { if (_openFilePickerSection) {
setState(() { setState(() {
_openFilePickerSection = false; _openFilePickerSection = false;
}); });
}
} }
} },
}, child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ if (_hasQuotedMessage)
if (_hasQuotedMessage) Padding(
Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: Row(
child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
children: [ Padding(
Padding( padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(8), child: StreamSvgIcon.reply(
child: StreamSvgIcon.reply( color: _streamChatTheme.colorTheme.disabled,
color: _streamChatTheme.colorTheme.disabled, ),
), ),
), Text(
Text( context.translations.replyToMessageLabel,
context.translations.replyToMessageLabel, style:
style: const TextStyle(fontWeight: FontWeight.bold), const TextStyle(fontWeight: FontWeight.bold),
), ),
IconButton( IconButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
icon: StreamSvgIcon.closeSmall(), icon: StreamSvgIcon.closeSmall(),
onPressed: () { onPressed: () {
_effectiveController.clearQuotedMessage(); _effectiveController.clearQuotedMessage();
_focusNode.unfocus(); _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(
padding: const EdgeInsets.only( padding: const EdgeInsets.symmetric(vertical: 8),
right: 12, child: _buildTextField(context),
left: 12,
bottom: 12,
),
child: _buildDmCheckbox(),
), ),
_buildFilePickerSection(), if (_effectiveController.value.parentId != null &&
], !widget.hideSendAsDm)
Padding(
padding: const EdgeInsets.only(
right: 12,
left: 12,
bottom: 12,
),
child: _buildDmCheckbox(),
),
_buildFilePickerSection(),
],
),
), ),
), ),
), );
); if (!_isEditing) {
if (!_isEditing) { child = Material(
child = Material( elevation: 8,
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, 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( Flex _buildTextField(BuildContext context) => Flex(
@@ -917,6 +929,7 @@ class MessageInputState extends State<MessageInput>
_actionsShrunk = value.isNotEmpty && actionsLength > 1; _actionsShrunk = value.isNotEmpty && actionsLength > 1;
}); });
_checkContainsUrl(value, context);
_checkCommands(value, context); _checkCommands(value, context);
_checkMentions(value, context); _checkMentions(value, context);
_checkEmoji(value, context); _checkEmoji(value, context);
@@ -939,14 +952,75 @@ class MessageInputState extends State<MessageInput>
return context.translations.writeAMessageLabel; return context.translations.writeAMessageLabel;
} }
void _checkEmoji(String s, BuildContext context) { String? _lastSearchedContainsUrlText;
if (s.isNotEmpty && 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 = <String, OGAttachmentResponse>{};
Future<OGAttachmentResponse> _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.baseOffset > 0 &&
_effectiveController.text _effectiveController.text
.substring( .substring(0, _effectiveController.baseOffset)
0,
_effectiveController.baseOffset,
)
.contains(':')) { .contains(':')) {
final textToSelection = _effectiveController.text.substring( final textToSelection = _effectiveController.text.substring(
0, 0,
@@ -962,14 +1036,11 @@ class MessageInputState extends State<MessageInput>
} }
} }
void _checkMentions(String s, BuildContext context) { void _checkMentions(String value, BuildContext context) {
if (s.isNotEmpty && if (value.isNotEmpty &&
_effectiveController.baseOffset > 0 && _effectiveController.baseOffset > 0 &&
_effectiveController.text _effectiveController.text
.substring( .substring(0, _effectiveController.baseOffset)
0,
_effectiveController.baseOffset,
)
.split(' ') .split(' ')
.last .last
.contains('@')) { .contains('@')) {
@@ -985,11 +1056,11 @@ class MessageInputState extends State<MessageInput>
} }
} }
void _checkCommands(String s, BuildContext context) { void _checkCommands(String value, BuildContext context) {
if (s.startsWith('/')) { if (value.startsWith('/')) {
final allCommands = StreamChannel.of(context).channel.config?.commands; final allCommands = StreamChannel.of(context).channel.config?.commands;
final command = final command =
allCommands?.firstWhereOrNull((it) => it.name == s.substring(1)); allCommands?.firstWhereOrNull((it) => it.name == value.substring(1));
if (command != null) { if (command != null) {
return _setCommand(command); return _setCommand(command);
} else if (!_showCommandsOverlay) { } else if (!_showCommandsOverlay) {
@@ -1051,10 +1122,7 @@ class MessageInputState extends State<MessageInput>
} }
final splits = _effectiveController.text final splits = _effectiveController.text
.substring( .substring(0, _effectiveController.selectionStart)
0,
_effectiveController.selectionStart,
)
.split('@'); .split('@');
final query = splits.last.toLowerCase(); final query = splits.last.toLowerCase();
@@ -1103,10 +1171,7 @@ class MessageInputState extends State<MessageInput>
} }
final splits = _effectiveController.text final splits = _effectiveController.text
.substring( .substring(0, _effectiveController.baseOffset)
0,
_effectiveController.baseOffset,
)
.split(':'); .split(':');
final query = splits.last.toLowerCase(); final query = splits.last.toLowerCase();
@@ -1154,11 +1219,14 @@ class MessageInputState extends State<MessageInput>
} }
Widget _buildAttachments() { Widget _buildAttachments() {
if (_effectiveController.attachments.isEmpty) return const Offstage(); final nonOGAttachments = _effectiveController.attachments.where(
final fileAttachments = _effectiveController.attachments (it) => it.titleLink == null,
);
if (nonOGAttachments.isEmpty) return const Offstage();
final fileAttachments = nonOGAttachments
.where((it) => it.type == 'file') .where((it) => it.type == 'file')
.toList(growable: false); .toList(growable: false);
final remainingAttachments = _effectiveController.attachments final remainingAttachments = nonOGAttachments
.where((it) => it.type != 'file') .where((it) => it.type != 'file')
.toList(growable: false); .toList(growable: false);
return Column( return Column(
@@ -1243,11 +1311,7 @@ class MessageInputState extends State<MessageInput>
focusElevation: 0, focusElevation: 0,
hoverElevation: 0, hoverElevation: 0,
onPressed: () { onPressed: () {
_effectiveController.value = _effectiveController.value.copyWith( _effectiveController.removeAttachmentById(attachment.id);
attachments: _effectiveController.attachments
.where((it) => it.id != attachment.id)
.toList(),
);
}, },
fillColor: fillColor:
_streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5), _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5),
@@ -1449,14 +1513,6 @@ class MessageInputState extends State<MessageInput>
} }
} }
/// 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 /// Adds an attachment to the [messageInputController.attachments] map
void _addAttachments(Iterable<Attachment> attachments) { void _addAttachments(Iterable<Attachment> attachments) {
final limit = widget.attachmentLimit; final limit = widget.attachmentLimit;
@@ -1591,6 +1647,8 @@ class MessageInputState extends State<MessageInput>
/// Sends the current message /// Sends the current message
Future<void> sendMessage() async { Future<void> sendMessage() async {
final skipEnrichUrl = _effectiveController.ogAttachment == null;
var message = _effectiveController.value; var message = _effectiveController.value;
var shouldKeepFocus = widget.shouldKeepFocusAfterMessage; var shouldKeepFocus = widget.shouldKeepFocusAfterMessage;
@@ -1611,10 +1669,16 @@ class MessageInputState extends State<MessageInput>
try { try {
Future sendingFuture; Future sendingFuture;
if (!_isEditing) { if (_isEditing) {
sendingFuture = channel.sendMessage(message); sendingFuture = channel.updateMessage(
message,
skipEnrichUrl: skipEnrichUrl,
);
} else { } else {
sendingFuture = channel.updateMessage(message); sendingFuture = channel.sendMessage(
message,
skipEnrichUrl: skipEnrichUrl,
);
} }
if (shouldKeepFocus) { if (shouldKeepFocus) {
@@ -1723,3 +1787,78 @@ class MessageInputState extends State<MessageInput>
super.didChangeDependencies(); 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,
),
],
);
}
}
@@ -1,8 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat/stream_chat.dart';
/// A value listenable builder related to a [Message]. /// A value listenable builder related to a [Message].
/// ///
@@ -17,33 +17,46 @@ class MessageInputController extends ValueNotifier<Message> {
/// message. /// message.
factory MessageInputController({ factory MessageInputController({
Message? message, Message? message,
Map<RegExp, TextStyleBuilder>? textPatternStyle,
}) => }) =>
MessageInputController._( MessageInputController._(
initialMessage: message ?? Message(), initialMessage: message ?? Message(),
textPatternStyle: textPatternStyle,
); );
/// Creates a controller for an editable text field from an initial [text]. /// Creates a controller for an editable text field from an initial [text].
factory MessageInputController.fromText(String? text) => factory MessageInputController.fromText(
String? text, {
Map<RegExp, TextStyleBuilder>? textPatternStyle,
}) =>
MessageInputController._( MessageInputController._(
initialMessage: Message(text: text), initialMessage: Message(text: text),
textPatternStyle: textPatternStyle,
); );
/// Creates a controller for an editable text field from initial /// Creates a controller for an editable text field from initial
/// [attachments]. /// [attachments].
factory MessageInputController.fromAttachments( factory MessageInputController.fromAttachments(
List<Attachment> attachments, List<Attachment> attachments, {
) => Map<RegExp, TextStyleBuilder>? textPatternStyle,
}) =>
MessageInputController._( MessageInputController._(
initialMessage: Message(attachments: attachments), initialMessage: Message(attachments: attachments),
textPatternStyle: textPatternStyle,
); );
MessageInputController._({ MessageInputController._({
required Message initialMessage, required Message initialMessage,
}) : _textEditingController = Map<RegExp, TextStyleBuilder>? textPatternStyle,
TextEditingController.fromValue(TextEditingValue( }) : _textEditingController = MessageTextFieldController.fromValue(
text: initialMessage.text ?? '', initialMessage.text == null
composing: TextRange.collapsed(initialMessage.text?.length ?? 0), ? const TextEditingValue()
)), : TextEditingValue(
text: initialMessage.text!,
composing: TextRange.collapsed(initialMessage.text!.length),
),
textPatternStyle: textPatternStyle,
),
_initialMessage = initialMessage, _initialMessage = initialMessage,
super(initialMessage) { super(initialMessage) {
addListener(_textEditingSyncer); addListener(_textEditingSyncer);
@@ -52,10 +65,7 @@ class MessageInputController extends ValueNotifier<Message> {
void _textEditingSyncer() { void _textEditingSyncer() {
final cleanText = value.command == null final cleanText = value.command == null
? value.text ? value.text
: value.text?.replaceFirst( : value.text?.replaceFirst('/${value.command} ', '');
'/${value.command} ',
'',
);
if (cleanText != _textEditingController.text) { if (cleanText != _textEditingController.text) {
final previousOffset = _textEditingController.value.selection.start; final previousOffset = _textEditingController.value.selection.start;
@@ -73,8 +83,9 @@ class MessageInputController extends ValueNotifier<Message> {
Message get message => value; Message get message => value;
/// Returns the controller of the text field linked to this controller. /// Returns the controller of the text field linked to this controller.
TextEditingController get textEditingController => _textEditingController; MessageTextFieldController get textEditingController =>
final TextEditingController _textEditingController; _textEditingController;
final MessageTextFieldController _textEditingController;
/// Returns the text of the message. /// Returns the text of the message.
String get text => _textEditingController.text; String get text => _textEditingController.text;
@@ -174,6 +185,30 @@ class MessageInputController extends ValueNotifier<Message> {
attachments = []; 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. /// Returns the list of mentioned users in the message.
List<User> get mentionedUsers => value.mentionedUsers; List<User> get mentionedUsers => value.mentionedUsers;
@@ -220,9 +255,8 @@ class MessageInputController extends ValueNotifier<Message> {
/// Sets the [value] to the initial [Message] value. /// Sets the [value] to the initial [Message] value.
void reset({bool resetId = true}) { void reset({bool resetId = true}) {
if (resetId) { if (resetId) {
_initialMessage = _initialMessage.copyWith( final newId = const Uuid().v4();
id: const Uuid().v4(), _initialMessage = _initialMessage.copyWith(id: newId);
);
} }
value = _initialMessage; value = _initialMessage;
} }
@@ -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<RegExp, TextStyleBuilder>? 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 = <TextSpan>[];
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);
}
}
@@ -8,7 +8,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.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' export 'package:flutter/services.dart'
show show
File diff suppressed because it is too large Load Diff
@@ -259,6 +259,7 @@ class StreamChatThemeData {
sendButtonIdleColor: colorTheme.disabled, sendButtonIdleColor: colorTheme.disabled,
inputBackgroundColor: colorTheme.barsBg, inputBackgroundColor: colorTheme.barsBg,
inputTextStyle: textTheme.body, inputTextStyle: textTheme.body,
linkHighlightColor: colorTheme.accentPrimary,
idleBorderGradient: LinearGradient( idleBorderGradient: LinearGradient(
colors: [ colors: [
colorTheme.disabled, colorTheme.disabled,
@@ -65,6 +65,7 @@ class MessageInputThemeData with Diagnosticable {
this.idleBorderGradient, this.idleBorderGradient,
this.borderRadius, this.borderRadius,
this.expandButtonColor, this.expandButtonColor,
this.linkHighlightColor,
}); });
/// Duration of the [MessageInput] send button animation /// Duration of the [MessageInput] send button animation
@@ -73,6 +74,9 @@ class MessageInputThemeData with Diagnosticable {
/// Background color of [MessageInput] send button /// Background color of [MessageInput] send button
final Color? sendButtonColor; final Color? sendButtonColor;
/// Color of a link
final Color? linkHighlightColor;
/// Background color of [MessageInput] action buttons /// Background color of [MessageInput] action buttons
final Color? actionButtonColor; final Color? actionButtonColor;
@@ -110,6 +114,7 @@ class MessageInputThemeData with Diagnosticable {
Color? actionButtonColor, Color? actionButtonColor,
Color? sendButtonColor, Color? sendButtonColor,
Color? actionButtonIdleColor, Color? actionButtonIdleColor,
Color? linkHighlightColor,
Color? sendButtonIdleColor, Color? sendButtonIdleColor,
Color? expandButtonColor, Color? expandButtonColor,
TextStyle? inputTextStyle, TextStyle? inputTextStyle,
@@ -133,6 +138,7 @@ class MessageInputThemeData with Diagnosticable {
activeBorderGradient: activeBorderGradient ?? this.activeBorderGradient, activeBorderGradient: activeBorderGradient ?? this.activeBorderGradient,
idleBorderGradient: idleBorderGradient ?? this.idleBorderGradient, idleBorderGradient: idleBorderGradient ?? this.idleBorderGradient,
borderRadius: borderRadius ?? this.borderRadius, borderRadius: borderRadius ?? this.borderRadius,
linkHighlightColor: linkHighlightColor ?? this.linkHighlightColor,
); );
/// Linearly interpolate from one [MessageInputThemeData] to another. /// Linearly interpolate from one [MessageInputThemeData] to another.
@@ -161,6 +167,8 @@ class MessageInputThemeData with Diagnosticable {
Color.lerp(a.sendButtonIdleColor, b.sendButtonIdleColor, t), Color.lerp(a.sendButtonIdleColor, b.sendButtonIdleColor, t),
sendAnimationDuration: a.sendAnimationDuration, sendAnimationDuration: a.sendAnimationDuration,
inputDecoration: a.inputDecoration, inputDecoration: a.inputDecoration,
linkHighlightColor:
Color.lerp(a.linkHighlightColor, b.linkHighlightColor, t),
); );
/// Merges [this] [MessageInputThemeData] with the [other] /// Merges [this] [MessageInputThemeData] with the [other]
@@ -181,6 +189,7 @@ class MessageInputThemeData with Diagnosticable {
idleBorderGradient: other.idleBorderGradient, idleBorderGradient: other.idleBorderGradient,
borderRadius: other.borderRadius, borderRadius: other.borderRadius,
expandButtonColor: other.expandButtonColor, expandButtonColor: other.expandButtonColor,
linkHighlightColor: other.linkHighlightColor,
); );
} }
@@ -200,7 +209,8 @@ class MessageInputThemeData with Diagnosticable {
inputDecoration == other.inputDecoration && inputDecoration == other.inputDecoration &&
idleBorderGradient == other.idleBorderGradient && idleBorderGradient == other.idleBorderGradient &&
activeBorderGradient == other.activeBorderGradient && activeBorderGradient == other.activeBorderGradient &&
borderRadius == other.borderRadius; borderRadius == other.borderRadius &&
linkHighlightColor == other.linkHighlightColor;
@override @override
int get hashCode => int get hashCode =>
@@ -215,7 +225,8 @@ class MessageInputThemeData with Diagnosticable {
inputDecoration.hashCode ^ inputDecoration.hashCode ^
idleBorderGradient.hashCode ^ idleBorderGradient.hashCode ^
activeBorderGradient.hashCode ^ activeBorderGradient.hashCode ^
borderRadius.hashCode; borderRadius.hashCode ^
linkHighlightColor.hashCode;
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
@@ -232,6 +243,7 @@ class MessageInputThemeData with Diagnosticable {
..add(DiagnosticsProperty('activeBorderGradient', activeBorderGradient)) ..add(DiagnosticsProperty('activeBorderGradient', activeBorderGradient))
..add(DiagnosticsProperty('idleBorderGradient', idleBorderGradient)) ..add(DiagnosticsProperty('idleBorderGradient', idleBorderGradient))
..add(DiagnosticsProperty('borderRadius', borderRadius)) ..add(DiagnosticsProperty('borderRadius', borderRadius))
..add(ColorProperty('expandButtonColor', expandButtonColor)); ..add(ColorProperty('expandButtonColor', expandButtonColor))
..add(ColorProperty('linkHighlightColor', linkHighlightColor));
} }
} }
@@ -25,6 +25,8 @@ export 'src/mention_tile.dart';
export 'src/message_action.dart'; export 'src/message_action.dart';
export 'src/message_input/countdown_button.dart'; export 'src/message_input/countdown_button.dart';
export 'src/message_input/message_input.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_attachment_picker.dart';
export 'src/message_input/stream_message_send_button.dart'; export 'src/message_input/stream_message_send_button.dart';
export 'src/message_input/stream_message_text_field.dart'; export 'src/message_input/stream_message_text_field.dart';
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; 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() { void main() {
testWidgets( testWidgets(
@@ -7,7 +7,6 @@ export 'src/better_stream_builder.dart';
export 'src/channel_list_core.dart' hide ChannelListCoreState; export 'src/channel_list_core.dart' hide ChannelListCoreState;
export 'src/channels_bloc.dart'; export 'src/channels_bloc.dart';
export 'src/lazy_load_scroll_view.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_list_core.dart' hide MessageListCoreState;
export 'src/message_search_bloc.dart'; export 'src/message_search_bloc.dart';
export 'src/message_search_list_core.dart' hide MessageSearchListCoreState; export 'src/message_search_list_core.dart' hide MessageSearchListCoreState;