feat(ui): add support for OG Attachment preview.

Signed-off-by: xsahil03x <[email protected]>
This commit is contained in:
Sahil Kumar
2021-12-20 16:11:12 +05:30
committed by xsahil03x
parent ec28d00e81
commit e19cc87362
5 changed files with 231 additions and 39 deletions
@@ -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,
),
],
);
}
}