523 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Dart
		
	
	
	
			
		
		
	
	
			523 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Dart
		
	
	
	
| import 'package:flutter/material.dart';
 | |
| 
 | |
| import 'package:collection/collection.dart';
 | |
| import 'package:flutter_highlighter/flutter_highlighter.dart';
 | |
| import 'package:flutter_highlighter/themes/shades-of-purple.dart';
 | |
| import 'package:highlight_selectable/theme_map.dart';
 | |
| import 'package:highlight_selectable/highlight_selectable.dart';
 | |
| import 'package:flutter_linkify/flutter_linkify.dart';
 | |
| import 'package:html/dom.dart' as dom;
 | |
| import 'package:html/parser.dart' as parser;
 | |
| import 'package:matrix/matrix.dart';
 | |
| 
 | |
| import 'package:extera_next/widgets/avatar.dart';
 | |
| import 'package:extera_next/widgets/mxc_image.dart';
 | |
| import '../../../utils/url_launcher.dart';
 | |
| 
 | |
| class HtmlMessage extends StatelessWidget {
 | |
|   final String html;
 | |
|   final Room room;
 | |
|   final Color textColor;
 | |
|   final double fontSize;
 | |
|   final TextStyle linkStyle;
 | |
|   final void Function(LinkableElement) onOpen;
 | |
| 
 | |
|   const HtmlMessage({
 | |
|     super.key,
 | |
|     required this.html,
 | |
|     required this.room,
 | |
|     required this.fontSize,
 | |
|     required this.linkStyle,
 | |
|     this.textColor = Colors.black,
 | |
|     required this.onOpen,
 | |
|   });
 | |
| 
 | |
|   /// Keep in sync with: https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes
 | |
|   static const Set<String> allowedHtmlTags = {
 | |
|     'font',
 | |
|     'del',
 | |
|     's',
 | |
|     'h1',
 | |
|     'h2',
 | |
|     'h3',
 | |
|     'h4',
 | |
|     'h5',
 | |
|     'h6',
 | |
|     'blockquote',
 | |
|     'p',
 | |
|     'a',
 | |
|     'ul',
 | |
|     'ol',
 | |
|     'sup',
 | |
|     'sub',
 | |
|     'li',
 | |
|     'b',
 | |
|     'i',
 | |
|     'u',
 | |
|     'strong',
 | |
|     'em',
 | |
|     'strike',
 | |
|     'code',
 | |
|     'hr',
 | |
|     'br',
 | |
|     'div',
 | |
|     'table',
 | |
|     'thead',
 | |
|     'tbody',
 | |
|     'tr',
 | |
|     'th',
 | |
|     'td',
 | |
|     'caption',
 | |
|     'pre',
 | |
|     'span',
 | |
|     'img',
 | |
|     'details',
 | |
|     'summary',
 | |
|     // Not in the allowlist of the matrix spec yet but should be harmless:
 | |
|     'ruby',
 | |
|     'rp',
 | |
|     'rt',
 | |
|     'html',
 | |
|     'body',
 | |
|     // Workaround for https://github.com/krille-chan/fluffychat/issues/507
 | |
|     'tg-forward',
 | |
|   };
 | |
| 
 | |
|   /// We add line breaks before these tags:
 | |
|   static const Set<String> blockHtmlTags = {
 | |
|     'p',
 | |
|     'ul',
 | |
|     'ol',
 | |
|     'pre',
 | |
|     'div',
 | |
|     'table',
 | |
|     'details',
 | |
|     'blockquote',
 | |
|   };
 | |
| 
 | |
|   /// We add line breaks before these tags:
 | |
|   static const Set<String> fullLineHtmlTag = {
 | |
|     'h1',
 | |
|     'h2',
 | |
|     'h3',
 | |
|     'h4',
 | |
|     'h5',
 | |
|     'h6',
 | |
|     'li',
 | |
|   };
 | |
| 
 | |
|   /// Adding line breaks before block elements.
 | |
|   List<InlineSpan> _renderWithLineBreaks(
 | |
|     dom.NodeList nodes,
 | |
|     BuildContext context, {
 | |
|     int depth = 1,
 | |
|   }) {
 | |
|     final onlyElements = nodes.whereType<dom.Element>().toList();
 | |
|     return [
 | |
|       for (var i = 0; i < nodes.length; i++) ...[
 | |
|         // Actually render the node child:
 | |
|         _renderHtml(nodes[i], context, depth: depth + 1),
 | |
|         // Add linebreaks between blocks:
 | |
|         if (nodes[i] is dom.Element &&
 | |
|             onlyElements.indexOf(nodes[i] as dom.Element) <
 | |
|                 onlyElements.length - 1) ...[
 | |
|           if (blockHtmlTags.contains((nodes[i] as dom.Element).localName))
 | |
|             const TextSpan(text: '\n\n'),
 | |
|           if (fullLineHtmlTag.contains((nodes[i] as dom.Element).localName))
 | |
|             const TextSpan(text: '\n'),
 | |
|         ],
 | |
|       ],
 | |
|     ];
 | |
|   }
 | |
| 
 | |
|   /// Transforms a Node to an InlineSpan.
 | |
|   InlineSpan _renderHtml(
 | |
|     dom.Node node,
 | |
|     BuildContext context, {
 | |
|     int depth = 1,
 | |
|   }) {
 | |
|     // We must not render elements nested more than 100 elements deep:
 | |
|     if (depth >= 100) return const TextSpan();
 | |
| 
 | |
|     // This is a text node, so we render it as text:
 | |
|     if (node is! dom.Element) {
 | |
|       var text = node.text ?? '';
 | |
|       // Single linebreak nodes between Elements are ignored:
 | |
|       if (text == '\n') text = '';
 | |
| 
 | |
|       return LinkifySpan(
 | |
|         text: text,
 | |
|         options: const LinkifyOptions(humanize: false),
 | |
|         linkStyle: linkStyle,
 | |
|         onOpen: onOpen,
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     // We must not render tags which are not in the allow list:
 | |
|     if (!allowedHtmlTags.contains(node.localName)) return const TextSpan();
 | |
| 
 | |
|     switch (node.localName) {
 | |
|       case 'br':
 | |
|         return const TextSpan(text: '\n');
 | |
|       case 'a':
 | |
|         final href = node.attributes['href'];
 | |
|         if (href == null) continue block;
 | |
|         final matrixId = node.attributes['href']
 | |
|             ?.parseIdentifierIntoParts()
 | |
|             ?.primaryIdentifier;
 | |
|         if (matrixId != null) {
 | |
|           if (matrixId.sigil == '@') {
 | |
|             final user = room.unsafeGetUserFromMemoryOrFallback(matrixId);
 | |
|             return WidgetSpan(
 | |
|               child: MatrixPill(
 | |
|                 key: Key('user_pill_$matrixId'),
 | |
|                 name: user.calcDisplayname(),
 | |
|                 avatar: user.avatarUrl,
 | |
|                 uri: href,
 | |
|                 outerContext: context,
 | |
|                 fontSize: fontSize,
 | |
|                 color: linkStyle.color,
 | |
|               ),
 | |
|             );
 | |
|           }
 | |
|           if (matrixId.sigil == '#' || matrixId.sigil == '!') {
 | |
|             final room = matrixId.sigil == '!'
 | |
|                 ? this.room.client.getRoomById(matrixId)
 | |
|                 : this.room.client.getRoomByAlias(matrixId);
 | |
|             return WidgetSpan(
 | |
|               child: MatrixPill(
 | |
|                 name: room?.getLocalizedDisplayname() ?? matrixId,
 | |
|                 avatar: room?.avatar,
 | |
|                 uri: href,
 | |
|                 outerContext: context,
 | |
|                 fontSize: fontSize,
 | |
|                 color: linkStyle.color,
 | |
|               ),
 | |
|             );
 | |
|           }
 | |
|         }
 | |
|         return WidgetSpan(
 | |
|           child: Tooltip(
 | |
|             message: href,
 | |
|             child: InkWell(
 | |
|               splashColor: Colors.transparent,
 | |
|               onTap: () => UrlLauncher(context, href, node.text).launchUrl(),
 | |
|               child: Text.rich(
 | |
|                 TextSpan(
 | |
|                   children: _renderWithLineBreaks(
 | |
|                     node.nodes,
 | |
|                     context,
 | |
|                     depth: depth,
 | |
|                   ),
 | |
|                   style: linkStyle,
 | |
|                 ),
 | |
|                 style: const TextStyle(height: 1.25),
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|         );
 | |
|       case 'li':
 | |
|         if (!{'ol', 'ul'}.contains(node.parent?.localName)) {
 | |
|           continue block;
 | |
|         }
 | |
|         return WidgetSpan(
 | |
|           child: Padding(
 | |
|             padding: EdgeInsets.only(left: fontSize),
 | |
|             child: Text.rich(
 | |
|               TextSpan(
 | |
|                 children: [
 | |
|                   if (node.parent?.localName == 'ul')
 | |
|                     const TextSpan(text: '• '),
 | |
|                   if (node.parent?.localName == 'ol')
 | |
|                     TextSpan(
 | |
|                       text:
 | |
|                           '${(node.parent?.nodes.whereType<dom.Element>().toList().indexOf(node) ?? 0) + (int.tryParse(node.parent?.attributes['start'] ?? '1') ?? 1)}. ',
 | |
|                     ),
 | |
|                   ..._renderWithLineBreaks(
 | |
|                     node.nodes,
 | |
|                     context,
 | |
|                     depth: depth,
 | |
|                   ),
 | |
|                 ],
 | |
|                 style: TextStyle(fontSize: fontSize, color: textColor),
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|         );
 | |
|       case 'blockquote':
 | |
|         return WidgetSpan(
 | |
|           child: Container(
 | |
|             padding: const EdgeInsets.only(left: 8.0),
 | |
|             decoration: BoxDecoration(
 | |
|               border: Border(
 | |
|                 left: BorderSide(
 | |
|                   color: textColor,
 | |
|                   width: 5,
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|             child: Text.rich(
 | |
|               TextSpan(
 | |
|                 children: _renderWithLineBreaks(
 | |
|                   node.nodes,
 | |
|                   context,
 | |
|                   depth: depth,
 | |
|                 ),
 | |
|               ),
 | |
|               style: TextStyle(
 | |
|                 fontStyle: FontStyle.italic,
 | |
|                 fontSize: fontSize,
 | |
|                 color: textColor,
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|         );
 | |
|       case 'code':
 | |
|         final isInline = node.parent?.localName != 'pre';
 | |
|         return isInline
 | |
|             ? TextSpan(
 | |
|                 text: node.text,
 | |
|                 style: TextStyle(
 | |
|                   fontSize: fontSize,
 | |
|                   fontFamily: 'RobotoMono',
 | |
|                   backgroundColor: const Color(0xff2d2b57),
 | |
|                   color: const Color(0xffe3dfff),
 | |
|                 ),
 | |
|               )
 | |
|             : WidgetSpan(
 | |
|                 child: HighlightSelectable(
 | |
|                   node.text,
 | |
|                   language: node.className
 | |
|                           .split(' ')
 | |
|                           .singleWhereOrNull(
 | |
|                               (className) => className.startsWith('language-'))
 | |
|                           ?.split('language-')
 | |
|                           .last ??
 | |
|                       'md',
 | |
|                   theme: themeMap['shades-of-purple']!,
 | |
|                   selectable: true,
 | |
|                   showCopyButton: !isInline,
 | |
|                   padding: EdgeInsets.symmetric(
 | |
|                     horizontal: 8,
 | |
|                     vertical: isInline ? 0 : 8,
 | |
|                   ),
 | |
|                   textStyle:
 | |
|                       TextStyle(fontSize: fontSize, fontFamily: 'RobotoMono'),
 | |
|                 ),
 | |
|               );
 | |
|       case 'img':
 | |
|         final mxcUrl = Uri.tryParse(node.attributes['src'] ?? '');
 | |
|         if (mxcUrl == null || mxcUrl.scheme != 'mxc') {
 | |
|           return TextSpan(text: node.attributes['alt']);
 | |
|         }
 | |
| 
 | |
|         final width = double.tryParse(node.attributes['width'] ?? '');
 | |
|         final height = double.tryParse(node.attributes['height'] ?? '');
 | |
|         const defaultDimension = 64.0;
 | |
|         final actualWidth = width ?? height ?? defaultDimension;
 | |
|         final actualHeight = height ?? width ?? defaultDimension;
 | |
| 
 | |
|         return WidgetSpan(
 | |
|           child: SizedBox(
 | |
|             width: actualWidth,
 | |
|             height: actualHeight,
 | |
|             child: MxcImage(
 | |
|               uri: mxcUrl,
 | |
|               width: actualWidth,
 | |
|               height: actualHeight,
 | |
|               isThumbnail: (actualWidth * actualHeight) > (256 * 256),
 | |
|             ),
 | |
|           ),
 | |
|         );
 | |
|       case 'hr':
 | |
|         return const WidgetSpan(child: Divider());
 | |
|       case 'details':
 | |
|         var obscure = true;
 | |
|         return WidgetSpan(
 | |
|           child: StatefulBuilder(
 | |
|             builder: (context, setState) => InkWell(
 | |
|               splashColor: Colors.transparent,
 | |
|               onTap: () => setState(() {
 | |
|                 obscure = !obscure;
 | |
|               }),
 | |
|               child: Text.rich(
 | |
|                 TextSpan(
 | |
|                   children: [
 | |
|                     WidgetSpan(
 | |
|                       child: Icon(
 | |
|                         obscure ? Icons.arrow_right : Icons.arrow_drop_down,
 | |
|                         size: fontSize * 1.2,
 | |
|                         color: textColor,
 | |
|                       ),
 | |
|                     ),
 | |
|                     if (obscure)
 | |
|                       ...node.nodes
 | |
|                           .where(
 | |
|                             (node) =>
 | |
|                                 node is dom.Element &&
 | |
|                                 node.localName == 'summary',
 | |
|                           )
 | |
|                           .map(
 | |
|                             (node) => _renderHtml(node, context, depth: depth),
 | |
|                           )
 | |
|                     else
 | |
|                       ..._renderWithLineBreaks(
 | |
|                         node.nodes,
 | |
|                         context,
 | |
|                         depth: depth,
 | |
|                       ),
 | |
|                   ],
 | |
|                 ),
 | |
|                 style: TextStyle(
 | |
|                   fontSize: fontSize,
 | |
|                   color: textColor,
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|         );
 | |
|       case 'span':
 | |
|         if (!node.attributes.containsKey('data-mx-spoiler')) {
 | |
|           continue block;
 | |
|         }
 | |
|         var obscure = true;
 | |
|         return WidgetSpan(
 | |
|           child: StatefulBuilder(
 | |
|             builder: (context, setState) => InkWell(
 | |
|               splashColor: Colors.transparent,
 | |
|               onTap: () => setState(() {
 | |
|                 obscure = !obscure;
 | |
|               }),
 | |
|               child: Text.rich(
 | |
|                 TextSpan(
 | |
|                   children: _renderWithLineBreaks(
 | |
|                     node.nodes,
 | |
|                     context,
 | |
|                     depth: depth,
 | |
|                   ),
 | |
|                 ),
 | |
|                 style: TextStyle(
 | |
|                   fontSize: fontSize,
 | |
|                   color: textColor,
 | |
|                   backgroundColor: obscure ? textColor : null,
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|         );
 | |
|       block:
 | |
|       default:
 | |
|         return TextSpan(
 | |
|           style: switch (node.localName) {
 | |
|             'body' => TextStyle(
 | |
|                 fontSize: fontSize,
 | |
|                 color: textColor,
 | |
|               ),
 | |
|             'a' => linkStyle,
 | |
|             'strong' => const TextStyle(fontWeight: FontWeight.bold),
 | |
|             'em' || 'i' => const TextStyle(fontStyle: FontStyle.italic),
 | |
|             'del' ||
 | |
|             's' ||
 | |
|             'strikethrough' =>
 | |
|               const TextStyle(decoration: TextDecoration.lineThrough),
 | |
|             'u' => const TextStyle(decoration: TextDecoration.underline),
 | |
|             'h1' => TextStyle(fontSize: fontSize * 1.6, height: 2),
 | |
|             'h2' => TextStyle(fontSize: fontSize * 1.5, height: 2),
 | |
|             'h3' => TextStyle(fontSize: fontSize * 1.4, height: 2),
 | |
|             'h4' => TextStyle(fontSize: fontSize * 1.3, height: 1.75),
 | |
|             'h5' => TextStyle(fontSize: fontSize * 1.2, height: 1.75),
 | |
|             'h6' => TextStyle(fontSize: fontSize * 1.1, height: 1.5),
 | |
|             'span' => TextStyle(
 | |
|                 color: node.attributes['color']?.hexToColor ??
 | |
|                     node.attributes['data-mx-color']?.hexToColor ??
 | |
|                     textColor,
 | |
|                 backgroundColor:
 | |
|                     node.attributes['data-mx-bg-color']?.hexToColor,
 | |
|               ),
 | |
|             'sup' =>
 | |
|               const TextStyle(fontFeatures: [FontFeature.superscripts()]),
 | |
|             'sub' => const TextStyle(fontFeatures: [FontFeature.subscripts()]),
 | |
|             _ => null,
 | |
|           },
 | |
|           children: _renderWithLineBreaks(
 | |
|             node.nodes,
 | |
|             context,
 | |
|             depth: depth,
 | |
|           ),
 | |
|         );
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return Text.rich(
 | |
|       _renderHtml(
 | |
|         parser.parse(html).body ?? dom.Element.html(''),
 | |
|         context,
 | |
|       ),
 | |
|       style: TextStyle(
 | |
|         fontSize: fontSize,
 | |
|         color: textColor,
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class MatrixPill extends StatelessWidget {
 | |
|   final String name;
 | |
|   final BuildContext outerContext;
 | |
|   final Uri? avatar;
 | |
|   final String uri;
 | |
|   final double? fontSize;
 | |
|   final Color? color;
 | |
| 
 | |
|   const MatrixPill({
 | |
|     super.key,
 | |
|     required this.name,
 | |
|     required this.outerContext,
 | |
|     this.avatar,
 | |
|     required this.uri,
 | |
|     required this.fontSize,
 | |
|     required this.color,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return InkWell(
 | |
|       splashColor: Colors.transparent,
 | |
|       onTap: UrlLauncher(outerContext, uri).launchUrl,
 | |
|       child: Row(
 | |
|         mainAxisSize: MainAxisSize.min,
 | |
|         children: [
 | |
|           Avatar(
 | |
|             mxContent: avatar,
 | |
|             name: name,
 | |
|             size: 16,
 | |
|           ),
 | |
|           const SizedBox(width: 6),
 | |
|           Text(
 | |
|             name,
 | |
|             style: TextStyle(
 | |
|               color: color,
 | |
|               decorationColor: color,
 | |
|               decoration: TextDecoration.underline,
 | |
|               fontSize: fontSize,
 | |
|               height: 1.25,
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| extension on String {
 | |
|   Color? get hexToColor {
 | |
|     var hexCode = this;
 | |
|     if (hexCode.startsWith('#')) hexCode = hexCode.substring(1);
 | |
|     if (hexCode.length == 6) hexCode = 'FF$hexCode';
 | |
|     final colorValue = int.tryParse(hexCode, radix: 16);
 | |
|     return colorValue == null ? null : Color(colorValue);
 | |
|   }
 | |
| }
 |