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