Files
pyramid/lib/features/chat/view/chat_message_list.dart
Bernd Steckmeister 31e4001fb0 refactor: Gott-Datei chat_view.dart in 6 Dateien aufgeteilt
Fünfter und letzter Durchgang der part/part-of-Aufteilung - reine
Verschiebung, keine Logikänderung. chat_view.dart schrumpft von 2116 auf
33 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem
Blockvergleich gegen das Original und flutter analyze.

Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt;
ROADMAP-Punkt "Toten Code & Duplikate entfernen, Ordnerstruktur
vereinheitlichen" abgehakt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 11:03:31 +02:00

491 lines
17 KiB
Dart

part of '../chat_view.dart';
class _MessageList extends StatelessWidget {
final Timeline timeline;
final ScrollController scrollCtrl;
final String currentUserId;
final PyramidTheme pt;
final ValueChanged<Event> onReply;
final Set<String> selectedIds;
final ValueChanged<String> 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 = <String>{};
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 = <String, LatestReceiptStateData>{};
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} : <String>{};
// 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 = <String>[];
final itemGroups = <List<Event>?>[];
final itemDates = <DateTime?>[];
final itemUnread = <bool>[];
final itemFullyReadIds = <String?>[];
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 = <String, int>{
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<String>) {
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<List<Event>> _buildGroups(List<Event> events) {
final groups = <List<Event>>[];
List<Event>? 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<String> 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<Event> _resolveEvents() {
if (timeline == null) return [];
return selectedIds
.map((id) {
try { return timeline!.events.firstWhere((e) => e.eventId == id); } catch (_) { return null; }
})
.whereType<Event>()
.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<String>.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)),
],
),
),
);
}
}