Merge pull request #835 from GetStream/feat/unfurl-url-client-side
feat(ui): add support for og attachment preview
This commit is contained in:
@@ -493,6 +493,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
|
||||
@@ -540,6 +541,7 @@ class Channel {
|
||||
id!,
|
||||
type,
|
||||
skipPush: skipPush,
|
||||
skipEnrichUrl: skipEnrichUrl,
|
||||
);
|
||||
state!.addMessage(response.message);
|
||||
if (cooldown > 0) cooldownStartedAt = DateTime.now();
|
||||
@@ -556,7 +558,10 @@ class Channel {
|
||||
///
|
||||
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
|
||||
/// before actually updating the message.
|
||||
Future<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
|
||||
@@ -594,7 +599,10 @@ class Channel {
|
||||
message = await attachmentsUploadCompleter.future;
|
||||
}
|
||||
|
||||
final response = await _client.updateMessage(message);
|
||||
final response = await _client.updateMessage(
|
||||
message,
|
||||
skipEnrichUrl: skipEnrichUrl,
|
||||
);
|
||||
|
||||
final m = response.message.copyWith(
|
||||
ownReactions: message.ownReactions,
|
||||
@@ -624,12 +632,14 @@ class Channel {
|
||||
Message message, {
|
||||
Map<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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+53
-19
@@ -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
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user