1
1
import 'package:flutter/gestures.dart' ;
2
2
import 'package:flutter/material.dart' ;
3
+ import 'package:flutter/services.dart' ;
3
4
import 'package:html/dom.dart' as dom;
4
5
5
6
import '../api/core.dart' ;
6
7
import '../api/model/model.dart' ;
8
+ import '../model/binding.dart' ;
7
9
import '../model/content.dart' ;
8
10
import '../model/store.dart' ;
11
+ import 'dialog.dart' ;
9
12
import 'store.dart' ;
10
13
import 'lightbox.dart' ;
11
14
import 'text.dart' ;
@@ -303,10 +306,11 @@ Widget _buildBlockInlineContainer({
303
306
required BlockInlineContainerNode node,
304
307
}) {
305
308
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);
307
311
}
308
- return _BlockInlineContainer (
309
- links : node.links ! , style: style, nodes: node.nodes);
312
+ return _BlockInlineContainer (links : node.links ! ,
313
+ style: style, nodes: node.nodes);
310
314
}
311
315
312
316
class _BlockInlineContainer extends StatefulWidget {
@@ -322,9 +326,44 @@ class _BlockInlineContainer extends StatefulWidget {
322
326
}
323
327
324
328
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
+
325
364
@override
326
365
Widget build (BuildContext context) {
327
- return InlineContent (recognizer: null ,
366
+ return InlineContent (recognizer: null , linkRecognizers : _recognizers,
328
367
style: widget.style, nodes: widget.nodes);
329
368
}
330
369
}
@@ -333,13 +372,15 @@ class InlineContent extends StatelessWidget {
333
372
InlineContent ({
334
373
super .key,
335
374
required this .recognizer,
375
+ required this .linkRecognizers,
336
376
required this .style,
337
377
required this .nodes,
338
378
}) {
339
379
_builder = _InlineContentBuilder (this );
340
380
}
341
381
342
382
final GestureRecognizer ? recognizer;
383
+ final Map <LinkNode , GestureRecognizer >? linkRecognizers;
343
384
final TextStyle ? style;
344
385
final List <InlineContentNode > nodes;
345
386
@@ -357,15 +398,31 @@ class _InlineContentBuilder {
357
398
final InlineContent widget;
358
399
359
400
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;
361
407
}
362
408
363
409
// Why do we have to track `recognizer` here, rather than apply it
364
410
// once at the top of the affected span? Because the events don't bubble
365
411
// within a paragraph:
366
412
// https://github.com/flutter/flutter/issues/10623
367
413
// 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
+ }
369
426
370
427
InlineSpan _buildNodes (List <InlineContentNode > nodes, {required TextStyle ? style}) {
371
428
return TextSpan (
@@ -412,9 +469,13 @@ class _InlineContentBuilder {
412
469
style: const TextStyle (fontStyle: FontStyle .italic));
413
470
414
471
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,
417
476
style: TextStyle (color: const HSLColor .fromAHSL (1 , 200 , 1 , 0.4 ).toColor ()));
477
+ _popRecognizer ();
478
+ return result;
418
479
}
419
480
420
481
InlineSpan _buildInlineCode (InlineCodeNode node) {
@@ -511,6 +572,9 @@ class UserMention extends StatelessWidget {
511
572
child: InlineContent (
512
573
// If an @-mention is inside a link, let the @-mention override it.
513
574
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 ,
514
578
style: null ,
515
579
nodes: node.nodes));
516
580
}
@@ -593,6 +657,38 @@ class MessageImageEmoji extends StatelessWidget {
593
657
}
594
658
}
595
659
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
+
596
692
/// Like [Image.network] , but includes [authHeader] if [src] is on-realm.
597
693
///
598
694
/// Use this to present image content in the ambient realm: avatars, images in
0 commit comments