feat(ui): add support for OG Attachment preview.
Signed-off-by: xsahil03x <[email protected]>
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,
|
||||
|
||||
@@ -354,6 +354,7 @@ class MessageInputState extends State<MessageInput>
|
||||
final _imagePicker = ImagePicker();
|
||||
late final _focusNode = widget.focusNode ?? FocusNode();
|
||||
bool _inputEnabled = true;
|
||||
|
||||
bool get _commandEnabled => _effectiveController.value.command != null;
|
||||
bool _showCommandsOverlay = false;
|
||||
bool _showMentionsOverlay = false;
|
||||
@@ -371,6 +372,7 @@ class MessageInputState extends State<MessageInput>
|
||||
_effectiveController.value.status != MessageSendingStatus.sending;
|
||||
|
||||
RestorableMessageInputController? _controller;
|
||||
|
||||
MessageInputController get _effectiveController =>
|
||||
widget.messageInputController ?? _controller!.value;
|
||||
|
||||
@@ -517,6 +519,14 @@ class MessageInputState extends State<MessageInput>
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_ogAttachment != null)
|
||||
OGAttachmentPreview(
|
||||
attachment: _ogAttachment!,
|
||||
onDismissPreviewPressed: () {
|
||||
setState(() => _ogAttachment = null);
|
||||
_focusNode.unfocus();
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
@@ -538,7 +548,7 @@ class MessageInputState extends State<MessageInput>
|
||||
),
|
||||
),
|
||||
);
|
||||
if (_isEditing) {
|
||||
if (!_isEditing) {
|
||||
child = Material(
|
||||
elevation: 8,
|
||||
child: child,
|
||||
@@ -896,6 +906,7 @@ class MessageInputState extends State<MessageInput>
|
||||
_actionsShrunk = value.isNotEmpty && actionsLength > 1;
|
||||
});
|
||||
|
||||
_checkContainsUrlDebounced.call([value, context]);
|
||||
_checkCommands(value, context);
|
||||
_checkMentions(value, context);
|
||||
_checkEmoji(value, context);
|
||||
@@ -918,14 +929,52 @@ class MessageInputState extends State<MessageInput>
|
||||
return context.translations.writeAMessageLabel;
|
||||
}
|
||||
|
||||
void _checkEmoji(String s, BuildContext context) {
|
||||
if (s.isNotEmpty &&
|
||||
Attachment? _ogAttachment;
|
||||
String? _lastSearchedContainsUrlText;
|
||||
CancelableOperation? _enrichUrlOperation;
|
||||
|
||||
late final _checkContainsUrlDebounced = debounce(
|
||||
(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 regex =
|
||||
RegExp(r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+');
|
||||
final matchedUrls = regex.allMatches(value);
|
||||
if (matchedUrls.isEmpty) return;
|
||||
|
||||
final firstMatchedUrl = matchedUrls.first.group(0)!;
|
||||
|
||||
// If the parsed url matches the ogAttachment url, don't do anything
|
||||
if (_ogAttachment?.titleLink == firstMatchedUrl) return;
|
||||
|
||||
final client = StreamChat.of(context).client;
|
||||
|
||||
_enrichUrlOperation = CancelableOperation.fromFuture(
|
||||
client.enrichUrl(firstMatchedUrl).then((ogAttachment) {
|
||||
final attachment = Attachment.fromOGAttachment(ogAttachment);
|
||||
setState(() => _ogAttachment = attachment);
|
||||
}).onError((error, stackTrace) {
|
||||
// Reset the ogAttachment if there was an error
|
||||
setState(() => _ogAttachment = null);
|
||||
if (error != null) {
|
||||
widget.onError?.call(error, stackTrace);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
|
||||
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,
|
||||
@@ -941,14 +990,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('@')) {
|
||||
@@ -964,11 +1010,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) {
|
||||
@@ -1030,10 +1076,7 @@ class MessageInputState extends State<MessageInput>
|
||||
}
|
||||
|
||||
final splits = _effectiveController.text
|
||||
.substring(
|
||||
0,
|
||||
_effectiveController.selectionStart,
|
||||
)
|
||||
.substring(0, _effectiveController.selectionStart)
|
||||
.split('@');
|
||||
final query = splits.last.toLowerCase();
|
||||
|
||||
@@ -1082,10 +1125,7 @@ class MessageInputState extends State<MessageInput>
|
||||
}
|
||||
|
||||
final splits = _effectiveController.text
|
||||
.substring(
|
||||
0,
|
||||
_effectiveController.baseOffset,
|
||||
)
|
||||
.substring(0, _effectiveController.baseOffset)
|
||||
.split(':');
|
||||
|
||||
final query = splits.last.toLowerCase();
|
||||
@@ -1222,11 +1262,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),
|
||||
@@ -1572,10 +1608,19 @@ class MessageInputState extends State<MessageInput>
|
||||
Future<void> sendMessage() async {
|
||||
var message = _effectiveController.value;
|
||||
|
||||
// Add ogAttachment if present
|
||||
final skipEnrichUrl = _ogAttachment == null;
|
||||
if (!skipEnrichUrl) {
|
||||
message = message.copyWith(
|
||||
attachments: [...message.attachments, _ogAttachment!],
|
||||
);
|
||||
}
|
||||
|
||||
var shouldKeepFocus = widget.shouldKeepFocusAfterMessage;
|
||||
|
||||
shouldKeepFocus ??= !_commandEnabled;
|
||||
|
||||
_ogAttachment = null;
|
||||
_effectiveController.reset();
|
||||
|
||||
if (widget.preMessageSending != null) {
|
||||
@@ -1590,10 +1635,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) {
|
||||
@@ -1702,3 +1753,73 @@ class MessageInputState extends State<MessageInput>
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
}
|
||||
|
||||
class OGAttachmentPreview extends StatelessWidget {
|
||||
const OGAttachmentPreview({
|
||||
Key? key,
|
||||
required this.attachment,
|
||||
this.onDismissPreviewPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
final Attachment attachment;
|
||||
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.0),
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user