import 'package:flutter/cupertino.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: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:fluffychat/utils/event_checkbox_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/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; final String? eventId; final Set? checkboxCheckedEvents; final bool limitHeight; const HtmlMessage({ super.key, required this.html, required this.room, required this.fontSize, required this.linkStyle, this.textColor = Colors.black, required this.onOpen, this.eventId, this.checkboxCheckedEvents, this.limitHeight = true, }); /// Keep in sync with: https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes static const Set 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 blockHtmlTags = { 'p', 'ul', 'ol', 'pre', 'div', 'table', 'details', 'blockquote', }; /// We add line breaks before these tags: static const Set fullLineHtmlTag = { 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', }; /// Adding line breaks before block elements. List _renderWithLineBreaks( dom.NodeList nodes, BuildContext context, { int depth = 1, }) { final onlyElements = nodes.whereType().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; } final eventId = this.eventId; final isCheckbox = node.className == 'task-list-item'; final checkboxIndex = isCheckbox ? node.rootElement .getElementsByClassName('task-list-item') .indexOf(node) + 1 : null; final checkedByReaction = !isCheckbox ? null : checkboxCheckedEvents?.firstWhereOrNull( (event) => event.checkedCheckboxId == checkboxIndex, ); final staticallyChecked = !isCheckbox ? false : node.children.first.attributes['checked'] == 'true'; 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().toList().indexOf(node) ?? 0) + (int.tryParse(node.parent?.attributes['start'] ?? '1') ?? 1)}. ', ), if (node.className == 'task-list-item') WidgetSpan( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: SizedBox.square( dimension: fontSize + 2, child: CupertinoCheckbox( checkColor: textColor, side: BorderSide(color: textColor), activeColor: textColor.withAlpha(64), value: staticallyChecked || checkedByReaction != null, onChanged: eventId == null || checkboxIndex == null || staticallyChecked || !room.canSendDefaultMessages || (checkedByReaction != null && checkedByReaction.senderId != room.client.userID) ? null : (_) => showFutureLoadingDialog( context: context, future: () => checkedByReaction != null ? room.redactEvent( checkedByReaction.eventId, ) : room.checkCheckbox( eventId, checkboxIndex, ), ), ), ), ), ), ..._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 WidgetSpan( child: Material( clipBehavior: Clip.hardEdge, borderRadius: BorderRadius.circular(4), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: HighlightView( node.text, language: node.className .split(' ') .singleWhereOrNull( (className) => className.startsWith('language-'), ) ?.split('language-') .last ?? 'md', theme: shadesOfPurpleTheme, 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) { final element = parser.parse(html).body ?? dom.Element.html(''); return Text.rich( _renderHtml(element, context), style: TextStyle( fontSize: fontSize, color: textColor, ), maxLines: limitHeight ? 64 : null, overflow: TextOverflow.fade, ); } } 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); } } extension on dom.Element { dom.Element get rootElement => parent?.rootElement ?? this; }