Skip to content

Commit c365525

Browse files
committed
content: Make links touchable
Fixes: #71
1 parent f869088 commit c365525

File tree

3 files changed

+210
-9
lines changed

3 files changed

+210
-9
lines changed

lib/model/content.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,6 @@ class InlineCodeNode extends InlineContainerNode {
399399
class LinkNode extends InlineContainerNode {
400400
const LinkNode({super.debugHtmlNode, required super.nodes, required this.url});
401401

402-
// TODO(#71): Use [LinkNode.url] to open links
403402
final String url; // Left as a string, to defer parsing until link actually followed.
404403

405404
// Unlike other [ContentNode]s, the identity is useful to show in debugging
@@ -559,6 +558,9 @@ class _ZulipContentParser {
559558
|| classes.contains('user-group-mention'))
560559
&& (classes.length == 1
561560
|| (classes.length == 2 && classes.contains('silent')))) {
561+
// TODO assert UserMentionNode can't contain LinkNode;
562+
// either a debug-mode check, or perhaps we can make expectations much
563+
// tighter on a UserMentionNode's contents overall.
562564
return UserMentionNode(nodes: nodes(), debugHtmlNode: debugHtmlNode);
563565
}
564566

lib/widgets/content.dart

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import 'package:flutter/gestures.dart';
22
import 'package:flutter/material.dart';
3+
import 'package:flutter/services.dart';
34
import 'package:html/dom.dart' as dom;
45

56
import '../api/core.dart';
67
import '../api/model/model.dart';
8+
import '../model/binding.dart';
79
import '../model/content.dart';
810
import '../model/store.dart';
11+
import 'dialog.dart';
912
import 'store.dart';
1013
import 'lightbox.dart';
1114
import 'text.dart';
@@ -303,10 +306,11 @@ Widget _buildBlockInlineContainer({
303306
required BlockInlineContainerNode node,
304307
}) {
305308
if (node.links == null) {
306-
return InlineContent(recognizer: null, style: style, nodes: node.nodes);
309+
return InlineContent(recognizer: null, linkRecognizers: null,
310+
style: style, nodes: node.nodes);
307311
}
308-
return _BlockInlineContainer(
309-
links: node.links!, style: style, nodes: node.nodes);
312+
return _BlockInlineContainer(links: node.links!,
313+
style: style, nodes: node.nodes);
310314
}
311315

312316
class _BlockInlineContainer extends StatefulWidget {
@@ -322,9 +326,44 @@ class _BlockInlineContainer extends StatefulWidget {
322326
}
323327

324328
class _BlockInlineContainerState extends State<_BlockInlineContainer> {
329+
final Map<LinkNode, GestureRecognizer> _recognizers = {};
330+
331+
void _prepareRecognizers() {
332+
_recognizers.addEntries(widget.links.map((node) => MapEntry(node,
333+
TapGestureRecognizer()..onTap = () => _launchUrl(context, node.url))));
334+
}
335+
336+
void _disposeRecognizers() {
337+
for (final recognizer in _recognizers.values) {
338+
recognizer.dispose();
339+
}
340+
_recognizers.clear();
341+
}
342+
343+
@override
344+
void initState() {
345+
super.initState();
346+
_prepareRecognizers();
347+
}
348+
349+
@override
350+
void didUpdateWidget(covariant _BlockInlineContainer oldWidget) {
351+
super.didUpdateWidget(oldWidget);
352+
if (!identical(widget.links, oldWidget.links)) {
353+
_disposeRecognizers();
354+
_prepareRecognizers();
355+
}
356+
}
357+
358+
@override
359+
void dispose() {
360+
_disposeRecognizers();
361+
super.dispose();
362+
}
363+
325364
@override
326365
Widget build(BuildContext context) {
327-
return InlineContent(recognizer: null,
366+
return InlineContent(recognizer: null, linkRecognizers: _recognizers,
328367
style: widget.style, nodes: widget.nodes);
329368
}
330369
}
@@ -333,13 +372,15 @@ class InlineContent extends StatelessWidget {
333372
InlineContent({
334373
super.key,
335374
required this.recognizer,
375+
required this.linkRecognizers,
336376
required this.style,
337377
required this.nodes,
338378
}) {
339379
_builder = _InlineContentBuilder(this);
340380
}
341381

342382
final GestureRecognizer? recognizer;
383+
final Map<LinkNode, GestureRecognizer>? linkRecognizers;
343384
final TextStyle? style;
344385
final List<InlineContentNode> nodes;
345386

@@ -357,15 +398,31 @@ class _InlineContentBuilder {
357398
final InlineContent widget;
358399

359400
InlineSpan build() {
360-
return _buildNodes(widget.nodes, style: widget.style);
401+
assert(_recognizer == widget.recognizer);
402+
assert(_recognizerStack == null || _recognizerStack!.isEmpty);
403+
final result = _buildNodes(widget.nodes, style: widget.style);
404+
assert(_recognizer == widget.recognizer);
405+
assert(_recognizerStack == null || _recognizerStack!.isEmpty);
406+
return result;
361407
}
362408

363409
// Why do we have to track `recognizer` here, rather than apply it
364410
// once at the top of the affected span? Because the events don't bubble
365411
// within a paragraph:
366412
// https://github.com/flutter/flutter/issues/10623
367413
// https://github.com/flutter/flutter/issues/10623#issuecomment-308030170
368-
final GestureRecognizer? _recognizer;
414+
GestureRecognizer? _recognizer;
415+
416+
List<GestureRecognizer?>? _recognizerStack;
417+
418+
void _pushRecognizer(GestureRecognizer? newRecognizer) {
419+
(_recognizerStack ??= []).add(_recognizer);
420+
_recognizer = newRecognizer;
421+
}
422+
423+
void _popRecognizer() {
424+
_recognizer = _recognizerStack!.removeLast();
425+
}
369426

370427
InlineSpan _buildNodes(List<InlineContentNode> nodes, {required TextStyle? style}) {
371428
return TextSpan(
@@ -412,9 +469,13 @@ class _InlineContentBuilder {
412469
style: const TextStyle(fontStyle: FontStyle.italic));
413470

414471
InlineSpan _buildLink(LinkNode node) {
415-
// TODO make link touchable by setting _recognizer
416-
return _buildNodes(node.nodes,
472+
final recognizer = widget.linkRecognizers?[node];
473+
assert(recognizer != null);
474+
_pushRecognizer(recognizer);
475+
final result = _buildNodes(node.nodes,
417476
style: TextStyle(color: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor()));
477+
_popRecognizer();
478+
return result;
418479
}
419480

420481
InlineSpan _buildInlineCode(InlineCodeNode node) {
@@ -511,6 +572,9 @@ class UserMention extends StatelessWidget {
511572
child: InlineContent(
512573
// If an @-mention is inside a link, let the @-mention override it.
513574
recognizer: null, // TODO make @-mentions tappable, for info on user
575+
// One hopes an @-mention can't contain an embedded link.
576+
// (The parser on creating a UserMentionNode has a TODO to check that.)
577+
linkRecognizers: null,
514578
style: null,
515579
nodes: node.nodes));
516580
}
@@ -593,6 +657,38 @@ class MessageImageEmoji extends StatelessWidget {
593657
}
594658
}
595659

660+
void _launchUrl(BuildContext context, String urlString) async {
661+
Future<void> showError(BuildContext context, String? message) {
662+
return showErrorDialog(context: context,
663+
title: 'Unable to open link',
664+
message: [
665+
'Link could not be opened: $urlString',
666+
if (message != null) message,
667+
].join("\n\n"));
668+
}
669+
670+
final store = PerAccountStoreWidget.of(context);
671+
final Uri url;
672+
try {
673+
url = store.account.realmUrl.resolve(urlString);
674+
} on FormatException { // TODO(log)
675+
await showError(context, null);
676+
return;
677+
}
678+
679+
bool launched = false;
680+
String? errorMessage;
681+
try {
682+
launched = await ZulipBinding.instance.launchUrl(url);
683+
} on PlatformException catch (e) {
684+
errorMessage = e.message;
685+
}
686+
if (!launched) { // TODO(log)
687+
if (!context.mounted) return;
688+
await showError(context, errorMessage);
689+
}
690+
}
691+
596692
/// Like [Image.network], but includes [authHeader] if [src] is on-realm.
597693
///
598694
/// Use this to present image content in the ambient realm: avatars, images in

test/widgets/content_test.dart

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,121 @@ import 'dart:async';
22
import 'dart:io';
33

44
import 'package:checks/checks.dart';
5+
import 'package:flutter/foundation.dart';
56
import 'package:flutter/material.dart';
67
import 'package:flutter_test/flutter_test.dart';
8+
import 'package:url_launcher/url_launcher.dart';
79
import 'package:zulip/api/core.dart';
10+
import 'package:zulip/model/content.dart';
811
import 'package:zulip/widgets/content.dart';
912
import 'package:zulip/widgets/store.dart';
1013

1114
import '../example_data.dart' as eg;
1215
import '../model/binding.dart';
16+
import 'dialog_checks.dart';
1317

1418
void main() {
1519
TestZulipBinding.ensureInitialized();
1620

21+
group('LinkNode interactions', () {
22+
const expectedModeAndroid = LaunchMode.platformDefault;
23+
24+
// The Flutter test font uses square glyphs, so width equals height:
25+
// https://github.com/flutter/flutter/wiki/Flutter-Test-Fonts
26+
const fontSize = 48.0;
27+
28+
Future<void> prepareContent(WidgetTester tester, String html) async {
29+
final globalStore = TestZulipBinding.instance.globalStore;
30+
addTearDown(TestZulipBinding.instance.reset);
31+
await globalStore.add(eg.selfAccount, eg.initialSnapshot());
32+
33+
await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp(
34+
home: PerAccountStoreWidget(accountId: eg.selfAccount.id,
35+
child: BlockContentList(
36+
nodes: parseContent(html).nodes)))));
37+
await tester.pump();
38+
await tester.pump();
39+
}
40+
41+
testWidgets('can tap a link to open URL', (tester) async {
42+
await prepareContent(tester,
43+
'<p><a href="https://example/">hello</a></p>');
44+
45+
await tester.tap(find.text('hello'));
46+
const expectedMode = LaunchMode.platformDefault;
47+
check(TestZulipBinding.instance.takeLaunchUrlCalls())
48+
.single.equals((url: Uri.parse('https://example/'), mode: expectedMode));
49+
}, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
50+
51+
testWidgets('multiple links in paragraph', (tester) async {
52+
await prepareContent(tester,
53+
'<p><a href="https://a/">foo</a> bar <a href="https://b/">baz</a></p>');
54+
final base = tester.getTopLeft(find.text('foo bar baz'))
55+
.translate(fontSize/2, fontSize/2); // middle of first letter
56+
57+
await tester.tapAt(base.translate(5*fontSize, 0)); // "foo bXr baz"
58+
check(TestZulipBinding.instance.takeLaunchUrlCalls()).isEmpty();
59+
60+
await tester.tapAt(base.translate(1*fontSize, 0)); // "fXo bar baz"
61+
check(TestZulipBinding.instance.takeLaunchUrlCalls())
62+
.single.equals((url: Uri.parse('https://a/'), mode: expectedModeAndroid));
63+
64+
await tester.tapAt(base.translate(9*fontSize, 0)); // "foo bar bXz"
65+
check(TestZulipBinding.instance.takeLaunchUrlCalls())
66+
.single.equals((url: Uri.parse('https://b/'), mode: expectedModeAndroid));
67+
});
68+
69+
testWidgets('link nested in other spans', (tester) async {
70+
await prepareContent(tester,
71+
'<p><strong><em><a href="https://a/">word</a></em></strong></p>');
72+
await tester.tap(find.text('word'));
73+
check(TestZulipBinding.instance.takeLaunchUrlCalls())
74+
.single.equals((url: Uri.parse('https://a/'), mode: expectedModeAndroid));
75+
});
76+
77+
testWidgets('link containing other spans', (tester) async {
78+
await prepareContent(tester,
79+
'<p><a href="https://a/">two <strong><em><code>words</code></em></strong></a></p>');
80+
final base = tester.getTopLeft(find.text('two words'))
81+
.translate(fontSize/2, fontSize/2); // middle of first letter
82+
83+
await tester.tapAt(base.translate(1*fontSize, 0)); // "tXo words"
84+
check(TestZulipBinding.instance.takeLaunchUrlCalls())
85+
.single.equals((url: Uri.parse('https://a/'), mode: expectedModeAndroid));
86+
87+
await tester.tapAt(base.translate(6*fontSize, 0)); // "two woXds"
88+
check(TestZulipBinding.instance.takeLaunchUrlCalls())
89+
.single.equals((url: Uri.parse('https://a/'), mode: expectedModeAndroid));
90+
});
91+
92+
testWidgets('relative links are resolved', (tester) async {
93+
await prepareContent(tester,
94+
'<p><a href="/a/b?c#d">word</a></p>');
95+
await tester.tap(find.text('word'));
96+
check(TestZulipBinding.instance.takeLaunchUrlCalls())
97+
.single.equals((url: Uri.parse('${eg.realmUrl}a/b?c#d'), mode: expectedModeAndroid));
98+
});
99+
100+
testWidgets('link inside HeadingNode', (tester) async {
101+
await prepareContent(tester,
102+
'<h6><a href="https://a/">word</a></h6>');
103+
await tester.tap(find.text('word'));
104+
check(TestZulipBinding.instance.takeLaunchUrlCalls())
105+
.single.equals((url: Uri.parse('https://a/'), mode: expectedModeAndroid));
106+
});
107+
108+
testWidgets('error dialog if invalid link', (tester) async {
109+
await prepareContent(tester,
110+
'<p><a href="file:///etc/bad">word</a></p>');
111+
TestZulipBinding.instance.launchUrlResult = false;
112+
await tester.tap(find.text('word'));
113+
await tester.pump();
114+
check(TestZulipBinding.instance.takeLaunchUrlCalls())
115+
.single.equals((url: Uri.parse('file:///etc/bad'), mode: expectedModeAndroid));
116+
checkErrorDialog(tester, expectedTitle: 'Unable to open link');
117+
});
118+
});
119+
17120
group('RealmContentNetworkImage', () {
18121
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);
19122

0 commit comments

Comments
 (0)