Skip to content

Commit 6d858c8

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 a3e3327 commit 6d858c8

File tree

2 files changed

+102
-0
lines changed

2 files changed

+102
-0
lines changed

lib/widgets/content.dart

+10
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import '../api/core.dart';
77
import '../api/model/model.dart';
88
import '../model/binding.dart';
99
import '../model/content.dart';
10+
import '../model/internal_link.dart';
1011
import '../model/store.dart';
1112
import 'code_block.dart';
1213
import 'dialog.dart';
1314
import 'lightbox.dart';
15+
import 'message_list.dart';
1416
import 'store.dart';
1517
import 'text.dart';
1618

@@ -670,6 +672,14 @@ void _launchUrl(BuildContext context, String urlString) async {
670672
return;
671673
}
672674

675+
final internalNarrow = parseInternalLink(url, store);
676+
if (internalNarrow != null) {
677+
Navigator.push(context,
678+
MessageListPage.buildRoute(context: context,
679+
narrow: internalNarrow));
680+
return;
681+
}
682+
673683
bool launched = false;
674684
String? errorMessage;
675685
try {

test/widgets/content_test.dart

+92
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,21 @@ import 'package:flutter_test/flutter_test.dart';
77
import 'package:url_launcher/url_launcher.dart';
88
import 'package:zulip/api/core.dart';
99
import 'package:zulip/model/content.dart';
10+
import 'package:zulip/model/narrow.dart';
1011
import 'package:zulip/widgets/content.dart';
12+
import 'package:zulip/widgets/message_list.dart';
13+
import 'package:zulip/widgets/page.dart';
1114
import 'package:zulip/widgets/store.dart';
1215

16+
import '../api/fake_api.dart';
1317
import '../example_data.dart' as eg;
1418
import '../model/binding.dart';
19+
import '../model/message_list_test.dart';
1520
import '../test_images.dart';
21+
import '../test_navigation.dart';
1622
import 'dialog_checks.dart';
23+
import 'message_list_checks.dart';
24+
import 'page_checks.dart';
1725

1826
void main() {
1927
TestZulipBinding.ensureInitialized();
@@ -155,6 +163,90 @@ void main() {
155163
});
156164
});
157165

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

0 commit comments

Comments
 (0)