part of '../chat_view.dart'; class _MessageList extends StatelessWidget { final Timeline timeline; final ScrollController scrollCtrl; final String currentUserId; final PyramidTheme pt; final ValueChanged onReply; final Set selectedIds; final ValueChanged onToggleSelect; final String? sessionFullyReadId; final bool sessionHasUnread; final bool unreadFading; /// Attached to the group-level _UnreadDivider so ensureVisible can scroll to it. final GlobalKey? unreadDividerKey; /// Jump-to-message target (from search): scroll to + highlight this event. final String? jumpEventId; final GlobalKey? jumpKey; final String? highlightEventId; final bool highlightOn; const _MessageList({ required this.timeline, required this.scrollCtrl, required this.currentUserId, required this.pt, required this.onReply, required this.selectedIds, required this.onToggleSelect, this.sessionFullyReadId, this.sessionHasUnread = false, this.unreadFading = false, this.unreadDividerKey, this.jumpEventId, this.jumpKey, this.highlightEventId, this.highlightOn = false, }); @override Widget build(BuildContext context) { // Dedupe by eventId — during concurrent forward/backward pagination the // timeline can transiently contain the same event twice, which would // produce duplicate ListView keys and crash the sliver (indexOf assertion). final seenIds = {}; final events = timeline.events .where(_isVisible) .where((e) => seenIds.add(e.eventId)) .toList(); if (events.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.forum_outlined, size: 48, color: pt.fgDim), const SizedBox(height: 16), Text( 'No messages yet', style: TextStyle(color: pt.fgMuted, fontSize: 14), ), ], ), ); } final groups = _buildGroups(events); final hasUnread = sessionHasUnread; // Resolve the effective fullyReadId: if the captured ID points to a // non-visible event (reaction, edit, state) walk the timeline forward // (toward older events) until we find the nearest visible one. String? fullyReadId = sessionFullyReadId; if (fullyReadId != null) { final inGroups = groups.any((g) => g.any((e) => e.eventId == fullyReadId)); if (!inGroups) { final tlEvents = timeline.events; // newest-first final idx = tlEvents.indexWhere((e) => e.eventId == fullyReadId); if (idx >= 0) { fullyReadId = null; for (int i = idx; i < tlEvents.length; i++) { if (_isVisible(tlEvents[i])) { fullyReadId = tlEvents[i].eventId; break; } } } else { fullyReadId = null; // event not even in loaded timeline } } } // Read-receipt display — matching Element behaviour: // • The newest unread own group shows ✓ (sent but not read) // • The newest own group AT or BEFORE the partner's receipt shows ✓✓ // • If those are the same group → just ✓✓ final room = timeline.room; // Read receipts (Element-style): // • ✓✓ at the single newest event another member has read (any sender) — // moves next to the partner's own reply when they reply/read everything. // • grey ✓ only on my newest message while it's still unread by them. // timeline.events is newest-first → lower index = newer. // // Receipts können je nach Client in `global` ODER `mainThread` stehen // (threaded read receipts) — beide zusammenführen, neuere ts gewinnt. final mergedReceipts = {}; for (final source in [ room.receiptState.global.otherUsers, room.receiptState.mainThread?.otherUsers ?? const {}, ]) { source.forEach((userId, data) { final existing = mergedReceipts[userId]; if (existing == null || data.ts > existing.ts) { mergedReceipts[userId] = data; } }); } int readIndex = -1; int latestReceiptTs = 0; mergedReceipts.forEach((userId, data) { if (userId == currentUserId) return; if (data.ts > latestReceiptTs) latestReceiptTs = data.ts; var ri = timeline.events.indexWhere((e) => e.eventId == data.eventId); // Receipt kann auf ein unsichtbares Event zeigen (Reaktion, Edit) — // zum nächstälteren sichtbaren Event vorrücken, sonst geht der Marker // verloren. while (ri >= 0 && ri < timeline.events.length && !_isVisible(timeline.events[ri])) { ri++; } if (ri >= timeline.events.length) ri = -1; if (ri >= 0 && (readIndex < 0 || ri < readIndex)) readIndex = ri; }); final readEventIds = readIndex >= 0 ? {timeline.events[readIndex].eventId} : {}; // Newest own visible message, and whether it's still unread (newer than the // partner's read position, or no read position yet). final newestOwnIndex = timeline.events.indexWhere((e) => _isVisible(e) && e.senderId == currentUserId); var ownUnread = newestOwnIndex >= 0 && (readIndex < 0 || newestOwnIndex < readIndex); // Fallback: Das Receipt-Event liegt außerhalb des geladenen Fensters // (z.B. sehr alte Nachricht) — wenn der Receipt-Zeitstempel jünger ist als // meine neueste Nachricht, gilt sie trotzdem als gelesen. if (ownUnread && readIndex < 0 && latestReceiptTs >= timeline.events[newestOwnIndex].originServerTs.millisecondsSinceEpoch) { readEventIds.add(timeline.events[newestOwnIndex].eventId); ownUnread = false; } final lastOwnGroupKey = (ownUnread) ? () { for (final g in groups) { if (g.first.senderId == currentUserId) return 'g:${g.first.eventId}'; } return null; }() : null; // Flatten groups + date dividers + unread divider into independent list items. // itemFullyReadIds[i] is non-null only for a group item that contains the // fullyRead event at a non-last position — MessageGroup renders an inline // divider after that event. final itemKeys = []; final itemGroups = ?>[]; final itemDates = []; final itemUnread = []; final itemFullyReadIds = []; bool unreadInserted = false; for (var i = 0; i < groups.length; i++) { final group = groups[i]; // Use the newest event (group.last after reversal) for date comparison. final groupNewest = group.last.originServerTs; final nextGroupNewest = i < groups.length - 1 ? groups[i + 1].last.originServerTs : null; final isLastOfDay = nextGroupNewest == null || !_sameDay(groupNewest, nextGroupNewest); // Check if this group contains the effective fullyRead event. final groupContainsFullyRead = fullyReadId != null && group.any((e) => e.eventId == fullyReadId); if (!unreadInserted && hasUnread && groupContainsFullyRead) { if (group.last.eventId == fullyReadId) { // fullyRead is the NEWEST event in the group → the whole group is // read. Insert a group-level divider between this group and the // newer (lower-index) groups. itemKeys.add('unread'); itemGroups.add(null); itemDates.add(null); itemUnread.add(true); itemFullyReadIds.add(null); } // Whether group-level or inline, we've now handled the marker. unreadInserted = true; } // Key based on oldest event (group.first) — stable identifier. itemKeys.add('g:${group.first.eventId}'); itemGroups.add(group); itemDates.add(null); itemUnread.add(false); // Pass fullyReadId to the group only when the event is mid-group // (not the last event) so MessageGroup can render an inline divider. // groupContainsFullyRead already guarantees fullyReadId != null. final passedFullyReadId = (groupContainsFullyRead && group.last.eventId != fullyReadId) ? fullyReadId : null; itemFullyReadIds.add(passedFullyReadId); if (isLastOfDay) { final d = groupNewest; itemKeys.add('d:${d.year}-${d.month}-${d.day}'); itemGroups.add(null); itemDates.add(d); itemUnread.add(false); itemFullyReadIds.add(null); } } // Fallback: fullyRead event is older than all loaded events — all visible // messages are unread. Insert divider above the oldest loaded group (top of list). if (!unreadInserted && hasUnread) { itemKeys.add('unread'); itemGroups.add(null); itemDates.add(null); itemUnread.add(true); itemFullyReadIds.add(null); } final keyIndex = { for (var i = 0; i < itemKeys.length; i++) itemKeys[i]: i, }; return ListView.builder( controller: scrollCtrl, reverse: true, physics: const ClampingScrollPhysics(), padding: const EdgeInsets.only(bottom: 8), scrollCacheExtent: const ScrollCacheExtent.pixels(2500), itemCount: itemKeys.length, findChildIndexCallback: (Key key) { // Only return an index when the key still maps to that exact slot — // returning a stale/duplicate index trips the sliver indexOf assertion. if (key is ValueKey) { final idx = keyIndex[key.value]; if (idx != null && idx < itemKeys.length && itemKeys[idx] == key.value) { return idx; } } return null; }, itemBuilder: (context, i) { final key = ValueKey(itemKeys[i]); if (itemUnread[i]) { // Wrap in SizedBox so the list can use the ValueKey for optimisation // while _UnreadDivider carries the GlobalKey for ensureVisible. return SizedBox( key: key, child: _UnreadDivider(key: unreadDividerKey, pt: pt, fading: unreadFading), ); } final date = itemDates[i]; if (date != null) return DateDivider(key: key, date: date); final group = itemGroups[i]!; final groupKey = itemKeys[i]; // Show the sending/sent indicator on the newest own group. final showStatus = groupKey == lastOwnGroupKey; return MessageGroup( key: key, events: group, currentUserId: currentUserId, room: room, timeline: timeline, onReply: () => onReply(group.last), selectedIds: selectedIds, onToggleSelect: onToggleSelect, showStatusIndicator: showStatus, readEventIds: readEventIds, fullyReadId: itemFullyReadIds[i], jumpEventId: jumpEventId, jumpKey: jumpKey, highlightEventId: highlightEventId, highlightOn: highlightOn, ); }, ); } List> _buildGroups(List events) { final groups = >[]; List? current; for (final event in events) { if (current == null || current.first.senderId != event.senderId || event.originServerTs.difference(current.last.originServerTs).abs() > const Duration(minutes: 5)) { current = [event]; groups.add(current); } else { current.add(event); } } // The timeline is newest-first, so each group is [newest, ..., oldest]. // Reverse each group so messages render oldest-to-newest (top to bottom). return groups.map((g) => g.reversed.toList()).toList(); } bool _sameDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; static bool _isVisible(Event e) { // NOTE: error-status events are kept visible so a failed send shows a // "not sent" state with retry/delete instead of silently disappearing. if (!{ EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted, }.contains(e.type)) { return false; } if ({ RelationshipTypes.edit, RelationshipTypes.reaction, }.contains(e.relationshipType)) { return false; } if (e.type == EventTypes.Redaction) { return false; } return true; } } class _SelectionHeader extends StatelessWidget { final PyramidTheme pt; final int count; final Set selectedIds; final Room? room; final Timeline? timeline; final String currentUserId; final VoidCallback onClear; final VoidCallback onReply; const _SelectionHeader({ required this.pt, required this.count, required this.selectedIds, required this.room, required this.timeline, required this.currentUserId, required this.onClear, required this.onReply, }); List _resolveEvents() { if (timeline == null) return []; return selectedIds .map((id) { try { return timeline!.events.firstWhere((e) => e.eventId == id); } catch (_) { return null; } }) .whereType() .toList(); } void _copyAll() { final texts = _resolveEvents().map((e) => e.body).join('\n'); Clipboard.setData(ClipboardData(text: texts)); } void _deleteAll(BuildContext context) { for (final e in _resolveEvents()) { if (e.senderId == currentUserId) { e.redactEvent(reason: 'Vom Nutzer gelöscht').catchError((_) => null); } } onClear(); } void _pinAll() { if (room == null) return; final pinned = List.from( room!.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [], ); for (final e in _resolveEvents()) { if (!pinned.contains(e.eventId)) pinned.add(e.eventId); } room!.setPinnedEvents(pinned).catchError((_) => ''); onClear(); } @override Widget build(BuildContext context) { final events = _resolveEvents(); final canDelete = events.isNotEmpty && events.every((e) => e.senderId == currentUserId); final isTextOnly = events.every((e) => e.messageType == 'm.text'); return Container( height: 52, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( color: pt.accentSoft, border: Border(bottom: BorderSide(color: pt.accent.withAlpha(80))), ), child: Row( children: [ IconButton( onPressed: onClear, icon: Icon(Icons.close_rounded, size: 20, color: pt.accent), tooltip: 'Auswahl aufheben', constraints: const BoxConstraints(minWidth: 36, minHeight: 36), padding: EdgeInsets.zero, ), const SizedBox(width: 4), Text( '$count ausgewählt', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), ), const Spacer(), if (count == 1) _SelBtn(icon: Icons.reply_rounded, tooltip: 'Antworten', pt: pt, onTap: onReply), if (isTextOnly) _SelBtn(icon: Icons.copy_rounded, tooltip: 'Kopieren', pt: pt, onTap: _copyAll), _SelBtn(icon: Icons.push_pin_outlined, tooltip: 'Anpinnen', pt: pt, onTap: _pinAll), if (canDelete) _SelBtn( icon: Icons.delete_outline_rounded, tooltip: 'Löschen', pt: pt, color: pt.danger, onTap: () => _deleteAll(context), ), ], ), ); } } class _SelBtn extends StatelessWidget { final IconData icon; final String tooltip; final PyramidTheme pt; final Color? color; final VoidCallback onTap; const _SelBtn({required this.icon, required this.tooltip, required this.pt, required this.onTap, this.color}); @override Widget build(BuildContext context) { return IconButton( onPressed: onTap, icon: Icon(icon, size: 20, color: color ?? pt.fg), tooltip: tooltip, constraints: const BoxConstraints(minWidth: 36, minHeight: 36), padding: EdgeInsets.zero, ); } } class _UnreadDivider extends StatelessWidget { final PyramidTheme pt; final bool fading; const _UnreadDivider({super.key, required this.pt, this.fading = false}); @override Widget build(BuildContext context) { return AnimatedOpacity( opacity: fading ? 0.0 : 1.0, duration: const Duration(milliseconds: 700), curve: Curves.easeOut, child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 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)), ], ), ), ); } }