diff --git a/CHANGES.md b/CHANGES.md index 627d40b..f3b945a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1161,3 +1161,5 @@ 2026-07-03 10:29 [Edit] /home/steggi/pyramid/ROADMAP.md 2026-07-03 10:56 [Edit] /home/steggi/pyramid/ROADMAP.md 2026-07-03 10:56 [Edit] /home/steggi/pyramid/PROGRESS.md +2026-07-03 10:58 [Edit] /home/steggi/pyramid/ROADMAP.md +2026-07-03 10:58 [Edit] /home/steggi/pyramid/PROGRESS.md diff --git a/PROGRESS.md b/PROGRESS.md index 31e0f8a..29b87fa 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,6 +13,48 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format: --- +## 2026-07-03 – M2: Gott-Datei `message_group.dart` aufgeteilt (7 Dateien) + +**Erledigt:** Gleiche Technik wie beim `settings_modal.dart`-Umbau direkt +darunter angewandt: `features/chat/message_group.dart` (2775 Zeilen, 41 +Klassen/Enums + 5 lose Top-Level-Hilfsfunktionen) per Dart `part`/`part of` +in 7 thematische Dateien unter `features/chat/message/` aufgeteilt: +`message_group_core.dart` (`MessageGroup`, `_MessageGroupState`, +`_SendFailedRow`, `_MessageBody`, `_ContinuationMessage`(+State), inkl. der in +der letzten Session vereinheitlichten `_formatMessageTime`), `message_text.dart` +(`_MessageContent`, `_MatrixHtmlText`, `_PlainLinkText`), +`message_link_preview.dart` (`_LinkPreview`(+State/Data), `_ReplyPreview`), +`message_media.dart` (alle Medientypen: Bild/Audio/Video/Doc/Datei/proprietäre +Inline-Karten + die zugehörigen Top-Level-Helfer `_reservedImageSize`/ +`_classifyMediaError`/`_checkedDownload`/`_mediaFallback` – mit 1000 Zeilen +weiterhin der größte Teil, aber thematisch klar abgegrenzt), +`message_reactions.dart`, `message_actions.dart` (Hover-Aktionsleiste), +`message_shared.dart` (`_Avatar`, `DateDivider` – bleibt öffentlich, da +`chat_view.dart` es importiert; über `part`/`part of` weiterhin ohne +Umbenennung nutzbar). `message_group.dart` selbst ist jetzt nur noch der +Bibliothekskopf (Imports + 7 `part`-Direktiven, 30 Zeilen). + +Verifikation identisch zum Settings-Umbau: automatisierte Klammer-Zählung +für die Blockgrenzen (kein manuelles Abtippen), alle 41 Original-Blöcke + +5 Helfer wortwörtlich in den neuen Dateien wiedergefunden, Anzahl +`class`/`enum` (45) und `Widget build(`-Methoden (27) vorher/nachher +identisch, `flutter analyze` unverändert bei 1 bekanntem Hinweis. `dart format` +diesmal bewusst NICHT ausgeführt (siehe Stolperfalle im Eintrag darunter). + +**Offen/Nächster Schritt:** UNGETESTET (Pi). Nächster Windows-Lauf: einen +Chat mit mehreren Nachrichtentypen öffnen (Text mit Link-Vorschau, Bild, +Video, Audio, Datei-Anhang, Reaktionen setzen, Hover-Aktionsleiste, +Datums-Trenner, gescheiterte Nachricht mit Retry) – rein mechanische +Verschiebung, aber `message_media.dart` ist der bisher am wenigsten oft +geprüfte Bereich (Download/Entschlüsselungs-Fehlerpfade), daher dort genauer +hinschauen. Danach ggf. `rooms_panel.dart` (2555 Zeilen) oder +`space_admin_dialog.dart` (2278 Zeilen) nach demselben Muster aufteilen. + +**Stolperfallen:** Keine neuen – siehe `dart format`-Hinweis im +`settings_modal.dart`-Eintrag darunter, hier von vornherein vermieden. + +--- + ## 2026-07-03 – M2: Gott-Datei `settings_modal.dart` aufgeteilt (12 Dateien) **Erledigt:** `widgets/settings_modal.dart` (5541 Zeilen, 50+ Klassen) war laut diff --git a/ROADMAP.md b/ROADMAP.md index 4633142..cd23e71 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,9 +80,11 @@ lassen, ohne dass andere Module angefasst werden müssen. doppelte `_formatTime` in `message_group.dart` zusammengeführt; Gott-Datei `widgets/settings_modal.dart` (5541 Zeilen) in 12 `part`-Dateien unter `widgets/settings/` aufgeteilt, siehe PROGRESS.md - 2026-07-03 „Settings-Datei aufgeteilt" – restliche Gott-Dateien - `message_group.dart`, `rooms_panel.dart`, `space_admin_dialog.dart`, - `chat_view.dart`, `document_viewer.dart` aus der Ist-Analyse noch offen) + 2026-07-03 „Settings-Datei aufgeteilt"; `features/chat/message_group.dart` + (2775 Zeilen) ebenso in 7 `part`-Dateien unter `features/chat/message/` + aufgeteilt (PROGRESS.md 2026-07-03 „message_group.dart aufgeteilt") – + restliche Gott-Dateien `rooms_panel.dart`, `space_admin_dialog.dart`, + `chat_view.dart` aus der Ist-Analyse noch offen) - [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit ## M3 – Calls: Logik, UI & Audio (Discord als Referenz) diff --git a/lib/features/chat/message/message_actions.dart b/lib/features/chat/message/message_actions.dart new file mode 100644 index 0000000..c26b15b --- /dev/null +++ b/lib/features/chat/message/message_actions.dart @@ -0,0 +1,282 @@ +part of '../message_group.dart'; + +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 _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), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/chat/message/message_group_core.dart b/lib/features/chat/message/message_group_core.dart new file mode 100644 index 0000000..d958283 --- /dev/null +++ b/lib/features/chat/message/message_group_core.dart @@ -0,0 +1,449 @@ +part of '../message_group.dart'; + +String _formatMessageTime(DateTime t) { + final h = t.hour.toString().padLeft(2, '0'); + final m = t.minute.toString().padLeft(2, '0'); + return '$h:$m'; +} + +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 = _formatMessageTime(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]; + } +} + +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, + ), + ), + ); +} + +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), + ], + ); + } +} + +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( + _formatMessageTime(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, + ), + ], + ), + ), + ], + ), + ); + } + +} diff --git a/lib/features/chat/message/message_link_preview.dart b/lib/features/chat/message/message_link_preview.dart new file mode 100644 index 0000000..316a436 --- /dev/null +++ b/lib/features/chat/message/message_link_preview.dart @@ -0,0 +1,237 @@ +part of '../message_group.dart'; + +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); + } +} diff --git a/lib/features/chat/message/message_media.dart b/lib/features/chat/message/message_media.dart new file mode 100644 index 0000000..6ef71ad --- /dev/null +++ b/lib/features/chat/message/message_media.dart @@ -0,0 +1,1000 @@ +part of '../message_group.dart'; + +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); +} + +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); +} + +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), + ), + ); + }, + ); + } +} + +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'; +} + +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._(); +} diff --git a/lib/features/chat/message/message_reactions.dart b/lib/features/chat/message/message_reactions.dart new file mode 100644 index 0000000..1ba9b8b --- /dev/null +++ b/lib/features/chat/message/message_reactions.dart @@ -0,0 +1,167 @@ +part of '../message_group.dart'; + +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 _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)), + ), + ), + ), + ); + } +} diff --git a/lib/features/chat/message/message_shared.dart b/lib/features/chat/message/message_shared.dart new file mode 100644 index 0000000..af54f72 --- /dev/null +++ b/lib/features/chat/message/message_shared.dart @@ -0,0 +1,204 @@ +part of '../message_group.dart'; + +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 _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)), + ], + ), + ); + } +} + +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); + } +} + +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), + ), + ); + } +} diff --git a/lib/features/chat/message/message_text.dart b/lib/features/chat/message/message_text.dart new file mode 100644 index 0000000..3c4608b --- /dev/null +++ b/lib/features/chat/message/message_text.dart @@ -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('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; + } +} + +class _MatrixHtmlText extends StatelessWidget { + final String html; + final TextStyle baseStyle; + final Color linkColor; + + const _MatrixHtmlText({ + required this.html, + required this.baseStyle, + required this.linkColor, + }); + + @override + Widget build(BuildContext context) { + final spans = _parseHtml(html, baseStyle, linkColor); + return SelectionArea( + child: RichText(text: TextSpan(children: spans, style: baseStyle)), + ); + } + + List _parseHtml(String html, TextStyle base, Color linkColor) { + // Normalise line endings and collapse whitespace around block elements. + var src = html + .replaceAll('\r\n', '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<(p|div|li)[^>]*>', caseSensitive: false), '') + .replaceAll(RegExp(r']*>', caseSensitive: false), ' ') + .replaceAll(RegExp(r']*>', caseSensitive: false), ''); + + final spans = []; + + // Simple recursive stack-based parser. + // We tokenise into (tag | text) chunks, then apply style based on open tags. + final tagRe = RegExp(r'<(/?)(\w+)([^>]*)>', caseSensitive: false); + + var bold = 0; + var italic = 0; + var strike = 0; + var underline = 0; + var code = 0; + String? pendingHref; + + int pos = 0; + for (final m in tagRe.allMatches(src)) { + // Text before this tag. + if (m.start > pos) { + final text = _decodeEntities(src.substring(pos, m.start)); + if (text.isNotEmpty) { + spans.add(_buildSpan( + text, base, bold > 0, italic > 0, strike > 0, underline > 0, + code > 0, pendingHref, linkColor, + )); + if (pendingHref != null) pendingHref = null; + } + } + pos = m.end; + + final closing = m.group(1) == '/'; + final tag = m.group(2)!.toLowerCase(); + final attrs = m.group(3) ?? ''; + + if (closing) { + switch (tag) { + case 'b': case 'strong': bold = (bold - 1).clamp(0, 99); break; + case 'i': case 'em': italic = (italic - 1).clamp(0, 99); break; + case 'del': case 's': case 'strike': strike = (strike - 1).clamp(0, 99); break; + case 'u': underline = (underline - 1).clamp(0, 99); break; + case 'code': code = (code - 1).clamp(0, 99); break; + case 'a': pendingHref = null; break; + } + } else { + switch (tag) { + case 'b': case 'strong': bold++; break; + case 'i': case 'em': italic++; break; + case 'del': case 's': case 'strike': strike++; break; + case 'u': underline++; break; + case 'code': code++; break; + case 'a': + final hrefMatch = RegExp("""href=["']([^"']+)["']""", caseSensitive: false) + .firstMatch(attrs); + pendingHref = hrefMatch?.group(1); + break; + } + } + } + + // Remaining text after last tag. + if (pos < src.length) { + final text = _decodeEntities(src.substring(pos)); + if (text.isNotEmpty) { + spans.add(_buildSpan( + text, base, bold > 0, italic > 0, strike > 0, underline > 0, + code > 0, pendingHref, linkColor, + )); + } + } + + return spans; + } + + InlineSpan _buildSpan( + String text, + TextStyle base, + bool bold, bool italic, bool strike, bool underline, bool code, + String? href, Color linkColor, + ) { + var style = base.copyWith( + fontWeight: bold ? FontWeight.bold : null, + fontStyle: italic ? FontStyle.italic : null, + decoration: TextDecoration.combine([ + if (strike) TextDecoration.lineThrough, + if (underline) TextDecoration.underline, + ]), + fontFamily: code ? 'monospace' : null, + fontSize: code ? (base.fontSize ?? 14) * 0.9 : null, + ); + + if (href != null) { + style = style.copyWith( + color: linkColor, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ); + return TextSpan( + text: text, + style: style, + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl(Uri.parse(href), mode: LaunchMode.externalApplication), + ); + } + return TextSpan(text: text, style: style); + } + + static String _decodeEntities(String s) => s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'") + .replaceAll(' ', ' '); +} + +class _PlainLinkText extends StatelessWidget { + final String text; + final TextStyle baseStyle; + final Color linkColor; + + const _PlainLinkText({ + required this.text, + required this.baseStyle, + required this.linkColor, + }); + + static final _urlRe = RegExp( + r'https?://[^\s\]\)>]+', + caseSensitive: false, + ); + + @override + Widget build(BuildContext context) { + final matches = _urlRe.allMatches(text).toList(); + if (matches.isEmpty) { + return SelectableText(text, style: baseStyle); + } + + final spans = []; + int pos = 0; + for (final m in matches) { + if (m.start > pos) { + spans.add(TextSpan(text: text.substring(pos, m.start))); + } + final url = m.group(0)!; + spans.add(TextSpan( + text: url, + style: baseStyle.copyWith( + color: linkColor, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), + )); + pos = m.end; + } + if (pos < text.length) { + spans.add(TextSpan(text: text.substring(pos))); + } + + return SelectionArea( + child: RichText(text: TextSpan(children: spans, style: baseStyle)), + ); + } +} diff --git a/lib/features/chat/message_group.dart b/lib/features/chat/message_group.dart index 1586e74..7fd5795 100644 --- a/lib/features/chat/message_group.dart +++ b/lib/features/chat/message_group.dart @@ -21,2755 +21,10 @@ import 'package:pyramid/utils/gif_favorite_service.dart'; import 'package:pyramid/widgets/mxc_image.dart'; import 'package:url_launcher/url_launcher.dart'; -String _formatMessageTime(DateTime t) { - final h = t.hour.toString().padLeft(2, '0'); - final m = t.minute.toString().padLeft(2, '0'); - return '$h:$m'; -} - -// 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 = _formatMessageTime(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]; - } -} - -// 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( - _formatMessageTime(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, - ), - ], - ), - ), - ], - ), - ); - } - -} - -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)), - ); - } -} +part 'message/message_group_core.dart'; +part 'message/message_text.dart'; +part 'message/message_link_preview.dart'; +part 'message/message_media.dart'; +part 'message/message_reactions.dart'; +part 'message/message_actions.dart'; +part 'message/message_shared.dart';