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:
Bernd Steckmeister
2026-07-03 10:58:46 +02:00
parent 5d772389d6
commit 5bf45a9366
11 changed files with 2777 additions and 2755 deletions
+2
View File
@@ -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
+42
View File
@@ -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
+5 -3
View File
@@ -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)
@@ -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),
),
);
}
}
+382
View File
@@ -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('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'")
.replaceAll('&nbsp;', ' ');
}
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