Skip to content

Commit c9fb0a0

Browse files
committed
content: Handle internal links
Integrates internal_links into link nodes so that urls that resolve to internal Narrows navigate to that instead of launching in an external browser. Fixes: zulip#73
1 parent c15a579 commit c9fb0a0

File tree

2 files changed

+89
-0
lines changed

2 files changed

+89
-0
lines changed

lib/widgets/content.dart

+9
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import '../model/store.dart';
1212
import 'code_block.dart';
1313
import 'dialog.dart';
1414
import 'lightbox.dart';
15+
import 'message_list.dart';
1516
import 'store.dart';
1617
import 'text.dart';
1718

@@ -668,6 +669,14 @@ void _launchUrl(BuildContext context, String urlString) async {
668669
return;
669670
}
670671

672+
final internalNarrow = parseInternalLink(url, store);
673+
if (internalNarrow != null) {
674+
Navigator.push(context,
675+
MessageListPage.buildRoute(context: context,
676+
narrow: internalNarrow));
677+
return;
678+
}
679+
671680
bool launched = false;
672681
String? errorMessage;
673682
try {

test/widgets/content_test.dart

+80
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,21 @@ import 'package:flutter_test/flutter_test.dart';
88
import 'package:url_launcher/url_launcher.dart';
99
import 'package:zulip/api/core.dart';
1010
import 'package:zulip/model/content.dart';
11+
import 'package:zulip/model/narrow.dart';
1112
import 'package:zulip/widgets/content.dart';
13+
import 'package:zulip/widgets/message_list.dart';
14+
import 'package:zulip/widgets/page.dart';
1215
import 'package:zulip/widgets/store.dart';
1316

17+
import '../api/fake_api.dart';
1418
import '../example_data.dart' as eg;
1519
import '../model/binding.dart';
20+
import '../model/message_list_test.dart';
1621
import '../test_images.dart';
22+
import '../test_navigation.dart';
1723
import 'dialog_checks.dart';
24+
import 'message_list_checks.dart';
25+
import 'page_checks.dart';
1826

1927
void main() {
2028
TestZulipBinding.ensureInitialized();
@@ -158,6 +166,78 @@ void main() {
158166
});
159167
});
160168

169+
group('Internal links', () {
170+
Future<List<Route<dynamic>>> prepareContentWithNavigator(WidgetTester tester, {
171+
required String html,
172+
}) async {
173+
addTearDown(testBinding.reset);
174+
final pushedRoutes = <Route<dynamic>>[];
175+
final testNavObserver = TestNavigatorObserver()
176+
..onPushed = (route, prevRoute) => pushedRoutes.add(route);
177+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
178+
streams: [eg.stream(streamId: 1, name: 'check')],
179+
));
180+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
181+
final connection = store.connection as FakeApiConnection;
182+
connection.prepare(json: newestResult(
183+
foundOldest: true,
184+
messages: [],
185+
).toJson());
186+
await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp(
187+
navigatorObservers: [testNavObserver],
188+
home: PerAccountStoreWidget(accountId: eg.selfAccount.id,
189+
child: BlockContentList(nodes: parseContent(html).nodes)))));
190+
await tester.pump();
191+
assert(pushedRoutes.length == 1);
192+
pushedRoutes.removeLast();
193+
return pushedRoutes;
194+
}
195+
196+
testWidgets('internal links are resolved: StreamNarrow', (tester) async {
197+
final pushedRoutes = await prepareContentWithNavigator(tester,
198+
html: '<p><a href="/#narrow/stream/1-check">stream</a></p>');
199+
200+
await tester.tap(find.text('stream'));
201+
check(testBinding.takeLaunchUrlCalls()).isEmpty();
202+
check(pushedRoutes).single.isA<WidgetRoute>()
203+
.page.isA<MessageListPage>()
204+
.narrow.equals(const StreamNarrow(1));
205+
});
206+
207+
testWidgets('internal links are resolved: TopicNarrow', (tester) async {
208+
final pushedRoutes = await prepareContentWithNavigator(tester,
209+
html: '<p><a href="/#narrow/stream/1-check/topic/my.20topic">topic</a></p>');
210+
211+
await tester.tap(find.text('topic'));
212+
check(testBinding.takeLaunchUrlCalls()).isEmpty();
213+
check(pushedRoutes).single.isA<WidgetRoute>()
214+
.page.isA<MessageListPage>()
215+
.narrow.equals(const TopicNarrow(1, 'my topic'));
216+
});
217+
218+
testWidgets('internal links are resolved: DmNarrow', (tester) async {
219+
final pushedRoutes = await prepareContentWithNavigator(tester,
220+
html: '<p><a href="/#narrow/dm/1-123-group">dm</a></p>');
221+
222+
await tester.tap(find.text('dm'));
223+
check(testBinding.takeLaunchUrlCalls()).isEmpty();
224+
check(pushedRoutes).single.isA<WidgetRoute>()
225+
.page.isA<MessageListPage>()
226+
.narrow.equals(DmNarrow.withUser(1, selfUserId: eg.selfUser.userId));
227+
});
228+
229+
testWidgets('invalid internal links are not followed', (tester) async {
230+
final pushedRoutes = await prepareContentWithNavigator(tester,
231+
html: '<p><a href="/#narrow/stream/1-check/topic">invalid</a></p>');
232+
233+
await tester.tap(find.text('invalid'));
234+
final expectedUrl = Uri.parse('${eg.realmUrl}#narrow/stream/1-check/topic');
235+
check(testBinding.takeLaunchUrlCalls())
236+
.single.equals((url: expectedUrl, mode: LaunchMode.externalApplication));
237+
check(pushedRoutes).isEmpty();
238+
});
239+
});
240+
161241
group('UnicodeEmoji', () {
162242
Future<void> prepareContent(WidgetTester tester, String html) async {
163243
await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(html).nodes)));

0 commit comments

Comments
 (0)