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]>
505 lines
17 KiB
Dart
505 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;
|
||
|
||
/// 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)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|