Merge pull request #835 from GetStream/feat/unfurl-url-client-side
feat(ui): add support for og attachment preview
This commit is contained in:
@@ -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,17 +336,6 @@ 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<MessageInputState>();
|
||||
assert(
|
||||
messageInputState != null,
|
||||
'You must have a MessageInput widget as ancestor of your widget tree',
|
||||
);
|
||||
return messageInputState!;
|
||||
}
|
||||
}
|
||||
|
||||
/// State of [MessageInput]
|
||||
@@ -354,6 +344,7 @@ class MessageInputState extends State<MessageInput>
|
||||
final _imagePicker = ImagePicker();
|
||||
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<MessageInput>
|
||||
_effectiveController.value.status != MessageSendingStatus.sending;
|
||||
|
||||
RestorableMessageInputController? _controller;
|
||||
|
||||
MessageInputController get _effectiveController =>
|
||||
widget.messageInputController ?? _controller!.value;
|
||||
|
||||
@@ -528,6 +520,14 @@ class MessageInputState extends State<MessageInput>
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_effectiveController.ogAttachment != null)
|
||||
OGAttachmentPreview(
|
||||
attachment: _effectiveController.ogAttachment!,
|
||||
onDismissPreviewPressed: () {
|
||||
_effectiveController.clearOGAttachment();
|
||||
_focusNode.unfocus();
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
@@ -907,6 +907,7 @@ class MessageInputState extends State<MessageInput>
|
||||
_actionsShrunk = value.isNotEmpty && actionsLength > 1;
|
||||
});
|
||||
|
||||
_checkContainsUrl(value, context);
|
||||
_checkCommands(value, context);
|
||||
_checkMentions(value, context);
|
||||
_checkEmoji(value, context);
|
||||
@@ -929,14 +930,75 @@ class MessageInputState extends State<MessageInput>
|
||||
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 = <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.text
|
||||
.substring(
|
||||
0,
|
||||
_effectiveController.baseOffset,
|
||||
)
|
||||
.substring(0, _effectiveController.baseOffset)
|
||||
.contains(':')) {
|
||||
final textToSelection = _effectiveController.text.substring(
|
||||
0,
|
||||
@@ -952,14 +1014,11 @@ class MessageInputState extends State<MessageInput>
|
||||
}
|
||||
}
|
||||
|
||||
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('@')) {
|
||||
@@ -975,11 +1034,11 @@ class MessageInputState extends State<MessageInput>
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1041,10 +1100,7 @@ class MessageInputState extends State<MessageInput>
|
||||
}
|
||||
|
||||
final splits = _effectiveController.text
|
||||
.substring(
|
||||
0,
|
||||
_effectiveController.selectionStart,
|
||||
)
|
||||
.substring(0, _effectiveController.selectionStart)
|
||||
.split('@');
|
||||
final query = splits.last.toLowerCase();
|
||||
|
||||
@@ -1093,10 +1149,7 @@ class MessageInputState extends State<MessageInput>
|
||||
}
|
||||
|
||||
final splits = _effectiveController.text
|
||||
.substring(
|
||||
0,
|
||||
_effectiveController.baseOffset,
|
||||
)
|
||||
.substring(0, _effectiveController.baseOffset)
|
||||
.split(':');
|
||||
|
||||
final query = splits.last.toLowerCase();
|
||||
@@ -1144,11 +1197,14 @@ class MessageInputState extends State<MessageInput>
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -1233,11 +1289,7 @@ class MessageInputState extends State<MessageInput>
|
||||
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),
|
||||
@@ -1439,14 +1491,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
|
||||
void _addAttachments(Iterable<Attachment> attachments) {
|
||||
final limit = widget.attachmentLimit;
|
||||
@@ -1581,6 +1625,8 @@ class MessageInputState extends State<MessageInput>
|
||||
|
||||
/// Sends the current message
|
||||
Future<void> sendMessage() async {
|
||||
final skipEnrichUrl = _effectiveController.ogAttachment == null;
|
||||
|
||||
var message = _effectiveController.value;
|
||||
|
||||
var shouldKeepFocus = widget.shouldKeepFocusAfterMessage;
|
||||
@@ -1601,10 +1647,16 @@ class MessageInputState extends State<MessageInput>
|
||||
|
||||
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) {
|
||||
@@ -1713,3 +1765,78 @@ class MessageInputState extends State<MessageInput>
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// A value listenable builder related to a [Message].
|
||||
///
|
||||
/// Pass in a [MessageInputController] as the `valueListenable`.
|
||||
typedef MessageValueListenableBuilder = ValueListenableBuilder<Message>;
|
||||
|
||||
/// Controller for storing and mutating a [Message] value.
|
||||
class MessageInputController extends ValueNotifier<Message> {
|
||||
/// Creates a controller for an editable text field.
|
||||
///
|
||||
/// This constructor treats a null [message] argument as if it were the empty
|
||||
/// message.
|
||||
factory MessageInputController({
|
||||
Message? message,
|
||||
Map<RegExp, TextStyleBuilder>? textPatternStyle,
|
||||
}) =>
|
||||
MessageInputController._(
|
||||
initialMessage: message ?? Message(),
|
||||
textPatternStyle: textPatternStyle,
|
||||
);
|
||||
|
||||
/// Creates a controller for an editable text field from an initial [text].
|
||||
factory MessageInputController.fromText(
|
||||
String? text, {
|
||||
Map<RegExp, TextStyleBuilder>? textPatternStyle,
|
||||
}) =>
|
||||
MessageInputController._(
|
||||
initialMessage: Message(text: text),
|
||||
textPatternStyle: textPatternStyle,
|
||||
);
|
||||
|
||||
/// Creates a controller for an editable text field from initial
|
||||
/// [attachments].
|
||||
factory MessageInputController.fromAttachments(
|
||||
List<Attachment> attachments, {
|
||||
Map<RegExp, TextStyleBuilder>? textPatternStyle,
|
||||
}) =>
|
||||
MessageInputController._(
|
||||
initialMessage: Message(attachments: attachments),
|
||||
textPatternStyle: textPatternStyle,
|
||||
);
|
||||
|
||||
MessageInputController._({
|
||||
required Message initialMessage,
|
||||
Map<RegExp, TextStyleBuilder>? 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);
|
||||
}
|
||||
|
||||
void _textEditingSyncer() {
|
||||
final cleanText = value.command == null
|
||||
? value.text
|
||||
: value.text?.replaceFirst('/${value.command} ', '');
|
||||
|
||||
if (cleanText != _textEditingController.text) {
|
||||
final previousOffset = _textEditingController.value.selection.start;
|
||||
final previousText = _textEditingController.text;
|
||||
final diff = (cleanText?.length ?? 0) - previousText.length;
|
||||
_textEditingController
|
||||
..text = cleanText ?? ''
|
||||
..selection = TextSelection.collapsed(
|
||||
offset: previousOffset + diff,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current message associated with this controller.
|
||||
Message get message => value;
|
||||
|
||||
/// Returns the controller of the text field linked to this controller.
|
||||
MessageTextFieldController get textEditingController =>
|
||||
_textEditingController;
|
||||
final MessageTextFieldController _textEditingController;
|
||||
|
||||
/// Returns the text of the message.
|
||||
String get text => _textEditingController.text;
|
||||
|
||||
Message _initialMessage;
|
||||
|
||||
/// Sets the message.
|
||||
set message(Message message) {
|
||||
value = message;
|
||||
}
|
||||
|
||||
/// Sets the message that's being quoted.
|
||||
set quotedMessage(Message message) {
|
||||
value = value.copyWith(
|
||||
quotedMessage: message,
|
||||
quotedMessageId: message.id,
|
||||
);
|
||||
}
|
||||
|
||||
/// Clears the quoted message.
|
||||
void clearQuotedMessage() {
|
||||
value = value.copyWith(
|
||||
quotedMessageId: null,
|
||||
quotedMessage: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Sets a command for the message.
|
||||
set command(Command command) {
|
||||
value = value.copyWith(
|
||||
command: command.name,
|
||||
text: '/${command.name} ',
|
||||
);
|
||||
}
|
||||
|
||||
/// Sets the text of the message.
|
||||
set text(String newText) {
|
||||
var newTextWithCommand = newText;
|
||||
if (value.command != null) {
|
||||
if (!newText.startsWith('/${value.command}')) {
|
||||
newTextWithCommand = '/${value.command} $newText';
|
||||
}
|
||||
}
|
||||
value = value.copyWith(text: newTextWithCommand);
|
||||
}
|
||||
|
||||
/// Returns the baseOffset of the text field.
|
||||
int get baseOffset => textEditingController.selection.baseOffset;
|
||||
|
||||
/// Returns the start of the selection of the text field.
|
||||
int get selectionStart => textEditingController.selection.start;
|
||||
|
||||
/// Sets the [showInChannel] flag of the message.
|
||||
set showInChannel(bool newValue) {
|
||||
value = value.copyWith(showInChannel: newValue);
|
||||
}
|
||||
|
||||
/// Returns true if the message is in a thread and
|
||||
/// should be shown in the main channel as well.
|
||||
bool get showInChannel => value.showInChannel ?? false;
|
||||
|
||||
/// Returns the attachments of the message.
|
||||
List<Attachment> get attachments => value.attachments;
|
||||
|
||||
/// Sets the list of [attachments] for the message.
|
||||
set attachments(List<Attachment> attachments) {
|
||||
value = value.copyWith(attachments: attachments);
|
||||
}
|
||||
|
||||
/// Adds a new attachment to the message.
|
||||
void addAttachment(Attachment attachment) {
|
||||
attachments = [...attachments, attachment];
|
||||
}
|
||||
|
||||
/// Adds a new attachment at the specified [index].
|
||||
void addAttachmentAt(int index, Attachment attachment) {
|
||||
attachments = [...attachments]..insert(index, attachment);
|
||||
}
|
||||
|
||||
/// Removes the specified [attachment] from the message.
|
||||
void removeAttachment(Attachment attachment) {
|
||||
attachments = [...attachments]..remove(attachment);
|
||||
}
|
||||
|
||||
/// Remove the attachment with the given [attachmentId].
|
||||
void removeAttachmentById(String attachmentId) {
|
||||
attachments = [...attachments]..removeWhere((it) => it.id == attachmentId);
|
||||
}
|
||||
|
||||
/// Removes the attachment at the given [index].
|
||||
void removeAttachmentAt(int index) {
|
||||
attachments = [...attachments]..removeAt(index);
|
||||
}
|
||||
|
||||
/// Clears the message attachments.
|
||||
void clearAttachments() {
|
||||
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<User> get mentionedUsers => value.mentionedUsers;
|
||||
|
||||
/// Sets the mentioned users.
|
||||
set mentionedUsers(List<User> users) {
|
||||
value = value.copyWith(mentionedUsers: users);
|
||||
}
|
||||
|
||||
/// Adds a user to the list of mentioned users.
|
||||
void addMentionedUser(User user) {
|
||||
mentionedUsers = [...mentionedUsers, user];
|
||||
}
|
||||
|
||||
/// Removes the specified [user] from the mentioned users list.
|
||||
void removeMentionedUser(User user) {
|
||||
mentionedUsers = [...mentionedUsers]..remove(user);
|
||||
}
|
||||
|
||||
/// Removes the mentioned user with the given [userId].
|
||||
void removeMentionedUserById(String userId) {
|
||||
mentionedUsers = [...mentionedUsers]..removeWhere((it) => it.id == userId);
|
||||
}
|
||||
|
||||
/// Removes all mentioned users from the message.
|
||||
void clearMentionedUsers() {
|
||||
mentionedUsers = [];
|
||||
}
|
||||
|
||||
/// Sets the [message], or [value], to empty.
|
||||
///
|
||||
/// After calling this function, [text], [attachments] and [mentionedUsers]
|
||||
/// will all be empty.
|
||||
///
|
||||
/// Calling this will notify all the listeners of this
|
||||
/// [MessageInputController] that they need to update
|
||||
/// (calls [notifyListeners]). For this reason,
|
||||
/// this method should only be called between frames, e.g. in response to user
|
||||
/// actions, not during the build, layout, or paint phases.
|
||||
void clear() {
|
||||
value = Message();
|
||||
_textEditingController.clear();
|
||||
}
|
||||
|
||||
/// Sets the [value] to the initial [Message] value.
|
||||
void reset({bool resetId = true}) {
|
||||
if (resetId) {
|
||||
final newId = const Uuid().v4();
|
||||
_initialMessage = _initialMessage.copyWith(id: newId);
|
||||
}
|
||||
value = _initialMessage;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
removeListener(_textEditingSyncer);
|
||||
_textEditingController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// A [RestorableProperty] that knows how to store and restore a
|
||||
/// [MessageInputController].
|
||||
///
|
||||
/// The [MessageInputController] is accessible via the [value] getter. During
|
||||
/// state restoration, the property will restore [MessageInputController.value]
|
||||
/// to the value it had when the restoration data it is getting restored from
|
||||
/// was collected.
|
||||
class RestorableMessageInputController
|
||||
extends RestorableChangeNotifier<MessageInputController> {
|
||||
/// Creates a [RestorableMessageInputController].
|
||||
///
|
||||
/// This constructor creates a default [Message] when no `message` argument
|
||||
/// is supplied.
|
||||
RestorableMessageInputController({Message? message})
|
||||
: _initialValue = message ?? Message();
|
||||
|
||||
/// Creates a [RestorableMessageInputController] from an initial
|
||||
/// [text] value.
|
||||
factory RestorableMessageInputController.fromText(String? text) =>
|
||||
RestorableMessageInputController(message: Message(text: text));
|
||||
|
||||
final Message _initialValue;
|
||||
|
||||
@override
|
||||
MessageInputController createDefaultValue() =>
|
||||
MessageInputController(message: _initialValue);
|
||||
|
||||
@override
|
||||
MessageInputController fromPrimitives(Object? data) {
|
||||
final message = Message.fromJson(json.decode(data! as String));
|
||||
return MessageInputController(message: message);
|
||||
}
|
||||
|
||||
@override
|
||||
String toPrimitives() => json.encode(value.value);
|
||||
}
|
||||
@@ -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/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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -259,6 +259,7 @@ class StreamChatThemeData {
|
||||
sendButtonIdleColor: colorTheme.disabled,
|
||||
inputBackgroundColor: colorTheme.barsBg,
|
||||
inputTextStyle: textTheme.body,
|
||||
linkHighlightColor: colorTheme.accentPrimary,
|
||||
idleBorderGradient: LinearGradient(
|
||||
colors: [
|
||||
colorTheme.disabled,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'should instantiate a new MessageInputController with empty message',
|
||||
(tester) async {
|
||||
final controller = MessageInputController()..text = 'test';
|
||||
|
||||
expect(controller.text, 'test');
|
||||
expect(controller.message.text, 'test');
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user