From 6d858c842c78977b6cd80c527c08e282630ddefb Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Fri, 15 Sep 2023 14:45:14 +0100 Subject: [PATCH] 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: #73 --- lib/widgets/content.dart | 10 ++++ test/widgets/content_test.dart | 92 ++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 92bc193cb10..77d88efbb0a 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -7,10 +7,12 @@ import '../api/core.dart'; import '../api/model/model.dart'; import '../model/binding.dart'; import '../model/content.dart'; +import '../model/internal_link.dart'; import '../model/store.dart'; import 'code_block.dart'; import 'dialog.dart'; import 'lightbox.dart'; +import 'message_list.dart'; import 'store.dart'; import 'text.dart'; @@ -670,6 +672,14 @@ void _launchUrl(BuildContext context, String urlString) async { return; } + final internalNarrow = parseInternalLink(url, store); + if (internalNarrow != null) { + Navigator.push(context, + MessageListPage.buildRoute(context: context, + narrow: internalNarrow)); + return; + } + bool launched = false; String? errorMessage; try { diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 8078d16ab7c..6ffa8cc4e1f 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -7,13 +7,21 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/api/core.dart'; import 'package:zulip/model/content.dart'; +import 'package:zulip/model/narrow.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; +import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; +import '../model/message_list_test.dart'; import '../test_images.dart'; +import '../test_navigation.dart'; import 'dialog_checks.dart'; +import 'message_list_checks.dart'; +import 'page_checks.dart'; void main() { TestZulipBinding.ensureInitialized(); @@ -155,6 +163,90 @@ void main() { }); }); + group('Internal links', () { + Future prepareContent(WidgetTester tester, { + NavigatorObserver? navigatorObserver, + required String html, + }) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + streams: [eg.stream(streamId: 1, name: 'check')], + )); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + final connection = store.connection as FakeApiConnection; + connection.prepare(json: newestResult( + foundOldest: true, + messages: [], + ).toJson()); + await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp( + navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], + home: PerAccountStoreWidget(accountId: eg.selfAccount.id, + child: BlockContentList( + nodes: parseContent(html).nodes))))); + await tester.pump(); + await tester.pump(); + } + + testWidgets('internal links are resolved: stream', (tester) async { + final pushedRoutes = >[]; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + await prepareContent(tester, + navigatorObserver: testNavObserver, + html: '

stream

'); + + await tester.tap(find.text('stream')); + check(testBinding.takeLaunchUrlCalls()).isEmpty(); + check(pushedRoutes).last.isA() + .page.isA() + .narrow.equals(const StreamNarrow(1)); + }); + + testWidgets('internal links are resolved: topic', (tester) async { + final pushedRoutes = >[]; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + await prepareContent(tester, + navigatorObserver: testNavObserver, + html: '

topic

'); + + await tester.tap(find.text('topic')); + check(testBinding.takeLaunchUrlCalls()).isEmpty(); + check(pushedRoutes).last.isA() + .page.isA() + .narrow.equals(const TopicNarrow(1, 'my topic')); + }); + + testWidgets('internal links are resolved: dm', (tester) async { + final pushedRoutes = >[]; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + await prepareContent(tester, + navigatorObserver: testNavObserver, + html: '

dm

'); + + await tester.tap(find.text('dm')); + check(testBinding.takeLaunchUrlCalls()).isEmpty(); + check(pushedRoutes).last.isA() + .page.isA() + .narrow.equals(DmNarrow.withUser(1, selfUserId: eg.selfUser.userId)); + }); + + testWidgets('invalid internal links are not followed', (tester) async { + final pushedRoutes = >[]; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + await prepareContent(tester, + navigatorObserver: testNavObserver, + html: '

invalid

'); + await tester.tap(find.text('invalid')); + final expectedUrl = '${eg.realmUrl}#narrow/stream/1-check/topic'; + const expectedMode = LaunchMode.externalApplication; + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: Uri.parse(expectedUrl), mode: expectedMode)); + }); + }); + group('UnicodeEmoji', () { Future prepareContent(WidgetTester tester, String html) async { await tester.pumpWidget(MaterialApp(home: BlockContentList(nodes: parseContent(html).nodes)));