diff --git a/e2e_app/integration_test/hyper_links_test.dart b/e2e_app/integration_test/hyper_links_test.dart index 517b3dd8e3..3427325c86 100644 --- a/e2e_app/integration_test/hyper_links_test.dart +++ b/e2e_app/integration_test/hyper_links_test.dart @@ -9,15 +9,12 @@ void main() { const youtubeLink = 'https://youtube.com/watch?v=xOX7MsrbaPY'; const giphyLink = 'https://giphy.com/gifs/test-gw3IWyGkC0rsazTi'; - // An OG attachment without `title_link` renders as an image, not a preview. - const noPreviewForImageOg = 'https://linear.app/stream/issue/FLU-1088'; // Dismissing the composer's preview doesn't send `skip_enrich_url`. const enrichedAfterDismissal = 'https://linear.app/stream/issue/FLU-1089'; streamTestWithEnv( allureId: '12706', description: 'user sends a Unsplash link with a preview', - skip: noPreviewForImageOg, body: (env) async { step('GIVEN the user opens a channel'); await env.userRobot.login().openChannel(); @@ -41,7 +38,7 @@ void main() { streamTestWithEnv( allureId: '12707', description: 'user sends a Unsplash link without a preview', - skip: noPreviewForImageOg, + skip: enrichedAfterDismissal, body: (env) async { step('GIVEN the user opens a channel'); await env.userRobot.login().openChannel(); @@ -68,7 +65,6 @@ void main() { streamTestWithEnv( allureId: '12708', description: 'participant sends a Unsplash link', - skip: noPreviewForImageOg, body: (env) async { step('GIVEN the user opens a channel'); await env.userRobot.login().openChannel(); diff --git a/e2e_app/integration_test/message_list_test.dart b/e2e_app/integration_test/message_list_test.dart index 6acdba5db5..8e032ae632 100644 --- a/e2e_app/integration_test/message_list_test.dart +++ b/e2e_app/integration_test/message_list_test.dart @@ -931,7 +931,6 @@ void main() { streamTestWithEnv( allureId: '12774', description: 'user sees a link preview for their own Unsplash link', - skip: 'https://linear.app/stream/issue/FLU-1088', body: (env) async { const link = 'https://unsplash.com/photos/1_2d3MRbI9c'; @@ -949,7 +948,6 @@ void main() { streamTestWithEnv( allureId: '12775', description: 'user sees a link preview for an Unsplash link', - skip: 'https://linear.app/stream/issue/FLU-1088', body: (env) async { const link = 'https://unsplash.com/photos/1_2d3MRbI9c'; diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index b19ae2519f..bc1836bae9 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,5 +1,10 @@ ## Upcoming +🔄 Changed + +- `Attachment.type` is now `url_preview` for every attachment scraped from a link in the message text, including a link to an image or file. Use `Attachment.rawType` to read the type it was created or received with. +- `Attachment.toJson` keeps the original `type` of an attachment scraped from a link, such as `video` or `image`, instead of writing `url_preview`. + 🐞 Fixed - Fixed a draft saved or deleted while offline not being kept. diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index 8d37339bef..9ca4f05130 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -66,8 +66,7 @@ class Attachment extends Equatable { ); factory Attachment.fromOGAttachment(OGAttachmentResponse ogAttachment) => Attachment( - // If the type is not specified, we default to urlPreview. - type: ogAttachment.type ?? AttachmentType.urlPreview, + type: ogAttachment.type, title: ogAttachment.title, titleLink: ogAttachment.titleLink, text: ogAttachment.text, @@ -80,26 +79,27 @@ class Attachment extends Equatable { uploadState: const UploadState.success(), ); - ///The attachment type based on the URL resource. This can be: audio, - ///image or video + /// The kind of attachment. + /// + /// An attachment scraped from a link in the message text is + /// [AttachmentType.urlPreview], whatever the link points to. [rawType] holds + /// the type it was created or received with. @JsonKey( - includeIfNull: false, - toJson: AttachmentType.toJson, + includeToJson: false, fromJson: AttachmentType.fromJson, ) AttachmentType? get type { - // If the attachment contains ogScrapeUrl as well as titleLink, we consider - // it as a urlPreview. - if (ogScrapeUrl != null && titleLink != null) { - return AttachmentType.urlPreview; - } + // If the attachment was scraped from a link, we consider it as a + // urlPreview, whatever the link points to. + if (ogScrapeUrl != null) return AttachmentType.urlPreview; return _type; } final AttachmentType? _type; - /// The raw attachment type. + /// The type this attachment was created or received with. + @JsonKey(name: 'type', includeFromJson: false, includeToJson: true) String? get rawType => _type; ///The link to which the attachment message points to. @@ -250,7 +250,7 @@ class Attachment extends Equatable { Map? extraData, }) => Attachment( id: id ?? this.id, - type: type ?? this.type, + type: type ?? rawType, titleLink: titleLink ?? this.titleLink, title: title ?? this.title, thumbUrl: thumbUrl ?? this.thumbUrl, @@ -278,7 +278,7 @@ class Attachment extends Equatable { Attachment merge(Attachment? other) { if (other == null) return this; return copyWith( - type: other.type, + type: other.rawType, titleLink: other.titleLink, title: other.title, thumbUrl: other.thumbUrl, @@ -307,7 +307,7 @@ class Attachment extends Equatable { @override List get props => [ id, - type, + rawType, titleLink, title, thumbUrl, diff --git a/packages/stream_chat/lib/src/core/models/attachment.g.dart b/packages/stream_chat/lib/src/core/models/attachment.g.dart index de9fd74d4a..9ad059b1f1 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.g.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.g.dart @@ -37,7 +37,7 @@ Attachment _$AttachmentFromJson(Map json) => Attachment( ); Map _$AttachmentToJson(Attachment instance) => { - 'type': ?AttachmentType.toJson(instance.type), + 'type': ?instance.rawType, 'title_link': ?instance.titleLink, 'title': ?instance.title, 'thumb_url': ?instance.thumbUrl, diff --git a/packages/stream_chat/test/src/core/models/attachment_test.dart b/packages/stream_chat/test/src/core/models/attachment_test.dart index 4417ef1173..a2a03b6468 100644 --- a/packages/stream_chat/test/src/core/models/attachment_test.dart +++ b/packages/stream_chat/test/src/core/models/attachment_test.dart @@ -1,3 +1,4 @@ +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.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; @@ -161,6 +162,54 @@ void main() { ); expect(autoUrlAttachment.isUrlPreview, isTrue); }); + + test('reports a scraped image without a title link as a url preview', () { + final scrapedImageAttachment = Attachment( + type: 'image', + ogScrapeUrl: 'https://images.example.com/photo.jpg', + imageUrl: 'https://images.example.com/photo.jpg', + ); + + expect(scrapedImageAttachment.type, AttachmentType.urlPreview); + }); + + test('serializes the original type of a scraped attachment', () { + final scrapedVideoAttachment = Attachment( + type: 'video', + ogScrapeUrl: 'https://youtube.com/watch?v=xOX7MsrbaPY', + titleLink: 'https://www.youtube.com/watch?v=xOX7MsrbaPY', + ); + + expect(scrapedVideoAttachment.toJson()['type'], 'video'); + }); + + test('keeps the original type of a scraped attachment when copied', () { + final scrapedImageAttachment = Attachment( + type: 'image', + ogScrapeUrl: 'https://images.example.com/photo.jpg', + ); + + final copy = scrapedImageAttachment.copyWith(title: 'Photo'); + + expect(copy.toJson()['type'], 'image'); + }); + + test('tells apart scraped attachments with different original types', () { + const link = 'https://example.com/media'; + final scrapedImage = Attachment(id: 'og', type: 'image', ogScrapeUrl: link); + final scrapedVideo = Attachment(id: 'og', type: 'video', ogScrapeUrl: link); + + expect(scrapedImage, isNot(scrapedVideo)); + }); + + test('sends no type for a scraped link the server gave no type', () { + final ogAttachment = OGAttachmentResponse()..ogScrapeUrl = 'https://getstream.io/blog'; + + final attachment = Attachment.fromOGAttachment(ogAttachment); + + expect(attachment.type, AttachmentType.urlPreview); + expect(attachment.toJson(), isNot(contains('type'))); + }); }); }); } diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index d529efd077..593d5aa96d 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -21,6 +21,7 @@ - Fixed a voice recording sent as a reply without text showing inside a filled container instead of directly on the message bubble. - Fixed a mention rendering as another mentioned user's shorter mention followed by leftover text, such as `@Anna` next to `@Ann`. - Fixed the poll creator showing the reorder handle and remove button when there is only one option. +- Fixed a link that points straight to an image showing as a plain image instead of a link preview, and appearing twice in the message composer. - Fixed the keyboard closing when removing the link preview in the message composer. - Fixed the composer's command and mention suggestions staying open after tapping outside the composer, such as on the message list. - Fixed "Message deleted" in a quoted reply showing in a muted gray instead of the reply's text color. diff --git a/packages/stream_chat_flutter/lib/src/attachment/link_preview_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/link_preview_attachment.dart index c569eda9cc..6ed680bdf4 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/link_preview_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/link_preview_attachment.dart @@ -142,7 +142,7 @@ class DefaultStreamLinkPreviewAttachment extends StatelessWidget { color: colorScheme.textPrimary, ), ), - if (urlAttachment.titleLink case final titleLink?) + if (urlAttachment.titleLink ?? urlAttachment.ogScrapeUrl case final link?) Row( mainAxisSize: .min, spacing: spacing.xxs, @@ -150,7 +150,7 @@ class DefaultStreamLinkPreviewAttachment extends StatelessWidget { Icon(icons.link, size: 12), Expanded( child: Text( - titleLink, + link, maxLines: 1, overflow: .ellipsis, style: textTheme.metadataDefault.copyWith( diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart index 5ee467e781..6793333e7a 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart @@ -43,7 +43,7 @@ class _DefaultStreamMessageComposerInputHeader extends StatelessWidget { final ogAttachment = controller.ogAttachment; final nonOGAttachments = controller.attachments .where((it) { - return it.titleLink == null && it.type != AttachmentType.voiceRecording; + return it.ogScrapeUrl == null && it.type != AttachmentType.voiceRecording; }) .toList(growable: false); final voiceRecordings = controller.attachments @@ -126,7 +126,10 @@ class _DefaultStreamMessageComposerInputHeader extends StatelessWidget { child: core.StreamMessageComposerLinkPreviewAttachment( title: ogAttachment.title != null ? Text(ogAttachment.title!) : null, subtitle: ogAttachment.text != null ? Text(ogAttachment.text!) : null, - caption: ogAttachment.titleLink != null ? Text(ogAttachment.titleLink!) : null, + caption: switch (ogAttachment.titleLink ?? ogAttachment.ogScrapeUrl) { + final link? => Text(link), + null => null, + }, thumbnail: switch (ogAttachment.imageUrl) { final imageUrl? when imageUrl.isNotEmpty => core.StreamNetworkImage(imageUrl, fit: .cover), _ => null, diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index 72a9acd2e5..498caae9f2 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -1478,7 +1478,7 @@ class DefaultStreamMessageComposerState extends State channel.state).thenReturn(channelState); + + const imageUrl = 'https://upload.wikimedia.org/flower.jpg'; + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(), + child: StreamChannel( + channel: channel, + child: SizedBox( + child: StreamLinkPreviewAttachment( + message: Message(), + urlAttachment: Attachment( + type: 'image', + imageUrl: imageUrl, + ogScrapeUrl: imageUrl, + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text(imageUrl), findsOneWidget); + }, + ); } diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_url_enrichment_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_url_enrichment_test.dart index 2eacebab6b..8ae6b7f64c 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_composer_url_enrichment_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_url_enrichment_test.dart @@ -115,5 +115,20 @@ void main() { expect(preview, findsNothing); expect(tester.widget(find.byType(EditableText)).focusNode.hasFocus, isTrue); }); + + testWidgets('shows a scraped image as a single link preview', (tester) async { + const url = 'https://example.com/photo.jpg'; + when(() => client.enrichUrl(any())).thenAnswer( + (_) async => OGAttachmentResponse() + ..type = 'image' + ..imageUrl = url + ..ogScrapeUrl = url, + ); + + await enrichUrlFrom(tester, 'look $url'); + + expect(find.byType(StreamMessageComposerAttachmentList), findsNothing); + expect(find.text(url), findsOneWidget); + }); }); }