Files
pyramid/lib/features/chat/message/message_text.dart
T
Bernd Steckmeister f9979e4f32 fix: beim Gott-Datei-Split verlorene Erklärkommentare wiederhergestellt
Qualitäts-Review-Befund (a): Alle 6 part/part-of-Aufteilungen hatten Code
vollständig übernommen (Zeilen-Multiset-Vergleich Original vs. heutiger
Stand: keine Code-Zeile verloren, keine hinzugefügt), ABER erklärende
Kommentare über den Klassen gingen verloren (24 Blöcke, u. a. die
HTML-Subset-Doku über _MatrixHtmlText, die Selbst-Aussperr-Warnung über
updatePowerLevelsSafely, TURN-Verbrauchskarte, PDF-Cache/Viewer-Doku).
Alle an den richtigen Klassen wiederhergestellt; reine Trennlinien und
ALL-CAPS-Abschnittslabels bewusst nicht (Gott-Datei-Navigation, durch
die neuen Dateinamen ersetzt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:37:48 +02:00

395 lines
13 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, dynamic>('m.relates_to');
final replyMap = relatesTo?.tryGetMap<String, dynamic>('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<String, Object?>('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<String>('format');
// Strip the Matrix reply fallback <mx-reply>…</mx-reply> 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'<mx-reply>.*?</mx-reply>', 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:
// <b>/<strong> → bold
// <i>/<em> → italic
// <del>/<s> → strikethrough
// <u> → underline
// <code> → monospace, slightly different background
// <a href> → tappable link (url_launcher)
// <br> → newline
// HTML entities → decoded
// Anything else → text content preserved, tags stripped
//
// Note: <pre> and <blockquote> 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<InlineSpan> _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'<br\s*/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'</(p|div|blockquote|pre|li)>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<(p|div|li)[^>]*>', caseSensitive: false), '')
.replaceAll(RegExp(r'<blockquote[^>]*>', caseSensitive: false), ' ')
.replaceAll(RegExp(r'<pre[^>]*>', caseSensitive: false), '');
final spans = <InlineSpan>[];
// 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('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'")
.replaceAll('&nbsp;', ' ');
}
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 = <InlineSpan>[];
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)),
);
}
}