31e4001fb0
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>
491 lines
17 KiB
Dart
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)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|