part of '../message_group.dart'; class _MessageContent extends StatelessWidget { final Event event; final PyramidTheme pt; final Timeline? timeline; const _MessageContent({required this.event, required this.pt, this.timeline}); // Resolves edits: returns the display event (last edit) for this event. Event get _displayEvent => timeline != null ? event.getDisplayEvent(timeline!) : event; String? _getReplyId() { final relatesTo = event.content.tryGetMap('m.relates_to'); final replyMap = relatesTo?.tryGetMap('m.in_reply_to'); return replyMap?['event_id'] as String?; } String _stripReplyFallback(String body) { final norm = body.replaceAll('\r\n', '\n'); if (!norm.startsWith('> ') && !norm.startsWith('>\n')) return norm; final lines = norm.split('\n'); var i = 0; while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) { i++; } if (i < lines.length && lines[i].trim().isEmpty) i++; if (i >= lines.length) return norm; return lines.skip(i).join('\n').trim(); } Event? _findReplyEvent(String replyId) { if (timeline == null) return null; try { return timeline!.events.firstWhere((e) => e.eventId == replyId); } catch (_) { return null; } } @override Widget build(BuildContext context) { final displayEvent = _displayEvent; final isEdited = displayEvent.eventId != event.eventId; // Still encrypted (decryption pending or failed) if (displayEvent.type == EventTypes.Encrypted) { return Padding( padding: const EdgeInsets.only(bottom: 2), child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [ Icon(Icons.lock_outline_rounded, size: 13, color: pt.fgDim), Text( 'Nachricht wird entschlüsselt…', style: TextStyle(color: pt.fgDim, fontSize: 13, fontStyle: FontStyle.italic), ), if (timeline != null) Builder( builder: (ctx) => GestureDetector( onTap: () { timeline!.requestKeys(); ScaffoldMessenger.of(ctx).showSnackBar( const SnackBar( content: Text('Schlüssel werden von anderen Geräten angefordert…'), duration: Duration(seconds: 3), ), ); }, child: Text( 'Schlüssel anfordern', style: TextStyle( color: pt.accent, fontSize: 12, decoration: TextDecoration.underline, decorationColor: pt.accent, ), ), ), ), ], ), ); } if (displayEvent.type == EventTypes.Sticker) { return _MediaContent(event: displayEvent, pt: pt); } final msgType = displayEvent.messageType; if (msgType == 'm.image') { final ext = displayEvent.body.contains('.') ? displayEvent.body.split('.').last.toLowerCase() : ''; const kImageExts = { 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'tif', 'heic', 'heif', 'avif', 'ico', 'jfif', }; // SVG kann Image.memory nicht dekodieren — über den SVG-Renderer anzeigen. if (ext == 'svg' || displayEvent.content .tryGetMap('info')?['mimetype'] == 'image/svg+xml') { return _DocPreviewContent(event: displayEvent, pt: pt); } if (ext.isNotEmpty && !kImageExts.contains(ext)) { return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded); } return _ImageContent(event: displayEvent, pt: pt); } if (msgType == 'm.audio') { return _AudioContent(event: displayEvent, pt: pt); } if (msgType == 'm.video') { return _VideoContent(event: displayEvent, pt: pt); } if (msgType == 'm.file') { if (DocumentViewer.isInlinePreviewable(displayEvent.body)) { return _DocPreviewContent(event: displayEvent, pt: pt); } if (DocumentViewer.showsInlineCard(displayEvent.body)) { return _ProprietaryInlineCard(event: displayEvent, pt: pt); } return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded); } final replyId = _getReplyId(); final replyEvent = replyId != null ? _findReplyEvent(replyId) : null; final plainBody = replyId != null ? _stripReplyFallback(displayEvent.body) : displayEvent.body; // Use formatted_body (HTML) if the event has one, otherwise plain text. final format = displayEvent.content.tryGet('format'); // Strip the Matrix reply fallback block — we render // our own quote card (_ReplyPreview) above, so the inline "In reply to @user" // would be redundant and ugly. final formattedBody = displayEvent.formattedText.replaceAll( RegExp(r'.*?', caseSensitive: false, dotAll: true), '', ).trim(); final hasHtml = format == 'org.matrix.custom.html' && formattedBody.isNotEmpty; final firstUrl = _firstUrl(plainBody); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (replyEvent != null) _ReplyPreview(replyEvent: replyEvent, pt: pt), Padding( padding: const EdgeInsets.only(bottom: 2), child: hasHtml ? _MatrixHtmlText( html: formattedBody, baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55), linkColor: pt.accent, ) : _PlainLinkText( text: plainBody, baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55), linkColor: pt.accent, ), ), if (isEdited) Text( '(bearbeitet)', style: TextStyle(color: pt.fgDim, fontSize: 11, fontStyle: FontStyle.italic), ), if (firstUrl != null) _LinkPreview(url: firstUrl, pt: pt), ], ); } // Extracts the first http(s) URL in the text, for link-preview fetching. static final RegExp _urlRegex = RegExp(r'https?://[^\s<>()\[\]]+', caseSensitive: false); String? _firstUrl(String text) { final m = _urlRegex.firstMatch(text); if (m == null) return null; var url = m.group(0)!; // Trim common trailing punctuation that isn't part of the URL. url = url.replaceAll(RegExp(r'[.,;:!?]+$'), ''); return url; } } // Handles the subset of HTML that Matrix clients emit in formatted_body: // / → bold // / → italic // / → strikethrough // → underline // → monospace, slightly different background // → tappable link (url_launcher) //
→ newline // HTML entities → decoded // Anything else → text content preserved, tags stripped // // Note:
 and 
are simplified (rendered as indented code/text). class _MatrixHtmlText extends StatelessWidget { final String html; final TextStyle baseStyle; final Color linkColor; const _MatrixHtmlText({ required this.html, required this.baseStyle, required this.linkColor, }); @override Widget build(BuildContext context) { final spans = _parseHtml(html, baseStyle, linkColor); return SelectionArea( child: RichText(text: TextSpan(children: spans, style: baseStyle)), ); } List _parseHtml(String html, TextStyle base, Color linkColor) { // Normalise line endings and collapse whitespace around block elements. var src = html .replaceAll('\r\n', '\n') .replaceAll(RegExp(r'', caseSensitive: false), '\n') .replaceAll(RegExp(r'', caseSensitive: false), '\n') .replaceAll(RegExp(r'<(p|div|li)[^>]*>', caseSensitive: false), '') .replaceAll(RegExp(r']*>', caseSensitive: false), ' ') .replaceAll(RegExp(r']*>', caseSensitive: false), ''); final spans = []; // Simple recursive stack-based parser. // We tokenise into (tag | text) chunks, then apply style based on open tags. final tagRe = RegExp(r'<(/?)(\w+)([^>]*)>', caseSensitive: false); var bold = 0; var italic = 0; var strike = 0; var underline = 0; var code = 0; String? pendingHref; int pos = 0; for (final m in tagRe.allMatches(src)) { // Text before this tag. if (m.start > pos) { final text = _decodeEntities(src.substring(pos, m.start)); if (text.isNotEmpty) { spans.add(_buildSpan( text, base, bold > 0, italic > 0, strike > 0, underline > 0, code > 0, pendingHref, linkColor, )); if (pendingHref != null) pendingHref = null; } } pos = m.end; final closing = m.group(1) == '/'; final tag = m.group(2)!.toLowerCase(); final attrs = m.group(3) ?? ''; if (closing) { switch (tag) { case 'b': case 'strong': bold = (bold - 1).clamp(0, 99); break; case 'i': case 'em': italic = (italic - 1).clamp(0, 99); break; case 'del': case 's': case 'strike': strike = (strike - 1).clamp(0, 99); break; case 'u': underline = (underline - 1).clamp(0, 99); break; case 'code': code = (code - 1).clamp(0, 99); break; case 'a': pendingHref = null; break; } } else { switch (tag) { case 'b': case 'strong': bold++; break; case 'i': case 'em': italic++; break; case 'del': case 's': case 'strike': strike++; break; case 'u': underline++; break; case 'code': code++; break; case 'a': final hrefMatch = RegExp("""href=["']([^"']+)["']""", caseSensitive: false) .firstMatch(attrs); pendingHref = hrefMatch?.group(1); break; } } } // Remaining text after last tag. if (pos < src.length) { final text = _decodeEntities(src.substring(pos)); if (text.isNotEmpty) { spans.add(_buildSpan( text, base, bold > 0, italic > 0, strike > 0, underline > 0, code > 0, pendingHref, linkColor, )); } } return spans; } InlineSpan _buildSpan( String text, TextStyle base, bool bold, bool italic, bool strike, bool underline, bool code, String? href, Color linkColor, ) { var style = base.copyWith( fontWeight: bold ? FontWeight.bold : null, fontStyle: italic ? FontStyle.italic : null, decoration: TextDecoration.combine([ if (strike) TextDecoration.lineThrough, if (underline) TextDecoration.underline, ]), fontFamily: code ? 'monospace' : null, fontSize: code ? (base.fontSize ?? 14) * 0.9 : null, ); if (href != null) { style = style.copyWith( color: linkColor, decoration: TextDecoration.underline, decorationColor: linkColor, ); return TextSpan( text: text, style: style, recognizer: TapGestureRecognizer() ..onTap = () => launchUrl(Uri.parse(href), mode: LaunchMode.externalApplication), ); } return TextSpan(text: text, style: style); } static String _decodeEntities(String s) => s .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll(''', "'") .replaceAll(' ', ' '); } class _PlainLinkText extends StatelessWidget { final String text; final TextStyle baseStyle; final Color linkColor; const _PlainLinkText({ required this.text, required this.baseStyle, required this.linkColor, }); static final _urlRe = RegExp( r'https?://[^\s\]\)>]+', caseSensitive: false, ); @override Widget build(BuildContext context) { final matches = _urlRe.allMatches(text).toList(); if (matches.isEmpty) { return SelectableText(text, style: baseStyle); } final spans = []; int pos = 0; for (final m in matches) { if (m.start > pos) { spans.add(TextSpan(text: text.substring(pos, m.start))); } final url = m.group(0)!; spans.add(TextSpan( text: url, style: baseStyle.copyWith( color: linkColor, decoration: TextDecoration.underline, decorationColor: linkColor, ), recognizer: TapGestureRecognizer() ..onTap = () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), )); pos = m.end; } if (pos < text.length) { spans.add(TextSpan(text: text.substring(pos))); } return SelectionArea( child: RichText(text: TextSpan(children: spans, style: baseStyle)), ); } }