f9979e4f32
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>
395 lines
13 KiB
Dart
395 lines
13 KiB
Dart
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('&', '&')
|
||
.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 = <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)),
|
||
);
|
||
}
|
||
}
|