Skip to content

Commit daef717

Browse files
xsahil03xclaude
andcommitted
fix(message_input): recognize uppercase URL schemes for link enrichment
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) <noreply@anthropic.com>
1 parent 141dec2 commit daef717

3 files changed

Lines changed: 112 additions & 9 deletions

File tree

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
🐞 Fixed
1414

15+
- Fixed link preview enrichment failing for uppercase URL schemes (e.g. `HTTPS://`) by normalizing the scheme before enriching.
1516
- Fixed last-message preview flicker during channel-state reloads.
1617
- Fixed shadowed messages not hidden in channel list items.
1718
- 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.

packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,20 +1293,23 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
12931293
if (_lastSearchedContainsUrlText == value) return;
12941294
_lastSearchedContainsUrlText = value;
12951295

1296-
final matchedUrls = _urlRegex.allMatches(value).where((it) {
1297-
final _parsedMatch = Uri.tryParse(it.group(0) ?? '')?.withScheme;
1298-
if (_parsedMatch == null) return false;
1299-
1300-
return widget.props.ogPreviewFilter.call(_parsedMatch, value);
1301-
}).toList();
1296+
// Find the first url to preview, normalizing the scheme so links like
1297+
// `HTTPS://` enrich the same as `https://`.
1298+
String? firstMatchedUrl;
1299+
for (final match in _urlRegex.allMatches(value)) {
1300+
final url = Uri.tryParse(match.group(0) ?? '')?.withScheme;
1301+
if (url == null) continue;
1302+
if (!widget.props.ogPreviewFilter.call(url, value)) continue;
1303+
1304+
firstMatchedUrl = url.toString();
1305+
break;
1306+
}
13021307

13031308
// Reset the og attachment if the text doesn't contain any url
1304-
if (matchedUrls.isEmpty || !channel.canSendLinks) {
1309+
if (firstMatchedUrl == null || !channel.canSendLinks) {
13051310
return _effectiveController.clearOGAttachment();
13061311
}
13071312

1308-
final firstMatchedUrl = matchedUrls.first.group(0)!;
1309-
13101313
// If the parsed url matches the ogAttachment url, don't do anything
13111314
if (_effectiveController.ogAttachment?.titleLink == firstMatchedUrl) {
13121315
return;
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// ignore_for_file: lines_longer_than_80_chars
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:mocktail/mocktail.dart';
6+
import 'package:record/record.dart';
7+
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
8+
9+
import '../fakes.dart';
10+
import '../mocks.dart';
11+
12+
void main() {
13+
group('MessageComposer URL enrichment', () {
14+
final originalRecordPlatform = RecordPlatform.instance;
15+
setUp(() => RecordPlatform.instance = FakeRecordPlatform());
16+
tearDown(() => RecordPlatform.instance = originalRecordPlatform);
17+
18+
late MockClient client;
19+
late MockClientState clientState;
20+
late MockChannel channel;
21+
late MockChannelState channelState;
22+
23+
setUp(() {
24+
registerFallbackValue(Message());
25+
26+
client = MockClient();
27+
clientState = MockClientState();
28+
channel = MockChannel(
29+
ownCapabilities: const [
30+
ChannelCapability.sendMessage,
31+
ChannelCapability.sendLinks,
32+
],
33+
);
34+
channelState = MockChannelState();
35+
36+
when(() => client.state).thenReturn(clientState);
37+
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
38+
when(() => clientState.currentUserStream).thenAnswer(
39+
(_) => Stream.value(OwnUser(id: 'user-id')),
40+
);
41+
42+
when(() => channel.state).thenReturn(channelState);
43+
when(() => channel.client).thenReturn(client);
44+
when(channel.getRemainingCooldown).thenReturn(0);
45+
46+
when(() => client.enrichUrl(any())).thenAnswer(
47+
(invocation) async => OGAttachmentResponse()..ogScrapeUrl = invocation.positionalArguments.first as String,
48+
);
49+
});
50+
51+
Future<Object?> enrichUrlFrom(WidgetTester tester, String text) async {
52+
// Enrichment runs behind a real-clock debounce, so drive the flow with
53+
// real timers via runAsync.
54+
await tester.runAsync(() async {
55+
await tester.pumpWidget(
56+
MaterialApp(
57+
home: StreamChat(
58+
client: client,
59+
connectivityStream: Stream.value([ConnectivityResult.mobile]),
60+
child: StreamChannel(
61+
channel: channel,
62+
child: Scaffold(body: StreamMessageComposer()),
63+
),
64+
),
65+
),
66+
);
67+
await tester.pumpAndSettle();
68+
69+
await tester.enterText(find.byType(TextField), text);
70+
await Future<void>.delayed(const Duration(milliseconds: 500));
71+
await tester.pumpAndSettle();
72+
});
73+
74+
return verify(() => client.enrichUrl(captureAny())).captured.single;
75+
}
76+
77+
testWidgets(
78+
'normalizes an uppercase HTTPS:// scheme before enriching',
79+
(tester) async {
80+
// The scheme is normalized to lowercase before enriching so a buggy
81+
// backend receives the same URL as the lowercase variant.
82+
expect(
83+
await enrichUrlFrom(tester, 'HTTPS://example.com'),
84+
'https://example.com',
85+
);
86+
},
87+
);
88+
89+
testWidgets(
90+
'enriches a lowercase https:// scheme unchanged',
91+
(tester) async {
92+
expect(
93+
await enrichUrlFrom(tester, 'https://example.com'),
94+
'https://example.com',
95+
);
96+
},
97+
);
98+
});
99+
}

0 commit comments

Comments
 (0)