Files
pyramid/lib/features/chat/view/chat_message_list.dart
T
Bernd SteckmeisterandClaude Opus 5.5 87b472a373 fix(ui): UX-Audit – Sackgassen, tote Knöpfe, englische Reste; Screenshot-Tour
Bernds Wunsch 2026-10-08: UI grundsätzlich prüfen (Navigation, Sackgassen,
Anruf-Logik). Audit + Konzept in docs/UI_AUDIT.md, belegt mit neuer
Screenshot-Tour integration_test/ui_audit_tour_test.dart (35 Zustände,
Handy + PC, zwei Testkonten, Test-Space mit Sprachkanal).

Sofort behoben:
- S1/S2: Gegenseite legt auf / Anruf scheitert / Sprachkanal bricht ab →
  automatisch zurück zum Chat mit Hinweis (Fehler sichtbar statt still);
  leere Anrufansicht deutsch mit Zurück-Knopf (vorher englische Sackgasse
  ohne Zurück am Handy).
- S4: toter „Threads“-Knopf entfernt. S5: Space betreten (PC) öffnet
  letzten bzw. ersten Textkanal. S6: keine doppelte Anrufsteuerung am PC.
- Annehmen-Fenster mit Anrufer-Avatar und Anrufart; eigene Kachel mit
  eigenem Initial statt „D“; Mitgliederliste rund mit Profilbild und „(Du)“;
  Tastaturkürzel nur am PC; englische Texte in Anruf/Leerzuständen deutsch.
- Tests: Fehlerempfänger des Testrahmens nach app.main() zurück (vorher
  verschwanden Fehlermeldungen), Lesebestätigungs-Test im Wegwerf-Raum statt
  in der DM, robustere Schritte; test.ps1 räumt liegengebliebene
  Test-Prozesse weg; gemeinsame Hilfen integration_test/support/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:35:40 +02:00

505 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// Höhe der darüber schwebenden Eingabeleiste: so viel Platz bleibt unten
/// frei, damit die neueste Nachricht ganz über die Leiste scrollen kann.
final double bottomInset;
/// Siehe MessageGroup.popInAfter.
final DateTime? popInAfter;
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,
this.bottomInset = 0,
this.popInAfter,
});
@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(
'Noch keine Nachrichten – schreib die erste!',
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 = (hasUnread &&
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: EdgeInsets.only(bottom: 8 + bottomInset),
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],
unreadFading: unreadFading,
unreadDividerKey:
itemFullyReadIds[i] != null ? unreadDividerKey : null,
jumpEventId: jumpEventId,
jumpKey: jumpKey,
highlightEventId: highlightEventId,
highlightOn: highlightOn,
popInAfter: popInAfter,
);
},
);
}
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)),
],
),
),
);
}
}