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(
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<UpdateMessageResponse> updateMessage(Message message) async {
Future<UpdateMessageResponse> 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<String, Object?>? set,
List<String>? unset,
bool skipEnrichUrl = false,
}) async {
try {
final response = await _client.partialUpdateMessage(
message.id,
set: set,
unset: unset,
skipEnrichUrl: skipEnrichUrl,
);
final updatedMessage = response.message.copyWith(
@@ -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<UpdateMessageResponse> updateMessage(Message message) =>
_chatApi.message.updateMessage(message);
Future<UpdateMessageResponse> 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<String, Object?>? set,
List<String>? unset,
bool skipEnrichUrl = false,
}) =>
_chatApi.message.partialUpdateMessage(
messageId,
set: set,
unset: unset,
skipEnrichUrl: skipEnrichUrl,
);
/// Deletes the given message
@@ -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<UpdateMessageResponse> 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<String, Object?>? set,
List<String>? 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);
@@ -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<Object?> get props => [
id,
@@ -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);
});
@@ -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).
@@ -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<MessageInputState>();
assert(
messageInputState != null,
'You must have a MessageInput widget as ancestor of your widget tree',
);
return messageInputState!;
}
}
/// State of [MessageInput]
class MessageInputState extends State<MessageInput>
with RestorationMixin<MessageInput> {
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<MessageInput>
_effectiveController.value.status != MessageSendingStatus.sending;
RestorableMessageInputController? _controller;
MessageInputController get _effectiveController =>
widget.messageInputController ?? _controller!.value;
@@ -398,11 +390,7 @@ class MessageInputState extends State<MessageInput>
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<MessageInput>
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<MessageInput>
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<MessageInput>
);
}
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<MessageInput>
_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<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,
@@ -962,14 +1036,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('@')) {
@@ -985,11 +1056,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) {
@@ -1051,10 +1122,7 @@ class MessageInputState extends State<MessageInput>
}
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<MessageInput>
}
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<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(
@@ -1243,11 +1311,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),
@@ -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
void _addAttachments(Iterable<Attachment> attachments) {
final limit = widget.attachmentLimit;
@@ -1591,6 +1647,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;
@@ -1611,10 +1669,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) {
@@ -1723,3 +1787,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,
),
],
);
}
}
@@ -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> {
/// 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) =>
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,
) =>
List<Attachment> attachments, {
Map<RegExp, TextStyleBuilder>? 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<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);
@@ -52,10 +65,7 @@ class MessageInputController extends ValueNotifier<Message> {
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> {
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<Message> {
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;
@@ -220,9 +255,8 @@ class MessageInputController extends ValueNotifier<Message> {
/// 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;
}
@@ -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';
@@ -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(
@@ -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;