import 'dart:convert'; import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pyramid/features/chat/media_player.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:matrix/matrix.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/media_cache.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/features/chat/emoji_picker.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:pyramid/features/chat/document_viewer.dart'; import 'package:pyramid/features/chat/media_viewer.dart'; import 'package:pyramid/features/chat/reaction_service.dart'; import 'package:pyramid/utils/gif_favorite_service.dart'; import 'package:pyramid/widgets/mxc_image.dart'; import 'package:url_launcher/url_launcher.dart'; // Wraps a group of consecutive messages from the same sender class MessageGroup extends StatefulWidget { final List events; final String currentUserId; final Room? room; final Timeline? timeline; final VoidCallback? onReply; final Set selectedIds; final ValueChanged? onToggleSelect; final bool showStatusIndicator; /// Event IDs that another member has read up to (their last-read position). /// A ✓✓ is rendered at any of this group's events contained here. final Set readEventIds; /// When non-null and pointing to a non-last event in this group, an inline /// "Neue Nachrichten" divider is rendered after that event's row. final String? fullyReadId; /// Jump-to-message (from search): the row matching [jumpEventId] gets /// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted. final String? jumpEventId; final GlobalKey? jumpKey; final String? highlightEventId; final bool highlightOn; const MessageGroup({ super.key, required this.events, required this.currentUserId, this.room, this.timeline, this.onReply, this.selectedIds = const {}, this.onToggleSelect, this.showStatusIndicator = false, this.readEventIds = const {}, this.fullyReadId, this.jumpEventId, this.jumpKey, this.highlightEventId, this.highlightOn = false, }); @override State createState() => _MessageGroupState(); } class _MessageGroupState extends State { // Per-message hover state (desktop): which event row the pointer is over, and // which row currently has its reaction-picker overlay open. String? _hoveredEventId; String? _overlayEventId; double _swipeOffset = 0; static const _swipeThreshold = 56.0; bool _swipeTriggered = false; bool get _isMobile => Platform.isAndroid || Platform.isIOS; @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final firstEvent = widget.events.first; final sender = firstEvent.senderFromMemoryOrFallback; final name = sender.calcDisplayname(); final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; final color = _colorFromId(firstEvent.senderId); final time = _formatTime(firstEvent.originServerTs); final avatarUri = sender.avatarUrl; // Build a FLAT column of rows — each message at the same indentation level. // First event row: avatar + name + time + content // Continuation rows: timestamp-slot + content // This prevents the nested-Row double-indent bug. final rows = []; // ── First event row ────────────────────────────────── // Each message row is INDIVIDUALLY selectable (see _selectableRow) so two // consecutive messages from the same sender aren't selected as one block. rows.add(_selectableRow(firstEvent, pt, Padding( padding: EdgeInsets.only(top: pt.msgGap / 2, left: 20, right: 20, bottom: 2), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Avatar SizedBox( width: 38, child: Column(children: [ const SizedBox(height: 2), _Avatar(initial: initial, color: color, pt: pt, size: 38, avatarUri: avatarUri), ]), ), const SizedBox(width: 14), // Name + time + message Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Text(name, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600)), const SizedBox(width: 8), Text(time, style: TextStyle(color: pt.fgDim, fontSize: 11)), ]), const SizedBox(height: 2), _MessageBody( event: firstEvent, pt: pt, timeline: widget.timeline, room: widget.room, currentUserId: widget.currentUserId, ), ], ), ), ], ), ))); if (widget.readEventIds.contains(firstEvent.eventId)) rows.add(_ReadMarker(pt: pt)); // Inline unread divider after the first event when it's the fullyRead // marker and there are more events in this group. if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) { rows.add(_InlineUnreadDivider(pt: pt)); } // ── Continuation event rows (siblings, same indentation) ── for (var j = 1; j < widget.events.length; j++) { final event = widget.events[j]; rows.add(_selectableRow(event, pt, Padding( padding: const EdgeInsets.only(top: 8, left: 20, right: 20), child: _ContinuationMessage( event: event, pt: pt, timeline: widget.timeline, room: widget.room, currentUserId: widget.currentUserId, ), ))); if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt)); // Inline divider after this event if it's the fullyRead and not the last. if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) { rows.add(_InlineUnreadDivider(pt: pt)); } } // Sending/sent indicator on the newest own message — only when no read ✓✓ // is already shown for it (the ✓✓ implies it was delivered + read). if (widget.showStatusIndicator && firstEvent.senderId == widget.currentUserId && widget.room != null && !widget.readEventIds.contains(widget.events.last.eventId)) { rows.add(Padding( padding: const EdgeInsets.only(right: 24, bottom: 1), child: Align( alignment: Alignment.centerRight, child: _MessageStatus( lastEvent: widget.events.last, pt: pt, ), ), )); } rows.add(const SizedBox(height: 2)); // Hover highlight + actions are now per-row (see _selectableRow), so the // group is just the column of rows. Widget content = Column( crossAxisAlignment: CrossAxisAlignment.start, children: rows, ); if (!_isMobile) return content; return GestureDetector( onHorizontalDragUpdate: (d) { if (d.delta.dx > 0) return; // Only allow right-to-left swipe for reply setState(() { _swipeOffset = (_swipeOffset + d.delta.dx).clamp(-_swipeThreshold, 0.0); if (_swipeOffset <= -_swipeThreshold && !_swipeTriggered) { _swipeTriggered = true; HapticFeedback.mediumImpact(); } }); }, onHorizontalDragEnd: (_) { if (_swipeTriggered) widget.onReply?.call(); setState(() { _swipeOffset = 0; _swipeTriggered = false; }); }, child: Stack( children: [ Transform.translate( offset: Offset(_swipeOffset, 0), child: content, ), if (_swipeOffset < -8) Positioned( right: 12, top: 0, bottom: 0, child: Center( child: AnimatedOpacity( opacity: (-_swipeOffset / _swipeThreshold).clamp(0.0, 1.0), duration: const Duration(milliseconds: 60), child: Icon(Icons.reply_rounded, size: 20, color: pt.fgMuted), ), ), ), ], ), ); } // Wraps a single message row so it can be selected independently of the rest // of the sender's group. Long-press enters selection mode; in selection mode a // full-row tap overlay toggles this message's selection. Widget _selectableRow(Event event, PyramidTheme pt, Widget child) { final selected = widget.selectedIds.contains(event.eventId); final inSel = widget.selectedIds.isNotEmpty; final showActions = !_isMobile && !inSel && (_hoveredEventId == event.eventId || _overlayEventId == event.eventId); final isJumpTarget = widget.jumpEventId == event.eventId; final isHighlighted = widget.highlightEventId == event.eventId; Widget row = GestureDetector( key: isJumpTarget ? widget.jumpKey : null, onLongPress: () => widget.onToggleSelect?.call(event.eventId), child: Stack( children: [ AnimatedContainer( duration: const Duration(milliseconds: 100), color: selected ? pt.accent.withAlpha(40) : (showActions ? pt.bg2 : Colors.transparent), child: child, ), // Jump-from-search highlight — fades out via AnimatedOpacity. if (isHighlighted) Positioned.fill( child: IgnorePointer( child: AnimatedOpacity( opacity: widget.highlightOn ? 1.0 : 0.0, duration: const Duration(milliseconds: 900), curve: Curves.easeOut, child: Container(color: pt.accent.withAlpha(48)), ), ), ), if (selected) Positioned( left: 0, top: 0, bottom: 0, child: Container(width: 3, color: pt.accent), ), if (showActions) Positioned( top: 0, right: 32, child: _HoverActions( pt: pt, event: event, events: [event], room: widget.room, currentUserId: widget.currentUserId, onReply: widget.onReply, onOverlayStateChanged: (active) => setState( () => _overlayEventId = active ? event.eventId : null), ), ), if (inSel) Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => widget.onToggleSelect?.call(event.eventId), ), ), ], ), ); if (_isMobile) return row; // Desktop: per-row hover highlight + actions. return MouseRegion( onEnter: (_) => setState(() => _hoveredEventId = event.eventId), onExit: (_) => setState(() { if (_hoveredEventId == event.eventId) _hoveredEventId = null; }), child: row, ); } Color _colorFromId(String id) { const colors = [ Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981), Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B), Color(0xFFFF6B9D), ]; if (id.isEmpty) return colors[0]; return colors[id.codeUnitAt(0) % colors.length]; } String _formatTime(DateTime t) { final h = t.hour.toString().padLeft(2, '0'); final m = t.minute.toString().padLeft(2, '0'); return '$h:$m'; } } // Shown under a message whose send failed (e.g. offline) — keeps the message // visible with retry / delete instead of letting it silently disappear. class _SendFailedRow extends StatelessWidget { final Event event; final PyramidTheme pt; const _SendFailedRow({required this.event, required this.pt}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 3, bottom: 1), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline_rounded, size: 13, color: pt.danger), const SizedBox(width: 4), Text('Nicht gesendet', style: TextStyle(color: pt.danger, fontSize: 12)), const SizedBox(width: 10), _link('Wiederholen', pt, () async { try { await event.sendAgain(); } catch (_) {} }), const SizedBox(width: 10), _link('Löschen', pt, () async { try { await event.cancelSend(); } catch (_) {} }), ], ), ); } Widget _link(String label, PyramidTheme pt, VoidCallback onTap) => GestureDetector( onTap: onTap, child: Text( label, style: TextStyle( color: pt.accent, fontSize: 12, decoration: TextDecoration.underline, decorationColor: pt.accent, ), ), ); } // Single message (first in group) with full avatar + name shown class _MessageBody extends StatelessWidget { final Event event; final PyramidTheme pt; final Timeline? timeline; final Room? room; final String currentUserId; const _MessageBody({ required this.event, required this.pt, this.timeline, this.room, required this.currentUserId, }); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _MessageContent(event: event, pt: pt, timeline: timeline), if (event.status.isError) _SendFailedRow(event: event, pt: pt), if (timeline != null) _ReactionsRow(event: event, timeline: timeline!, room: room, currentUserId: currentUserId, pt: pt), ], ); } } // Continuation message — no avatar, just indented text with timestamp on hover class _ContinuationMessage extends StatefulWidget { final Event event; final PyramidTheme pt; final Timeline? timeline; final Room? room; final String currentUserId; const _ContinuationMessage({ required this.event, required this.pt, this.timeline, this.room, required this.currentUserId, }); @override State<_ContinuationMessage> createState() => _ContinuationMessageState(); } class _ContinuationMessageState extends State<_ContinuationMessage> { bool _hovered = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Timestamp on hover (in avatar column space) SizedBox( width: 38, child: Padding( padding: const EdgeInsets.only(top: 4), child: AnimatedOpacity( opacity: _hovered ? 1 : 0, duration: const Duration(milliseconds: 120), child: Text( _formatTime(widget.event.originServerTs), textAlign: TextAlign.center, style: TextStyle( color: widget.pt.fgDim, fontSize: 10, ), ), ), ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _MessageContent(event: widget.event, pt: widget.pt, timeline: widget.timeline), if (widget.event.status.isError) _SendFailedRow(event: widget.event, pt: widget.pt), if (widget.timeline != null) _ReactionsRow( event: widget.event, timeline: widget.timeline!, room: widget.room, currentUserId: widget.currentUserId, pt: widget.pt, ), ], ), ), ], ), ); } String _formatTime(DateTime t) { final h = t.hour.toString().padLeft(2, '0'); final m = t.minute.toString().padLeft(2, '0'); return '$h:$m'; } } 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; } } // ─── Link preview card (Open-Graph, WhatsApp/Discord-style) ────────────────── class _LinkPreviewData { final String? title; final String? description; final String? imageMxc; const _LinkPreviewData({this.title, this.description, this.imageMxc}); bool get isEmpty => (title == null || title!.isEmpty) && (description == null || description!.isEmpty) && imageMxc == null; } class _LinkPreview extends ConsumerStatefulWidget { final String url; final PyramidTheme pt; const _LinkPreview({required this.url, required this.pt}); // url → preview (null = fetched but nothing usable). Avoids refetching on // every rebuild/scroll. static final Map _cache = {}; @override ConsumerState<_LinkPreview> createState() => _LinkPreviewState(); } class _LinkPreviewState extends ConsumerState<_LinkPreview> { _LinkPreviewData? _data; bool _done = false; @override void initState() { super.initState(); _load(); } Future _load() async { if (_LinkPreview._cache.containsKey(widget.url)) { setState(() { _data = _LinkPreview._cache[widget.url]; _done = true; }); return; } try { final client = await ref.read(matrixClientProvider.future); final hs = client.homeserver.toString(); final enc = Uri.encodeQueryComponent(widget.url); final headers = {'authorization': 'Bearer ${client.accessToken}'}; var res = await client.httpClient.get( Uri.parse('$hs/_matrix/client/v1/media/preview_url?url=$enc'), headers: headers, ); if (res.statusCode == 404) { // Legacy fallback for older servers. res = await client.httpClient.get( Uri.parse('$hs/_matrix/media/v3/preview_url?url=$enc'), headers: headers, ); } _LinkPreviewData? data; if (res.statusCode == 200) { final json = jsonDecode(utf8.decode(res.bodyBytes)) as Map; data = _LinkPreviewData( title: (json['og:title'] as String?)?.trim(), description: (json['og:description'] as String?)?.trim(), imageMxc: json['og:image'] as String?, ); if (data.isEmpty) data = null; } _LinkPreview._cache[widget.url] = data; if (mounted) setState(() { _data = data; _done = true; }); } catch (_) { _LinkPreview._cache[widget.url] = null; if (mounted) setState(() { _data = null; _done = true; }); } } @override Widget build(BuildContext context) { final data = _data; if (!_done || data == null) return const SizedBox.shrink(); final pt = widget.pt; final client = ref.watch(matrixClientProvider).valueOrNull; return Padding( padding: const EdgeInsets.only(top: 6, bottom: 2), child: GestureDetector( onTap: () => launchUrl(Uri.parse(widget.url), mode: LaunchMode.externalApplication), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 380), child: ClipRRect( borderRadius: BorderRadius.circular(pt.rBase), child: Container( decoration: BoxDecoration( color: pt.bg2, border: Border(left: BorderSide(color: pt.accent, width: 3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (data.imageMxc != null && client != null) ConstrainedBox( constraints: const BoxConstraints(maxHeight: 180), child: SizedBox( width: double.infinity, child: MxcImage( mxcUri: data.imageMxc!, client: client, fit: BoxFit.cover, placeholder: const SizedBox.shrink(), ), ), ), Padding( padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (data.title != null && data.title!.isNotEmpty) Text( data.title!, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis, ), if (data.description != null && data.description!.isNotEmpty) ...[ const SizedBox(height: 3), Text( data.description!, style: TextStyle(color: pt.fgMuted, fontSize: 12, height: 1.35), maxLines: 3, overflow: TextOverflow.ellipsis, ), ], ], ), ), ], ), ), ), ), ), ); } } class _ReplyPreview extends StatelessWidget { final Event replyEvent; final PyramidTheme pt; const _ReplyPreview({required this.replyEvent, required this.pt}); @override Widget build(BuildContext context) { final senderName = replyEvent.senderFromMemoryOrFallback.calcDisplayname(); // Media-aware preview: show an icon + label instead of a raw filename, // matching WhatsApp/Discord. Falls back to stripped text for m.text. final (IconData? icon, String body) = _previewFor(replyEvent); return Padding( padding: const EdgeInsets.only(bottom: 4), child: IntrinsicHeight( child: ClipRRect( borderRadius: BorderRadius.circular(pt.rSm), child: Container( color: pt.accentSoft, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Accent bar flush with the left edge of the tinted card. Container(width: 3, color: pt.accent), Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(8, 5, 10, 5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(senderName, style: TextStyle(color: pt.accent, fontSize: 12, fontWeight: FontWeight.w600)), Row( children: [ if (icon != null) ...[ Icon(icon, size: 13, color: pt.fgMuted), const SizedBox(width: 4), ], Expanded( child: Text( body, style: TextStyle(color: pt.fgMuted, fontSize: 12), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ], ), ), ), ], ), ), ), ), ); } (IconData?, String) _previewFor(Event e) { switch (e.messageType) { case 'm.image': return (Icons.image_outlined, 'Bild'); case 'm.video': return (Icons.videocam_outlined, 'Video'); case 'm.audio': return (Icons.mic_none_rounded, 'Sprachnachricht'); case 'm.file': return (Icons.insert_drive_file_outlined, e.body.isNotEmpty ? e.body : 'Datei'); } if (e.type == EventTypes.Sticker) return (Icons.emoji_emotions_outlined, 'Sticker'); // Plain text: strip nested reply fallback, collapse to first line. String body = e.body.replaceAll('\r\n', '\n'); if (body.startsWith('> ')) { final lines = body.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) body = lines.skip(i).join(' ').trim(); } return (null, body.split('\n').first); } } class _ImageContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _ImageContent({required this.event, required this.pt}); @override ConsumerState<_ImageContent> createState() => _ImageContentState(); } class _ImageContentState extends ConsumerState<_ImageContent> { late Future<_MediaResult> _future; @override void initState() { super.initState(); _future = _load(); } Future<_MediaResult> _load() async { final event = widget.event; final rawUrl = event.content.tryGet('url') ?? ''; if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) { return _MediaResult.network(rawUrl); } // Serve from the shared LRU cache if this image was viewed recently. final cached = MediaCache.instance.get(event.eventId); if (cached != null) return _MediaResult.bytes(cached); final client = await ref.read(matrixClientProvider.future); final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) return _MediaResult.none(); if (event.isAttachmentEncrypted) { try { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); MediaCache.instance.put(event.eventId, file.bytes); return _MediaResult.bytes(file.bytes); } catch (e) { return _MediaResult.error(_classifyMediaError(e)); } } else { try { final httpUri = await mxcUri.getThumbnailUri(client, width: 800, height: 600, method: ThumbnailMethod.scale); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); MediaCache.instance.put(event.eventId, res.bodyBytes); return _MediaResult.bytes(res.bodyBytes); } catch (_) { try { final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); MediaCache.instance.put(event.eventId, res.bodyBytes); return _MediaResult.bytes(res.bodyBytes); } catch (e) { return _MediaResult.error(e.toString().split('\n').first); } } } } @override Widget build(BuildContext context) { final pt = widget.pt; return LayoutBuilder(builder: (context, constraints) { // Never exceed the available chat width (important on narrow phones), // capped at the comfortable desktop maximum. final maxW = constraints.maxWidth.isFinite ? constraints.maxWidth.clamp(80.0, 420.0) : 420.0; const maxH = 320.0; // Reserve the final display box up-front from the event's info dimensions, // so the layout doesn't shift (and yank the scroll position) when the image // finishes loading. Null when dimensions are absent. final box = _reservedImageSize(widget.event, maxW: maxW, maxH: maxH); return FutureBuilder<_MediaResult>( future: _future, builder: (context, snap) { Widget inner; if (snap.connectionState == ConnectionState.waiting) { inner = Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim))); } else if (snap.hasData) { final result = snap.data!; if (result.bytes != null) { inner = Image.memory( result.bytes!, fit: BoxFit.contain, gaplessPlayback: true, errorBuilder: (context, e, stack) => _mediaFallback(pt, e.toString().split('\n').first, widget.event.body), ); } else if (result.networkUrl != null) { inner = Image.network(result.networkUrl!, fit: BoxFit.contain); } else { inner = _mediaFallback(pt, result.error, widget.event.body); } } else { inner = _mediaFallback(pt, null, widget.event.body); } final result = snap.data; return GestureDetector( onTap: (snap.hasData && result != null) ? () => MediaViewer.show( context, senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(), filename: widget.event.body, bytes: result.bytes, networkUrl: result.networkUrl, event: widget.event, ) : null, child: Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(top: 4, bottom: 4), width: box?.width, height: box?.height, constraints: box == null ? BoxConstraints( minWidth: 120, minHeight: 90, maxWidth: maxW, maxHeight: maxH, ) : null, decoration: BoxDecoration( color: pt.bg1, borderRadius: BorderRadius.circular(pt.rBase), ), clipBehavior: Clip.hardEdge, child: inner, ), ), ); }, ); }); } } // Computes the on-screen box for an image/video event, fitting within // [maxW]×[maxH] while preserving the aspect ratio from the event's `info` // (w/h). Used to reserve layout space before the media loads so nothing shifts. // Returns null when the event carries no dimensions — callers should then use // loose constraints instead of a fixed box. Size? _reservedImageSize(Event event, {required double maxW, required double maxH}) { final info = event.content.tryGetMap('info'); final w = (info?['w'] as num?)?.toDouble(); final h = (info?['h'] as num?)?.toDouble(); if (w == null || h == null || w <= 0 || h <= 0) return null; // Fit inside maxW×maxH without distorting; never upscale small images. var scale = (maxW / w).clamp(0.0, maxH / h); if (scale > 1.0) scale = 1.0; return Size(w * scale, h * scale); } // Downloads media bytes then hands off to PyramidAudioPlayer / PyramidVideoPlayer class _AudioContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _AudioContent({required this.event, required this.pt}); @override ConsumerState<_AudioContent> createState() => _AudioContentState(); } class _AudioContentState extends ConsumerState<_AudioContent> { late Future<_MediaResult> _future; @override void initState() { super.initState(); _future = _loadBytes(); } Future<_MediaResult> _loadBytes() async { final client = await ref.read(matrixClientProvider.future); final event = widget.event; if (event.isAttachmentEncrypted) { try { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); return _MediaResult.bytes(file.bytes); } catch (e) { return _MediaResult.error(_classifyMediaError(e)); } } final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) return _MediaResult.none(); try { final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); return _MediaResult.bytes(res.bodyBytes); } catch (e) { return _MediaResult.error(e.toString().split('\n').first); } } @override Widget build(BuildContext context) { final pt = widget.pt; return FutureBuilder<_MediaResult>( future: _future, builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row(mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim)), const SizedBox(width: 10), Text('Lade Audio…', style: TextStyle(color: pt.fgMuted, fontSize: 12)), ]), ); } final result = snap.data; if (result?.bytes == null) { return _mediaFallback(pt, result?.error, widget.event.body); } return PyramidAudioPlayer( bytes: result!.bytes!, filename: widget.event.body, pt: pt, isVoice: widget.event.content['msgtype'] == 'm.audio', ); }, ); } } class _VideoContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _VideoContent({required this.event, required this.pt}); @override ConsumerState<_VideoContent> createState() => _VideoContentState(); } class _VideoContentState extends ConsumerState<_VideoContent> { late Future<_MediaResult> _future; @override void initState() { super.initState(); _future = _loadBytes(); } Future<_MediaResult> _loadBytes() async { final client = await ref.read(matrixClientProvider.future); final event = widget.event; if (event.isAttachmentEncrypted) { try { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); return _MediaResult.bytes(file.bytes); } catch (e) { return _MediaResult.error(_classifyMediaError(e)); } } final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) return _MediaResult.none(); try { final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); return _MediaResult.bytes(res.bodyBytes); } catch (e) { return _MediaResult.error(e.toString().split('\n').first); } } @override Widget build(BuildContext context) { final pt = widget.pt; return FutureBuilder<_MediaResult>( future: _future, builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { return Container( margin: const EdgeInsets.only(top: 4, bottom: 4), constraints: const BoxConstraints(maxWidth: 360), height: 200, decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), ); } final result = snap.data; if (result?.bytes == null) { return _FileContent(event: widget.event, pt: pt, icon: Icons.videocam_rounded); } return PyramidVideoPlayer( bytes: result!.bytes!, filename: widget.event.body, pt: pt, senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(), ); }, ); } } class _MediaContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _MediaContent({required this.event, required this.pt}); @override ConsumerState<_MediaContent> createState() => _MediaContentState(); } class _MediaContentState extends ConsumerState<_MediaContent> { late Future<_MediaResult> _future; @override void initState() { super.initState(); _future = _load(); } Future<_MediaResult> _load() async { final client = await ref.read(matrixClientProvider.future); final event = widget.event; // External URL (e.g. Giphy GIFs) — not an MXC URI final rawUrl = event.content.tryGet('url') ?? ''; if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) { return _MediaResult.network(rawUrl); } final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) return _MediaResult.none(); if (event.isAttachmentEncrypted) { try { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); return _MediaResult.bytes(file.bytes); } catch (e) { return _MediaResult.error(_classifyMediaError(e)); } } else { try { final httpUri = await mxcUri.getThumbnailUri( client, width: 800, height: 600, method: ThumbnailMethod.scale, ); final res = await client.httpClient.get( httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}, ); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); return _MediaResult.bytes(res.bodyBytes); } catch (_) { // Fall back to full-size download try { final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get( httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}, ); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); return _MediaResult.bytes(res.bodyBytes); } catch (e) { return _MediaResult.error(e.toString().split('\n').first); } } } } @override Widget build(BuildContext context) { final pt = widget.pt; final isSticker = widget.event.type == EventTypes.Sticker; final maxW = isSticker ? 160.0 : 420.0; final maxH = isSticker ? 160.0 : 320.0; return FutureBuilder<_MediaResult>( future: _future, builder: (context, snap) { Widget inner; if (snap.connectionState == ConnectionState.waiting) { inner = Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim), ), ); } else if (snap.hasData) { final result = snap.data!; if (result.networkUrl != null) { inner = Image.network( result.networkUrl!, fit: BoxFit.contain, errorBuilder: (context, e, stack) => _fallback(pt), ); } else if (result.bytes != null) { inner = Image.memory( result.bytes!, fit: BoxFit.contain, errorBuilder: (context, e, stack) => _fallback(pt), ); } else { inner = _fallback(pt, result.error); } } else { inner = _fallback(pt); } return Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(top: 4, bottom: 4), constraints: BoxConstraints(maxWidth: maxW, maxHeight: maxH), decoration: isSticker ? null : BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), clipBehavior: isSticker ? Clip.none : Clip.hardEdge, child: inner, ), ); }, ); } Widget _fallback(PyramidTheme pt, [String? error]) => _mediaFallback(pt, error, widget.event.body); } // Downloads bytes for a PDF/SVG file and shows PdfInlinePreview or SvgImage. class _DocPreviewContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _DocPreviewContent({required this.event, required this.pt}); @override ConsumerState<_DocPreviewContent> createState() => _DocPreviewContentState(); } class _DocPreviewContentState extends ConsumerState<_DocPreviewContent> { late Future<_MediaResult> _future; @override void initState() { super.initState(); _future = _loadBytes(); } Future<_MediaResult> _loadBytes() async { final event = widget.event; // Serve from cache if we already downloaded this attachment this session. final cached = _DocBytesCache.get(event.eventId); if (cached != null) return _MediaResult.bytes(cached); final client = await ref.read(matrixClientProvider.future); if (event.isAttachmentEncrypted) { try { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); _DocBytesCache.put(event.eventId, file.bytes); return _MediaResult.bytes(file.bytes); } catch (e) { return _MediaResult.error(_classifyMediaError(e)); } } final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) return _MediaResult.none(); try { final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}'); _DocBytesCache.put(event.eventId, res.bodyBytes); return _MediaResult.bytes(res.bodyBytes); } catch (e) { return _MediaResult.error(e.toString().split('\n').first); } } Future _saveFile(Uint8List bytes) async { try { Directory? dir; if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { dir = await getDownloadsDirectory(); } dir ??= await getTemporaryDirectory(); final filename = widget.event.body.isNotEmpty ? widget.event.body : 'file'; final ext = filename.contains('.') ? filename.split('.').last : 'bin'; final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext'; final file = File('${dir.path}/$name'); await file.writeAsBytes(bytes); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Gespeichert: ${file.path}'), duration: const Duration(seconds: 4), )); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'))); } } } @override Widget build(BuildContext context) { final pt = widget.pt; final ext = widget.event.body.contains('.') ? widget.event.body.split('.').last.toLowerCase() : ''; return FutureBuilder<_MediaResult>( future: _future, builder: (ctx, snap) { if (snap.connectionState == ConnectionState.waiting) { return Container( width: 280, height: 80, margin: const EdgeInsets.only(top: 4, bottom: 4), decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)), ); } final result = snap.data; if (result?.bytes == null) { return _FileContent(event: widget.event, pt: pt, icon: Icons.picture_as_pdf_outlined); } final bytes = result!.bytes!; final sender = widget.event.senderFromMemoryOrFallback.calcDisplayname(); if (ext == 'pdf' || ext == 'ai' || ext == 'eps') { return PdfInlinePreview( bytes: bytes, filename: widget.event.body, pt: pt, senderName: sender, onDownload: () => _saveFile(bytes), ); } // SVG — show inline like an image return GestureDetector( onTap: () => DocumentViewer.show( context, filename: widget.event.body, bytes: bytes, senderName: sender, ), child: Container( margin: const EdgeInsets.only(top: 4, bottom: 4), constraints: const BoxConstraints(maxWidth: 300, maxHeight: 300), decoration: BoxDecoration( color: pt.bg1, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), clipBehavior: Clip.hardEdge, child: SvgPicture.memory(bytes, fit: BoxFit.contain), ), ); }, ); } } // Inline card for proprietary formats — shows immediately from event metadata, // downloads lazily only when the user taps or hits the download button. class _ProprietaryInlineCard extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; const _ProprietaryInlineCard({required this.event, required this.pt}); @override ConsumerState<_ProprietaryInlineCard> createState() => _ProprietaryInlineCardState(); } class _ProprietaryInlineCardState extends ConsumerState<_ProprietaryInlineCard> { bool _loading = false; Uint8List? _cachedBytes; String get _filename => widget.event.body.isNotEmpty ? widget.event.body : 'file'; String get _ext => _filename.contains('.') ? _filename.split('.').last.toLowerCase() : ''; int? get _fileSize => widget.event.content.tryGetMap('info')?['size'] as int?; String _fmtSize(int b) { if (b < 1024) return '$b B'; if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; } IconData get _icon => switch (_ext) { 'psd' || 'psb' => Icons.photo_camera_outlined, 'ai' || 'eps' => Icons.gesture_outlined, 'indd' || 'inx' => Icons.menu_book_outlined, 'afphoto' || 'afdesign' || 'afpub' => Icons.palette_outlined, 'sketch' || 'fig' || 'xd' => Icons.design_services_outlined, 'doc' => Icons.description_outlined, 'ppt' => Icons.slideshow_outlined, 'xls' => Icons.table_chart_outlined, _ => Icons.insert_drive_file_outlined, }; Future _download() async { if (_cachedBytes != null) return _cachedBytes!; final event = widget.event; final shared = _DocBytesCache.get(event.eventId); if (shared != null) return _cachedBytes = shared; final client = await ref.read(matrixClientProvider.future); if (event.isAttachmentEncrypted) { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); _DocBytesCache.put(event.eventId, file.bytes); return _cachedBytes = file.bytes; } final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) throw Exception('Kein URI'); final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}'); _DocBytesCache.put(event.eventId, res.bodyBytes); return _cachedBytes = res.bodyBytes; } Future _openViewer() async { if (_loading) return; setState(() => _loading = true); try { final bytes = await _download(); if (!mounted) return; await DocumentViewer.show( context, filename: _filename, bytes: bytes, senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(), ); } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3))); } finally { if (mounted) setState(() => _loading = false); } } Future _save() async { if (_loading) return; setState(() => _loading = true); try { final bytes = await _download(); Directory? dir; if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { dir = await getDownloadsDirectory(); } dir ??= await getTemporaryDirectory(); final outFile = File('${dir.path}/pyramid_${DateTime.now().millisecondsSinceEpoch}.$_ext'); await outFile.writeAsBytes(bytes); if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Gespeichert: ${outFile.path}'), duration: const Duration(seconds: 4))); } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3))); } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { final pt = widget.pt; final extLabel = _ext.toUpperCase(); final size = _fileSize; return GestureDetector( onTap: _openViewer, child: Container( width: 280, margin: const EdgeInsets.only(top: 4, bottom: 4), decoration: BoxDecoration( borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), clipBehavior: Clip.hardEdge, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // Visual area Container( width: 280, height: 140, color: pt.accentSoft, child: Stack( alignment: Alignment.center, children: [ Icon(_icon, size: 56, color: pt.accent.withAlpha(180)), Positioned( bottom: 12, right: 12, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: pt.accent, borderRadius: BorderRadius.circular(4), ), child: Text( extLabel.length > 4 ? extLabel.substring(0, 4) : extLabel, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700), ), ), ), ], ), ), // Footer Container( color: pt.bg2, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), child: Row( children: [ Icon(_icon, size: 14, color: pt.fgDim), const SizedBox(width: 6), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(_filename, style: TextStyle(color: pt.fg, fontSize: 12, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis), if (size != null) Text(_fmtSize(size), style: TextStyle(color: pt.fgMuted, fontSize: 10)), ], ), ), if (_loading) SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)) else GestureDetector( onTap: _save, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.all(2), child: Icon(Icons.download_rounded, size: 16, color: pt.fgMuted), ), ), ], ), ), ], ), ), ); } } class _FileContent extends ConsumerStatefulWidget { final Event event; final PyramidTheme pt; final IconData icon; const _FileContent({required this.event, required this.pt, required this.icon}); @override ConsumerState<_FileContent> createState() => _FileContentState(); } class _FileContentState extends ConsumerState<_FileContent> { bool _saving = false; bool _previewing = false; String get _filename => widget.event.body.isNotEmpty ? widget.event.body : 'file'; String _ext() { final n = _filename; return n.contains('.') ? n.split('.').last.toLowerCase() : 'bin'; } Future _preview() async { if (_previewing || _saving) return; setState(() => _previewing = true); try { final client = await ref.read(matrixClientProvider.future); final event = widget.event; Uint8List bytes; if (event.isAttachmentEncrypted) { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); bytes = file.bytes; } else { final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) throw Exception('Kein URI'); final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}'); bytes = res.bodyBytes; } if (!mounted) return; await DocumentViewer.show( context, filename: _filename, bytes: bytes, senderName: event.senderFromMemoryOrFallback.calcDisplayname(), ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)), ); } } finally { if (mounted) setState(() => _previewing = false); } } Future _save({bool openAfter = false}) async { if (_saving) return; setState(() => _saving = true); try { final client = await ref.read(matrixClientProvider.future); final event = widget.event; Uint8List bytes; if (event.isAttachmentEncrypted) { final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client)); bytes = file.bytes; } else { final mxcUri = event.attachmentMxcUrl; if (mxcUri == null) throw Exception('Kein URI'); final httpUri = await mxcUri.getDownloadUri(client); final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'}); if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}'); bytes = res.bodyBytes; } Directory? dir; if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { dir = await getDownloadsDirectory(); } dir ??= await getTemporaryDirectory(); final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.${_ext()}'; final outFile = File('${dir.path}/$name'); await outFile.writeAsBytes(bytes); if (openAfter) { if (Platform.isWindows) { await Process.run('cmd', ['/c', 'start', '', outFile.path]); } else if (Platform.isMacOS) { await Process.run('open', [outFile.path]); } else { await Process.run('xdg-open', [outFile.path]); } } if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(openAfter ? 'Geöffnet: ${outFile.path}' : 'Gespeichert: ${outFile.path}'), duration: const Duration(seconds: 4), )); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)), ); } } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { final pt = widget.pt; final isVideo = widget.event.messageType == 'm.video'; return Container( margin: const EdgeInsets.only(top: 4, bottom: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), constraints: const BoxConstraints(maxWidth: 340), decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 40, height: 40, decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)), child: Icon(widget.icon, size: 22, color: pt.accent), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(_filename, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis), const SizedBox(height: 2), Text(_ext().toUpperCase(), style: TextStyle(color: pt.fgDim, fontSize: 11)), ], ), ), const SizedBox(width: 8), if (_saving || _previewing) SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)) else Row( mainAxisSize: MainAxisSize.min, children: [ if (DocumentViewer.supportsPreview(_filename)) IconButton( onPressed: _preview, tooltip: 'Vorschau', icon: Icon(Icons.visibility_outlined, size: 22, color: pt.fgMuted), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), padding: EdgeInsets.zero, ), if (isVideo) IconButton( onPressed: () => _save(openAfter: true), tooltip: 'Öffnen', icon: Icon(Icons.play_circle_outline_rounded, size: 22, color: pt.fgMuted), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), padding: EdgeInsets.zero, ), IconButton( onPressed: () => _save(), tooltip: 'Herunterladen', icon: Icon(Icons.download_rounded, size: 22, color: pt.fgMuted), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), padding: EdgeInsets.zero, ), ], ), ], ), ); } } String _classifyMediaError(Object e) { final s = e.toString(); if (s.contains('HandshakeException') || s.contains('CERTIFICATE') || s.contains('SocketException')) { return 'Verbindungsfehler (TLS/Netzwerk)'; } if (s.contains('HTTP 4') || s.contains('HTTP 5') || s.contains('Unknown error when fetching')) { return 'Datei nicht verfügbar (Serverfehler)'; } if (s.contains('decrypt') || s.contains('Decrypt') || s.contains('OlmException')) { return 'Entschlüsselung fehlgeschlagen'; } return 'Download fehlgeschlagen'; } // Thin wrapper over the shared MediaCache so document previews share the same // LRU byte budget as chat images (keeps the last-viewed media instantly // available, evicts the oldest). class _DocBytesCache { static Uint8List? get(String eventId) => MediaCache.instance.get(eventId); static void put(String eventId, Uint8List bytes) => MediaCache.instance.put(eventId, bytes); } // Download callback that validates HTTP status before returning bytes. // Prevents false "decryption failed" errors when the server returns an // error page instead of the actual encrypted file bytes. Future Function(Uri) _checkedDownload(Client client) { return (Uri url) async { final res = await client.httpClient.get( url, headers: {'authorization': 'Bearer ${client.accessToken}'}, ); if (res.statusCode != 200) { throw Exception('HTTP ${res.statusCode}: ${res.body}'); } return res.bodyBytes; }; } Widget _mediaFallback(PyramidTheme pt, String? error, String body) => Padding( padding: const EdgeInsets.all(12), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.broken_image_outlined, size: 16, color: pt.fgDim), const SizedBox(width: 6), Flexible(child: Text(error ?? body, style: TextStyle(color: pt.fgDim, fontSize: 12), overflow: TextOverflow.ellipsis)), ], ), ); class _MediaResult { final String? networkUrl; final Uint8List? bytes; final String? error; _MediaResult._({this.networkUrl, this.bytes, this.error}); factory _MediaResult.network(String url) => _MediaResult._(networkUrl: url); factory _MediaResult.bytes(Uint8List b) => _MediaResult._(bytes: b); factory _MediaResult.error(String e) => _MediaResult._(error: e); factory _MediaResult.none() => _MediaResult._(); } // ── Reactions row ───────────────────────────────────────────────────────── class _ReactionsRow extends StatelessWidget { final Event event; final Timeline timeline; final Room? room; final String currentUserId; final PyramidTheme pt; const _ReactionsRow({ required this.event, required this.timeline, required this.currentUserId, required this.pt, this.room, }); @override Widget build(BuildContext context) { final reactionEvents = event.aggregatedEvents(timeline, RelationshipTypes.reaction); if (reactionEvents.isEmpty) return const SizedBox.shrink(); // Group by emoji key → list of senders final Map> grouped = {}; for (final r in reactionEvents) { final key = r.content.tryGetMap('m.relates_to')?['key'] as String?; if (key == null || key.isEmpty) continue; (grouped[key] ??= []).add(r.senderId); } if (grouped.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(top: 4, bottom: 2), child: Wrap( spacing: 4, runSpacing: 4, children: grouped.entries.map((e) { final myReaction = e.value.contains(currentUserId); return _ReactionChip( emoji: e.key, count: e.value.length, mine: myReaction, pt: pt, onTap: () { if (myReaction) { // Find own reaction event to redact for (final r in reactionEvents) { final key = r.content.tryGetMap('m.relates_to')?['key'] as String?; if (key == e.key && r.senderId == currentUserId) { r.redactEvent().catchError((_) => null); break; } } } else { room?.sendReaction(event.eventId, e.key).catchError((_) => ''); ReactionService.recordUsed(e.key); } }, ); }).toList(), ), ); } } class _ReactionChip extends StatefulWidget { final String emoji; final int count; final bool mine; final PyramidTheme pt; final VoidCallback onTap; const _ReactionChip({ required this.emoji, required this.count, required this.mine, required this.pt, required this.onTap, }); @override State<_ReactionChip> createState() => _ReactionChipState(); } class _ReactionChipState extends State<_ReactionChip> { bool _hovered = false; @override Widget build(BuildContext context) { final pt = widget.pt; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 120), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), decoration: BoxDecoration( color: widget.mine ? pt.accentSoft : (_hovered ? pt.bgHover : pt.bg2), borderRadius: BorderRadius.circular(pt.rBase), border: Border.all( color: widget.mine ? pt.accent : (_hovered ? pt.borderStrong : pt.border), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(widget.emoji, style: const TextStyle(fontSize: 13)), const SizedBox(width: 4), Text( '${widget.count}', style: TextStyle( color: widget.mine ? pt.accent : pt.fgMuted, fontSize: 12, fontWeight: FontWeight.w500, ), ), ], ), ), ), ); } } class _Avatar extends ConsumerStatefulWidget { final String initial; final Color color; final PyramidTheme pt; final double size; final Uri? avatarUri; const _Avatar({ required this.initial, required this.color, required this.pt, required this.size, this.avatarUri, }); @override ConsumerState<_Avatar> createState() => _AvatarState(); } class _AvatarState extends ConsumerState<_Avatar> { bool _hovered = false; @override Widget build(BuildContext context) { final client = ref.watch(matrixClientProvider).valueOrNull; final radius = BorderRadius.circular(widget.size / 2); Widget child; if (client != null && widget.avatarUri != null) { child = MxcAvatar( mxcUri: widget.avatarUri, client: client, size: widget.size, borderRadius: radius, placeholder: (_) => _InitialBox( initial: widget.initial, color: widget.color, size: widget.size, radius: radius, ), ); } else { child = _InitialBox( initial: widget.initial, color: widget.color, size: widget.size, radius: radius, ); } return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeOutBack, transform: _hovered ? Matrix4.diagonal3Values(1.06, 1.06, 1.0) : Matrix4.identity(), transformAlignment: Alignment.center, width: widget.size, height: widget.size, child: child, ), ); } } class _InitialBox extends StatelessWidget { final String initial; final Color color; final double size; final BorderRadius radius; const _InitialBox({required this.initial, required this.color, required this.size, required this.radius}); @override Widget build(BuildContext context) { return Container( width: size, height: size, decoration: BoxDecoration(color: color, borderRadius: radius), child: Center( child: Text(initial, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)), ), ); } } class _HoverActions extends StatefulWidget { final PyramidTheme pt; final Event event; final List events; final Room? room; final String currentUserId; final VoidCallback? onReply; final ValueChanged? onOverlayStateChanged; const _HoverActions({ required this.pt, required this.event, required this.events, required this.currentUserId, this.room, this.onReply, this.onOverlayStateChanged, }); @override State<_HoverActions> createState() => _HoverActionsState(); } class _HoverActionsState extends State<_HoverActions> { OverlayEntry? _reactionOverlay; List _quickReactions = ReactionService.quickReactions; @override void initState() { super.initState(); ReactionService.load().then((_) { if (mounted) setState(() => _quickReactions = ReactionService.quickReactions); }); } void _closeReactionOverlay() { _reactionOverlay?.remove(); _reactionOverlay = null; widget.onOverlayStateChanged?.call(false); } void _toggleReactionPicker() { if (_reactionOverlay != null) { _closeReactionOverlay(); return; } final box = context.findRenderObject() as RenderBox?; final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero; _reactionOverlay = OverlayEntry(builder: (ctx) { final screenSize = MediaQuery.sizeOf(ctx); const pickerW = 340.0; const pickerH = 380.0; double left = (offset.dx - pickerW / 2).clamp(8.0, screenSize.width - pickerW - 8); double top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8); return Material( type: MaterialType.transparency, child: Stack(children: [ GestureDetector( onTap: _closeReactionOverlay, behavior: HitTestBehavior.translucent, child: const SizedBox.expand(), ), Positioned( left: left, top: top, child: EmojiPicker( onEmojiSelected: (emoji) { _closeReactionOverlay(); widget.room?.sendReaction(widget.event.eventId, emoji) .catchError((_) => ''); ReactionService.recordUsed(emoji).then((_) { if (mounted) setState(() => _quickReactions = ReactionService.quickReactions); }); }, onClose: _closeReactionOverlay, ), ), ]), ); }); widget.onOverlayStateChanged?.call(true); Overlay.of(context).insert(_reactionOverlay!); } void _showMoreMenu() { final box = context.findRenderObject() as RenderBox?; final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero; final size = box?.size ?? Size.zero; final isOwnMessage = widget.event.senderId == widget.currentUserId; final text = widget.event.body; // Find any image or sticker event in the group that can be saved as a sticker. final imageEvent = widget.events.firstWhereOrNull( (e) => e.messageType == 'm.image' || e.type == EventTypes.Sticker, ); // Capture messenger synchronously before showMenu's async gap so the // snackbar can be shown even if the widget is unmounted by then. final messenger = ScaffoldMessenger.maybeOf(context); showMenu( context: context, position: RelativeRect.fromLTRB( offset.dx, offset.dy - size.height, offset.dx + size.width, offset.dy, ), items: [ PopupMenuItem(value: 'copy', child: Row(children: [ Icon(Icons.copy_rounded, size: 16, color: widget.pt.fgMuted), const SizedBox(width: 10), Text('Kopieren', style: TextStyle(color: widget.pt.fg, fontSize: 14)), ])), if (imageEvent != null) PopupMenuItem(value: 'save_sticker', child: Row(children: [ Icon(Icons.sticky_note_2_outlined, size: 16, color: widget.pt.fgMuted), const SizedBox(width: 10), Text('Als Sticker speichern', style: TextStyle(color: widget.pt.fg, fontSize: 14)), ])), if (isOwnMessage) PopupMenuItem(value: 'delete', child: Row(children: [ Icon(Icons.delete_outline_rounded, size: 16, color: widget.pt.danger), const SizedBox(width: 10), Text('Löschen', style: TextStyle(color: widget.pt.danger, fontSize: 14)), ])), PopupMenuItem(value: 'pin', child: Row(children: [ Icon(Icons.push_pin_outlined, size: 16, color: widget.pt.fgMuted), const SizedBox(width: 10), Text('Anpinnen', style: TextStyle(color: widget.pt.fg, fontSize: 14)), ])), ], ).then((value) async { if (value == 'copy') { Clipboard.setData(ClipboardData(text: text)); } else if (value == 'save_sticker' && imageEvent != null) { final ok = await GifFavoriteService.addImageAsStickerFavorite(imageEvent); messenger?.showSnackBar(SnackBar( content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'), duration: const Duration(seconds: 2), )); } else if (value == 'delete') { widget.event.redactEvent(reason: 'Vom Nutzer gelöscht') .catchError((_) => null); } else if (value == 'pin') { final room = widget.room; if (room == null) return; final pinned = List.from( room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [], ); final eventId = widget.event.eventId; if (!pinned.contains(eventId)) pinned.add(eventId); room.setPinnedEvents(pinned).catchError((_) => ''); } }); } @override void dispose() { if (_reactionOverlay != null) { _reactionOverlay!.remove(); _reactionOverlay = null; widget.onOverlayStateChanged?.call(false); } super.dispose(); } @override Widget build(BuildContext context) { final pt = widget.pt; return Container( padding: const EdgeInsets.all(2), decoration: BoxDecoration( color: pt.bg0, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), boxShadow: [ BoxShadow( color: Colors.black.withAlpha(100), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: Row( children: [ // Quick reactions ..._quickReactions.map((emoji) => _QuickReactionBtn( emoji: emoji, pt: pt, onTap: () { widget.room?.sendReaction(widget.event.eventId, emoji) .catchError((_) => ''); ReactionService.recordUsed(emoji).then((_) { if (mounted) setState(() => _quickReactions = ReactionService.quickReactions); }); }, )), Container(width: 1, height: 18, color: pt.border, margin: const EdgeInsets.symmetric(horizontal: 2)), _ActionBtn( icon: Icons.add_reaction_outlined, tooltip: 'Reaktion', pt: pt, accent: true, onTap: _toggleReactionPicker, ), _ActionBtn( icon: Icons.reply_rounded, tooltip: 'Antworten', pt: pt, onTap: widget.onReply, ), _ActionBtn( icon: Icons.more_horiz_rounded, tooltip: 'Mehr', pt: pt, onTap: _showMoreMenu, ), ], ), ); } } class _QuickReactionBtn extends StatefulWidget { final String emoji; final PyramidTheme pt; final VoidCallback? onTap; const _QuickReactionBtn({required this.emoji, required this.pt, this.onTap}); @override State<_QuickReactionBtn> createState() => _QuickReactionBtnState(); } class _QuickReactionBtnState extends State<_QuickReactionBtn> { bool _hovered = false; @override Widget build(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 100), width: 28, height: 28, decoration: BoxDecoration( color: _hovered ? widget.pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(widget.pt.rSm), ), child: Center( child: Text(widget.emoji, style: const TextStyle(fontSize: 15)), ), ), ), ); } } class _ActionBtn extends StatefulWidget { final IconData icon; final String tooltip; final PyramidTheme pt; final bool accent; final VoidCallback? onTap; const _ActionBtn({ required this.icon, required this.tooltip, required this.pt, this.accent = false, this.onTap, }); @override State<_ActionBtn> createState() => _ActionBtnState(); } class _ActionBtnState extends State<_ActionBtn> { bool _hovered = false; @override Widget build(BuildContext context) { final color = _hovered ? (widget.accent ? widget.pt.accent : widget.pt.fg) : widget.pt.fgMuted; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: Tooltip( message: widget.tooltip, waitDuration: const Duration(milliseconds: 400), child: AnimatedContainer( duration: const Duration(milliseconds: 120), width: 32, height: 32, decoration: BoxDecoration( color: _hovered ? widget.pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(widget.pt.rSm), ), child: Center( child: Icon(widget.icon, size: 16, color: color), ), ), ), ), ); } } // Inline "Neue Nachrichten" divider rendered inside a MessageGroup when the // fullyRead event is in the middle of the group (same sender, <5 min window). class _InlineUnreadDivider extends StatelessWidget { final PyramidTheme pt; const _InlineUnreadDivider({required this.pt}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(0, 8, 0, 4), child: Row( children: [ Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Text( 'Neue Nachrichten', style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600), ), ), Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)), ], ), ); } } // Date divider between message groups class DateDivider extends StatelessWidget { final DateTime date; const DateDivider({super.key, required this.date}); @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); return Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), child: Row( children: [ Expanded(child: Divider(color: pt.border, height: 1)), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( _formatDate(date), style: TextStyle( color: pt.fgDim, fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.06 * 11, ), ), ), Expanded(child: Divider(color: pt.border, height: 1)), ], ), ); } String _formatDate(DateTime d) { final now = DateTime.now(); if (d.year == now.year && d.month == now.month && d.day == now.day) { return 'Heute · ${d.day}. ${_monthName(d.month)}'; } final yesterday = now.subtract(const Duration(days: 1)); if (d.year == yesterday.year && d.month == yesterday.month && d.day == yesterday.day) { return 'Gestern'; } return '${d.day}. ${_monthName(d.month)} ${d.year}'; } String _monthName(int m) => const [ '', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember' ][m]; } class _MessageStatus extends StatelessWidget { final Event lastEvent; final PyramidTheme pt; const _MessageStatus({required this.lastEvent, required this.pt}); @override Widget build(BuildContext context) { final status = lastEvent.status; if (status == EventStatus.error) { return Icon(Icons.error_outline_rounded, size: 12, color: pt.danger); } if (status == EventStatus.sending) { return Icon(Icons.access_time_rounded, size: 12, color: pt.fgDim); } // Sent (delivered). "Read" is shown by the per-event ✓✓ marker. return Icon(Icons.done_rounded, size: 13, color: pt.fgDim); } } // Read receipt: a ✓✓ shown at the partner's last-read message (any sender), // so it moves next to their own message when they reply. class _ReadMarker extends StatelessWidget { final PyramidTheme pt; const _ReadMarker({required this.pt}); // Goldener Doppelhaken — bewusst nicht die Akzentfarbe, damit "gelesen" // unabhängig vom Theme sofort erkennbar ist. static const _gold = Color(0xFFE6B422); @override Widget build(BuildContext context) { return const Padding( padding: EdgeInsets.only(right: 24, top: 1, bottom: 1), child: Align( alignment: Alignment.centerRight, child: Icon(Icons.done_all_rounded, size: 13, color: _gold), ), ); } } // ─── Matrix HTML renderer ────────────────────────────────────────────────────── // // 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(' ', ' '); } // ─── Plain text with clickable URLs ─────────────────────────────────────────── 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)), ); } }