refactor: Gott-Datei message_group.dart in 7 Dateien aufgeteilt
Gleiche Technik wie bei settings_modal.dart: reine Verschiebung per Dart part/part-of, keine Logikänderung. message_group.dart schrumpft von 2775 auf 30 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze (Baseline unverändert). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1161,3 +1161,5 @@
|
|||||||
2026-07-03 10:29 [Edit] /home/steggi/pyramid/ROADMAP.md
|
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/ROADMAP.md
|
||||||
2026-07-03 10:56 [Edit] /home/steggi/pyramid/PROGRESS.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
|
||||||
|
|||||||
+42
@@ -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)
|
## 2026-07-03 – M2: Gott-Datei `settings_modal.dart` aufgeteilt (12 Dateien)
|
||||||
|
|
||||||
**Erledigt:** `widgets/settings_modal.dart` (5541 Zeilen, 50+ Klassen) war laut
|
**Erledigt:** `widgets/settings_modal.dart` (5541 Zeilen, 50+ Klassen) war laut
|
||||||
|
|||||||
+5
-3
@@ -80,9 +80,11 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
doppelte `_formatTime` in `message_group.dart` zusammengeführt;
|
doppelte `_formatTime` in `message_group.dart` zusammengeführt;
|
||||||
Gott-Datei `widgets/settings_modal.dart` (5541 Zeilen) in 12
|
Gott-Datei `widgets/settings_modal.dart` (5541 Zeilen) in 12
|
||||||
`part`-Dateien unter `widgets/settings/` aufgeteilt, siehe PROGRESS.md
|
`part`-Dateien unter `widgets/settings/` aufgeteilt, siehe PROGRESS.md
|
||||||
2026-07-03 „Settings-Datei aufgeteilt" – restliche Gott-Dateien
|
2026-07-03 „Settings-Datei aufgeteilt"; `features/chat/message_group.dart`
|
||||||
`message_group.dart`, `rooms_panel.dart`, `space_admin_dialog.dart`,
|
(2775 Zeilen) ebenso in 7 `part`-Dateien unter `features/chat/message/`
|
||||||
`chat_view.dart`, `document_viewer.dart` aus der Ist-Analyse noch offen)
|
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
|
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
||||||
|
|
||||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
part of '../message_group.dart';
|
||||||
|
|
||||||
|
class _HoverActions extends StatefulWidget {
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final Event event;
|
||||||
|
final List<Event> events;
|
||||||
|
final Room? room;
|
||||||
|
final String currentUserId;
|
||||||
|
final VoidCallback? onReply;
|
||||||
|
final ValueChanged<bool>? 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<String> _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<String>(
|
||||||
|
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<String>.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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Event> events;
|
||||||
|
final String currentUserId;
|
||||||
|
final Room? room;
|
||||||
|
final Timeline? timeline;
|
||||||
|
final VoidCallback? onReply;
|
||||||
|
final Set<String> selectedIds;
|
||||||
|
final ValueChanged<String>? 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<String> 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<MessageGroup> createState() => _MessageGroupState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MessageGroupState extends State<MessageGroup> {
|
||||||
|
// 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 = <Widget>[];
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<String, _LinkPreviewData?> _cache = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_LinkPreview> createState() => _LinkPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LinkPreviewState extends ConsumerState<_LinkPreview> {
|
||||||
|
_LinkPreviewData? _data;
|
||||||
|
bool _done = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<String, dynamic>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<String, List<String>> grouped = {};
|
||||||
|
for (final r in reactionEvents) {
|
||||||
|
final key = r.content.tryGetMap<String, dynamic>('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<String, dynamic>('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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
part of '../message_group.dart';
|
||||||
|
|
||||||
|
class _MessageContent extends StatelessWidget {
|
||||||
|
final Event event;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final Timeline? timeline;
|
||||||
|
|
||||||
|
const _MessageContent({required this.event, required this.pt, this.timeline});
|
||||||
|
|
||||||
|
// Resolves edits: returns the display event (last edit) for this event.
|
||||||
|
Event get _displayEvent =>
|
||||||
|
timeline != null ? event.getDisplayEvent(timeline!) : event;
|
||||||
|
|
||||||
|
String? _getReplyId() {
|
||||||
|
final relatesTo = event.content.tryGetMap<String, dynamic>('m.relates_to');
|
||||||
|
final replyMap = relatesTo?.tryGetMap<String, dynamic>('m.in_reply_to');
|
||||||
|
return replyMap?['event_id'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stripReplyFallback(String body) {
|
||||||
|
final norm = body.replaceAll('\r\n', '\n');
|
||||||
|
if (!norm.startsWith('> ') && !norm.startsWith('>\n')) return norm;
|
||||||
|
final lines = norm.split('\n');
|
||||||
|
var i = 0;
|
||||||
|
while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (i < lines.length && lines[i].trim().isEmpty) i++;
|
||||||
|
if (i >= lines.length) return norm;
|
||||||
|
return lines.skip(i).join('\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
Event? _findReplyEvent(String replyId) {
|
||||||
|
if (timeline == null) return null;
|
||||||
|
try {
|
||||||
|
return timeline!.events.firstWhere((e) => e.eventId == replyId);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final displayEvent = _displayEvent;
|
||||||
|
final isEdited = displayEvent.eventId != event.eventId;
|
||||||
|
|
||||||
|
// Still encrypted (decryption pending or failed)
|
||||||
|
if (displayEvent.type == EventTypes.Encrypted) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 4,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lock_outline_rounded, size: 13, color: pt.fgDim),
|
||||||
|
Text(
|
||||||
|
'Nachricht wird entschlüsselt…',
|
||||||
|
style: TextStyle(color: pt.fgDim, fontSize: 13, fontStyle: FontStyle.italic),
|
||||||
|
),
|
||||||
|
if (timeline != null)
|
||||||
|
Builder(
|
||||||
|
builder: (ctx) => GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
timeline!.requestKeys();
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Schlüssel werden von anderen Geräten angefordert…'),
|
||||||
|
duration: Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'Schlüssel anfordern',
|
||||||
|
style: TextStyle(
|
||||||
|
color: pt.accent,
|
||||||
|
fontSize: 12,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: pt.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayEvent.type == EventTypes.Sticker) {
|
||||||
|
return _MediaContent(event: displayEvent, pt: pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
final msgType = displayEvent.messageType;
|
||||||
|
|
||||||
|
if (msgType == 'm.image') {
|
||||||
|
final ext = displayEvent.body.contains('.')
|
||||||
|
? displayEvent.body.split('.').last.toLowerCase()
|
||||||
|
: '';
|
||||||
|
const kImageExts = {
|
||||||
|
'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp',
|
||||||
|
'tiff', 'tif', 'heic', 'heif', 'avif', 'ico', 'jfif',
|
||||||
|
};
|
||||||
|
// SVG kann Image.memory nicht dekodieren — über den SVG-Renderer anzeigen.
|
||||||
|
if (ext == 'svg' ||
|
||||||
|
displayEvent.content
|
||||||
|
.tryGetMap<String, Object?>('info')?['mimetype'] ==
|
||||||
|
'image/svg+xml') {
|
||||||
|
return _DocPreviewContent(event: displayEvent, pt: pt);
|
||||||
|
}
|
||||||
|
if (ext.isNotEmpty && !kImageExts.contains(ext)) {
|
||||||
|
return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded);
|
||||||
|
}
|
||||||
|
return _ImageContent(event: displayEvent, pt: pt);
|
||||||
|
}
|
||||||
|
if (msgType == 'm.audio') {
|
||||||
|
return _AudioContent(event: displayEvent, pt: pt);
|
||||||
|
}
|
||||||
|
if (msgType == 'm.video') {
|
||||||
|
return _VideoContent(event: displayEvent, pt: pt);
|
||||||
|
}
|
||||||
|
if (msgType == 'm.file') {
|
||||||
|
if (DocumentViewer.isInlinePreviewable(displayEvent.body)) {
|
||||||
|
return _DocPreviewContent(event: displayEvent, pt: pt);
|
||||||
|
}
|
||||||
|
if (DocumentViewer.showsInlineCard(displayEvent.body)) {
|
||||||
|
return _ProprietaryInlineCard(event: displayEvent, pt: pt);
|
||||||
|
}
|
||||||
|
return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded);
|
||||||
|
}
|
||||||
|
|
||||||
|
final replyId = _getReplyId();
|
||||||
|
final replyEvent = replyId != null ? _findReplyEvent(replyId) : null;
|
||||||
|
final plainBody = replyId != null
|
||||||
|
? _stripReplyFallback(displayEvent.body)
|
||||||
|
: displayEvent.body;
|
||||||
|
|
||||||
|
// Use formatted_body (HTML) if the event has one, otherwise plain text.
|
||||||
|
final format = displayEvent.content.tryGet<String>('format');
|
||||||
|
// Strip the Matrix reply fallback <mx-reply>…</mx-reply> block — we render
|
||||||
|
// our own quote card (_ReplyPreview) above, so the inline "In reply to @user"
|
||||||
|
// would be redundant and ugly.
|
||||||
|
final formattedBody = displayEvent.formattedText.replaceAll(
|
||||||
|
RegExp(r'<mx-reply>.*?</mx-reply>', caseSensitive: false, dotAll: true),
|
||||||
|
'',
|
||||||
|
).trim();
|
||||||
|
final hasHtml = format == 'org.matrix.custom.html' && formattedBody.isNotEmpty;
|
||||||
|
|
||||||
|
final firstUrl = _firstUrl(plainBody);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (replyEvent != null) _ReplyPreview(replyEvent: replyEvent, pt: pt),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
|
child: hasHtml
|
||||||
|
? _MatrixHtmlText(
|
||||||
|
html: formattedBody,
|
||||||
|
baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55),
|
||||||
|
linkColor: pt.accent,
|
||||||
|
)
|
||||||
|
: _PlainLinkText(
|
||||||
|
text: plainBody,
|
||||||
|
baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55),
|
||||||
|
linkColor: pt.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isEdited)
|
||||||
|
Text(
|
||||||
|
'(bearbeitet)',
|
||||||
|
style: TextStyle(color: pt.fgDim, fontSize: 11, fontStyle: FontStyle.italic),
|
||||||
|
),
|
||||||
|
if (firstUrl != null) _LinkPreview(url: firstUrl, pt: pt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracts the first http(s) URL in the text, for link-preview fetching.
|
||||||
|
static final RegExp _urlRegex = RegExp(r'https?://[^\s<>()\[\]]+', caseSensitive: false);
|
||||||
|
String? _firstUrl(String text) {
|
||||||
|
final m = _urlRegex.firstMatch(text);
|
||||||
|
if (m == null) return null;
|
||||||
|
var url = m.group(0)!;
|
||||||
|
// Trim common trailing punctuation that isn't part of the URL.
|
||||||
|
url = url.replaceAll(RegExp(r'[.,;:!?]+$'), '');
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MatrixHtmlText extends StatelessWidget {
|
||||||
|
final String html;
|
||||||
|
final TextStyle baseStyle;
|
||||||
|
final Color linkColor;
|
||||||
|
|
||||||
|
const _MatrixHtmlText({
|
||||||
|
required this.html,
|
||||||
|
required this.baseStyle,
|
||||||
|
required this.linkColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final spans = _parseHtml(html, baseStyle, linkColor);
|
||||||
|
return SelectionArea(
|
||||||
|
child: RichText(text: TextSpan(children: spans, style: baseStyle)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<InlineSpan> _parseHtml(String html, TextStyle base, Color linkColor) {
|
||||||
|
// Normalise line endings and collapse whitespace around block elements.
|
||||||
|
var src = html
|
||||||
|
.replaceAll('\r\n', '\n')
|
||||||
|
.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
|
||||||
|
.replaceAll(RegExp(r'</(p|div|blockquote|pre|li)>', caseSensitive: false), '\n')
|
||||||
|
.replaceAll(RegExp(r'<(p|div|li)[^>]*>', caseSensitive: false), '')
|
||||||
|
.replaceAll(RegExp(r'<blockquote[^>]*>', caseSensitive: false), ' ')
|
||||||
|
.replaceAll(RegExp(r'<pre[^>]*>', caseSensitive: false), '');
|
||||||
|
|
||||||
|
final spans = <InlineSpan>[];
|
||||||
|
|
||||||
|
// Simple recursive stack-based parser.
|
||||||
|
// We tokenise into (tag | text) chunks, then apply style based on open tags.
|
||||||
|
final tagRe = RegExp(r'<(/?)(\w+)([^>]*)>', caseSensitive: false);
|
||||||
|
|
||||||
|
var bold = 0;
|
||||||
|
var italic = 0;
|
||||||
|
var strike = 0;
|
||||||
|
var underline = 0;
|
||||||
|
var code = 0;
|
||||||
|
String? pendingHref;
|
||||||
|
|
||||||
|
int pos = 0;
|
||||||
|
for (final m in tagRe.allMatches(src)) {
|
||||||
|
// Text before this tag.
|
||||||
|
if (m.start > pos) {
|
||||||
|
final text = _decodeEntities(src.substring(pos, m.start));
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
spans.add(_buildSpan(
|
||||||
|
text, base, bold > 0, italic > 0, strike > 0, underline > 0,
|
||||||
|
code > 0, pendingHref, linkColor,
|
||||||
|
));
|
||||||
|
if (pendingHref != null) pendingHref = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = m.end;
|
||||||
|
|
||||||
|
final closing = m.group(1) == '/';
|
||||||
|
final tag = m.group(2)!.toLowerCase();
|
||||||
|
final attrs = m.group(3) ?? '';
|
||||||
|
|
||||||
|
if (closing) {
|
||||||
|
switch (tag) {
|
||||||
|
case 'b': case 'strong': bold = (bold - 1).clamp(0, 99); break;
|
||||||
|
case 'i': case 'em': italic = (italic - 1).clamp(0, 99); break;
|
||||||
|
case 'del': case 's': case 'strike': strike = (strike - 1).clamp(0, 99); break;
|
||||||
|
case 'u': underline = (underline - 1).clamp(0, 99); break;
|
||||||
|
case 'code': code = (code - 1).clamp(0, 99); break;
|
||||||
|
case 'a': pendingHref = null; break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (tag) {
|
||||||
|
case 'b': case 'strong': bold++; break;
|
||||||
|
case 'i': case 'em': italic++; break;
|
||||||
|
case 'del': case 's': case 'strike': strike++; break;
|
||||||
|
case 'u': underline++; break;
|
||||||
|
case 'code': code++; break;
|
||||||
|
case 'a':
|
||||||
|
final hrefMatch = RegExp("""href=["']([^"']+)["']""", caseSensitive: false)
|
||||||
|
.firstMatch(attrs);
|
||||||
|
pendingHref = hrefMatch?.group(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining text after last tag.
|
||||||
|
if (pos < src.length) {
|
||||||
|
final text = _decodeEntities(src.substring(pos));
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
spans.add(_buildSpan(
|
||||||
|
text, base, bold > 0, italic > 0, strike > 0, underline > 0,
|
||||||
|
code > 0, pendingHref, linkColor,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
InlineSpan _buildSpan(
|
||||||
|
String text,
|
||||||
|
TextStyle base,
|
||||||
|
bool bold, bool italic, bool strike, bool underline, bool code,
|
||||||
|
String? href, Color linkColor,
|
||||||
|
) {
|
||||||
|
var style = base.copyWith(
|
||||||
|
fontWeight: bold ? FontWeight.bold : null,
|
||||||
|
fontStyle: italic ? FontStyle.italic : null,
|
||||||
|
decoration: TextDecoration.combine([
|
||||||
|
if (strike) TextDecoration.lineThrough,
|
||||||
|
if (underline) TextDecoration.underline,
|
||||||
|
]),
|
||||||
|
fontFamily: code ? 'monospace' : null,
|
||||||
|
fontSize: code ? (base.fontSize ?? 14) * 0.9 : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href != null) {
|
||||||
|
style = style.copyWith(
|
||||||
|
color: linkColor,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: linkColor,
|
||||||
|
);
|
||||||
|
return TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: style,
|
||||||
|
recognizer: TapGestureRecognizer()
|
||||||
|
..onTap = () => launchUrl(Uri.parse(href), mode: LaunchMode.externalApplication),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return TextSpan(text: text, style: style);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _decodeEntities(String s) => s
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(' ', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlainLinkText extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final TextStyle baseStyle;
|
||||||
|
final Color linkColor;
|
||||||
|
|
||||||
|
const _PlainLinkText({
|
||||||
|
required this.text,
|
||||||
|
required this.baseStyle,
|
||||||
|
required this.linkColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
static final _urlRe = RegExp(
|
||||||
|
r'https?://[^\s\]\)>]+',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final matches = _urlRe.allMatches(text).toList();
|
||||||
|
if (matches.isEmpty) {
|
||||||
|
return SelectableText(text, style: baseStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
final spans = <InlineSpan>[];
|
||||||
|
int pos = 0;
|
||||||
|
for (final m in matches) {
|
||||||
|
if (m.start > pos) {
|
||||||
|
spans.add(TextSpan(text: text.substring(pos, m.start)));
|
||||||
|
}
|
||||||
|
final url = m.group(0)!;
|
||||||
|
spans.add(TextSpan(
|
||||||
|
text: url,
|
||||||
|
style: baseStyle.copyWith(
|
||||||
|
color: linkColor,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: linkColor,
|
||||||
|
),
|
||||||
|
recognizer: TapGestureRecognizer()
|
||||||
|
..onTap = () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
|
||||||
|
));
|
||||||
|
pos = m.end;
|
||||||
|
}
|
||||||
|
if (pos < text.length) {
|
||||||
|
spans.add(TextSpan(text: text.substring(pos)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return SelectionArea(
|
||||||
|
child: RichText(text: TextSpan(children: spans, style: baseStyle)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user