Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5fd710f
test(e2e): add e2e_app, port native e2e suites, one app process per C…
testableapple Oct 23, 2026
0b8aa6d
chore(repo): ignore e2e mock server, Allure results and allurectl in …
testableapple Oct 23, 2026
b4979e4
test(repo): link every e2e test to its Allure TestOps case
testableapple Oct 23, 2026
b71afe2
Merge remote-tracking branch 'origin/master' into test/e2e-native-parity
testableapple Oct 23, 2026
b03e78e
test(repo): unmount the e2e app before resetting the client, verify c…
testableapple Oct 23, 2026
3bb8bd3
ci(repo): run PR e2e on API 36, use a preinstalled NDK, retry failed …
testableapple Oct 23, 2026
e7510b3
test(repo): retype into a field that ignored the text
testableapple Oct 23, 2026
1e75428
test(repo): keep the copied message id in the e2e app, bound the vid…
testableapple Oct 23, 2026
4b7a5fa
Merge branch 'master' into test/e2e-native-parity
testableapple Oct 23, 2026
8d9cef2
test(repo): scroll until a message is on screen, stop when the emulat…
testableapple Oct 23, 2026
0e4da80
ci(repo): give the e2e app's Gradle the sample app's memory settings
testableapple Oct 23, 2026
4a66a48
Bump timeout
testableapple Oct 24, 2026
ab90f31
test(repo): restart a dead emulator during the e2e run
testableapple Oct 24, 2026
c883599
test(repo): time-limit e2e runs so a dead emulator can't hang the job
testableapple Oct 24, 2026
87586b9
fix(repo): look up timeout through sh so the lane runs on Linux
testableapple Oct 24, 2026
d42a93a
ci(repo): split the e2e coverage nightly into 4 batches
testableapple Oct 25, 2026
8e73290
ci(repo): give the e2e runners memory headroom
testableapple Oct 25, 2026
b6acb2a
test(repo): retry the tap that opens the image gallery
testableapple Oct 25, 2026
b7ab1da
fix(llc, ui): show a scraped image link as a link preview (FLU-1088)
xsahil03x Oct 26, 2026
400ad91
Merge remote-tracking branch 'origin/test/e2e-native-parity' into sah…
xsahil03x Oct 26, 2026
f121d72
test(repo): enable the scraped image link e2e tests (FLU-1088)
xsahil03x Oct 26, 2026
3c5dd14
refactor(llc): limit the attachment change to the url preview check
xsahil03x Oct 26, 2026
b9ca498
fix(llc): send an attachment's original type instead of url_preview
xsahil03x Oct 26, 2026
f62cef9
docs(llc): clarify the attachment type changelog entry
xsahil03x Oct 26, 2026
c488196
Merge remote-tracking branch 'origin/master' into sahil/flu-1088-an-o…
xsahil03x Oct 27, 2026
3d4da1c
Merge remote-tracking branch 'origin/master' into sahil/flu-1088-an-o…
xsahil03x Oct 28, 2026
f64ae35
fix(llc): compare scraped attachments by their original type
xsahil03x Oct 28, 2026
3da010e
Merge remote-tracking branch 'origin/master' into sahil/flu-1088-an-o…
xsahil03x Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions e2e_app/integration_test/hyper_links_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down
2 changes: 0 additions & 2 deletions e2e_app/integration_test/message_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';

Expand Down
5 changes: 5 additions & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
30 changes: 15 additions & 15 deletions packages/stream_chat/lib/src/core/models/attachment.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Comment thread
xsahil03x marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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.
Expand Down Expand Up @@ -250,7 +250,7 @@ class Attachment extends Equatable {
Map<String, Object?>? 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -307,7 +307,7 @@ class Attachment extends Equatable {
@override
List<Object?> get props => [
id,
type,
rawType,
titleLink,
title,
thumbUrl,
Expand Down
2 changes: 1 addition & 1 deletion packages/stream_chat/lib/src/core/models/attachment.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 49 additions & 0 deletions packages/stream_chat/test/src/core/models/attachment_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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')));
});
});
});
}
1 change: 1 addition & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,15 @@ 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,
children: [
Icon(icons.link, size: 12),
Expanded(
child: Text(
titleLink,
link,
maxLines: 1,
overflow: .ellipsis,
style: textTheme.metadataDefault.copyWith(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1478,7 +1478,7 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
}

// If the parsed url matches the ogAttachment url, don't do anything
if (_effectiveController.ogAttachment?.titleLink == firstMatchedUrl) {
if (_effectiveController.ogAttachment?.ogScrapeUrl == firstMatchedUrl) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,40 @@ void main() {
expect(find.text('Flutter'), findsOneWidget);
},
);

testWidgets(
'Shows the scraped url when the attachment has no title link',
(WidgetTester tester) async {
final channel = MockChannel();
final channelState = MockChannelState();

when(() => 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);
},
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,20 @@ void main() {
expect(preview, findsNothing);
expect(tester.widget<EditableText>(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);
});
});
}
Loading