refactor: Gott-Datei message_group.dart in 7 Dateien aufgeteilt
Gleiche Technik wie bei settings_modal.dart: reine Verschiebung per Dart part/part-of, keine Logikänderung. message_group.dart schrumpft von 2775 auf 30 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze (Baseline unverändert). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user