From 5bcd89003cd488dc17559d9dad7cd7dc2356758d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 23 Jul 2026 12:05:48 +0200 Subject: [PATCH] fix(ui): enrich link previews for uppercase URL schemes Uppercase schemes like `HTTPS://` were detected but forwarded verbatim to the enrichment endpoint, which failed to return link-preview data. Normalize the scheme before enriching so `HTTPS://` behaves like `https://`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../message_input/stream_message_input.dart | 21 ++-- .../message_input_url_enrichment_test.dart | 112 ++++++++++++++++++ 3 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 packages/stream_chat_flutter/test/src/message_input/message_input_url_enrichment_test.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index c5416c6301..ff000f87e0 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -6,6 +6,7 @@ 🐞 Fixed +- Fixed link preview enrichment failing for uppercase URL schemes (e.g. `HTTPS://`) by normalizing the scheme before enriching. - Fixed `StreamMessageListView` firing `markThreadRead` on a reply-less parent, which produced a guaranteed 404 every time the thread view was opened before the first reply. - Fixed shadowed messages not hidden in channel list items. - Fixed last-message preview flicker during channel-state reloads. diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart index 109e11e6cc..7eac9585aa 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart @@ -1315,20 +1315,23 @@ class StreamMessageInputState extends State if (_lastSearchedContainsUrlText == value) return; _lastSearchedContainsUrlText = value; - final matchedUrls = _urlRegex.allMatches(value).where((it) { - final _parsedMatch = Uri.tryParse(it.group(0) ?? '')?.withScheme; - if (_parsedMatch == null) return false; - - return widget.ogPreviewFilter.call(_parsedMatch, value); - }).toList(); + // Find the first url to preview, normalizing the scheme so links like + // `HTTPS://` enrich the same as `https://`. + String? firstMatchedUrl; + for (final match in _urlRegex.allMatches(value)) { + final url = Uri.tryParse(match.group(0) ?? '')?.withScheme; + if (url == null) continue; + if (!widget.ogPreviewFilter.call(url, value)) continue; + + firstMatchedUrl = url.toString(); + break; + } // Reset the og attachment if the text doesn't contain any url - if (matchedUrls.isEmpty || !channel.canSendLinks) { + if (firstMatchedUrl == null || !channel.canSendLinks) { return _effectiveController.clearOGAttachment(); } - final firstMatchedUrl = matchedUrls.first.group(0)!; - // If the parsed url matches the ogAttachment url, don't do anything if (_effectiveController.ogAttachment?.titleLink == firstMatchedUrl) { return; diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_url_enrichment_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_url_enrichment_test.dart new file mode 100644 index 0000000000..b02b24ac10 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_url_enrichment_test.dart @@ -0,0 +1,112 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + group('MessageInput URL enrichment', () { + final originalRecordPlatform = RecordPlatform.instance; + setUp(() => RecordPlatform.instance = FakeRecordPlatform()); + tearDown(() => RecordPlatform.instance = originalRecordPlatform); + + late MockClient client; + late MockClientState clientState; + late MockChannel channel; + late MockChannelState channelState; + + setUp(() { + registerFallbackValue(Message()); + + client = MockClient(); + clientState = MockClientState(); + channel = MockChannel( + ownCapabilities: const [ + ChannelCapability.sendMessage, + ChannelCapability.sendLinks, + ], + ); + channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.currentUserStream).thenAnswer( + (_) => Stream.value(OwnUser(id: 'user-id')), + ); + + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channelState.isUpToDate).thenReturn(true); + + when(() => client.enrichUrl(any())).thenAnswer( + (invocation) async => OGAttachmentResponse() + ..ogScrapeUrl = invocation.positionalArguments.first as String, + ); + }); + + Future enrichUrlFrom(WidgetTester tester, String text) async { + // Enrichment runs behind a real-clock debounce, so drive the flow with + // real timers via runAsync. + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + channel: channel, + child: const Scaffold(body: StreamMessageInput()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), text); + await Future.delayed(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + }); + + return verify(() => client.enrichUrl(captureAny())).captured.single; + } + + // The scheme (and host) are normalized to lowercase before enriching so a + // backend that only handles lowercase schemes receives a consistent url. + // Case-sensitive path and query parts must be preserved as-is. + final cases = { + 'uppercase HTTPS scheme': ('HTTPS://example.com', 'https://example.com'), + 'uppercase HTTP scheme': ('HTTP://example.com', 'http://example.com'), + 'mixed-case scheme': ('HtTpS://example.com', 'https://example.com'), + 'mixed-case host': ('HTTPS://Example.COM', 'https://example.com'), + 'preserves case-sensitive path and query': ( + 'HTTPS://example.com/Path?Q=AbC', + 'https://example.com/Path?Q=AbC', + ), + 'uppercase scheme with www and path': ( + 'HTTPS://www.example.com/foo', + 'https://www.example.com/foo', + ), + 'url embedded in surrounding text': ( + 'look at HTTPS://example.com now', + 'https://example.com', + ), + 'lowercase https scheme unchanged': ( + 'https://example.com', + 'https://example.com', + ), + }; + + for (final entry in cases.entries) { + final (input, expected) = entry.value; + testWidgets('enriches ${entry.key}', (tester) async { + expect(await enrichUrlFrom(tester, input), expected); + }); + } + }); +}