From 31e4001fb0dc63234804c340452cba905f497371 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Fri, 3 Jul 2026 11:03:31 +0200 Subject: [PATCH] refactor: Gott-Datei chat_view.dart in 6 Dateien aufgeteilt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGES.md | 2 + PROGRESS.md | 58 + ROADMAP.md | 31 +- lib/features/chat/chat_view.dart | 2095 +---------------- lib/features/chat/view/chat_connection.dart | 78 + lib/features/chat/view/chat_header.dart | 732 ++++++ lib/features/chat/view/chat_message_list.dart | 490 ++++ lib/features/chat/view/chat_misc.dart | 130 + lib/features/chat/view/chat_typing.dart | 136 ++ lib/features/chat/view/chat_view_core.dart | 521 ++++ 10 files changed, 2170 insertions(+), 2103 deletions(-) create mode 100644 lib/features/chat/view/chat_connection.dart create mode 100644 lib/features/chat/view/chat_header.dart create mode 100644 lib/features/chat/view/chat_message_list.dart create mode 100644 lib/features/chat/view/chat_misc.dart create mode 100644 lib/features/chat/view/chat_typing.dart create mode 100644 lib/features/chat/view/chat_view_core.dart diff --git a/CHANGES.md b/CHANGES.md index e7c1b99..cef0c81 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1167,3 +1167,5 @@ 2026-07-03 11:00 [Edit] /home/steggi/pyramid/PROGRESS.md 2026-07-03 11:01 [Edit] /home/steggi/pyramid/ROADMAP.md 2026-07-03 11:01 [Edit] /home/steggi/pyramid/PROGRESS.md +2026-07-03 11:03 [Edit] /home/steggi/pyramid/ROADMAP.md +2026-07-03 11:03 [Edit] /home/steggi/pyramid/PROGRESS.md diff --git a/PROGRESS.md b/PROGRESS.md index 6b03544..e0f346e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,6 +13,64 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format: --- +## 2026-07-03 – M2: Gott-Datei `chat_view.dart` aufgeteilt (6 Dateien) – ROADMAP-Punkt abgehakt + +**Erledigt:** Fünfter und letzter Durchgang derselben `part`/`part of`-Technik: +`features/chat/chat_view.dart` (2116 Zeilen, 22 Klassen, keine losen +Top-Level-Helfer) aufgeteilt in 6 Dateien unter `features/chat/view/`: +`chat_view_core.dart` (`ChatView`, `_ChatViewState` – der komplette +Nachrichten-Senden/Scroll/Attachment-Zustandsautomat, mit 521 Zeilen +weiterhin der größte Einzelblock, aber nicht weiter unterteilt, da es EINE +zusammenhängende State-Klasse ist, keine künstliche Aufspaltung mitten in +einer Klasse), `chat_header.dart` (`_ChatHeader`, DM-Header inkl. +`_DmExpandingHeader`+State, `_DmHeaderAvatar`, `_InitialCircle`, +`_PresenceLine`+State), `chat_connection.dart` (`_ConnectionBanner`+State, +Offline/Reconnect-Banner), `chat_message_list.dart` (`_MessageList`, +`_SelectionHeader`, `_SelBtn`, `_UnreadDivider`), `chat_typing.dart` +(`_TypingIndicator`+State, `_TypingDots`+State), `chat_misc.dart` +(`_DragOverlay`+State für Datei-Drag&Drop, `NoChatSelected` – bleibt +öffentlich, `app_shell.dart` importiert `chat_view.dart` und nutzt es). +`chat_view.dart` selbst: nur noch Bibliothekskopf (33 Zeilen). + +Verifikation identisch zu den vier vorigen Umbauten: automatisierte +Klammer-Zählung, alle 22 Blöcke wortwörtlich wiedergefunden, +`class`/`enum`-Anzahl (22) und `Widget build(`-Methoden (15) vorher/nachher +identisch, `flutter analyze` unverändert bei 1 bekanntem Hinweis. + +**Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt** +(`settings_modal.dart`, `message_group.dart`, `rooms_panel.dart`, +`space_admin_dialog.dart`, `chat_view.dart` – siehe die vier Einträge +darunter). ROADMAP-Punkt „Toten Code & Duplikate entfernen, Ordnerstruktur +vereinheitlichen" jetzt abgehakt. + +**Offen/Nächster Schritt:** **Wichtig, bevor der nächste ROADMAP-Punkt +angefasst wird:** all diese Aufteilungen sind bisher NUR per `flutter +analyze` + automatisiertem Textvergleich geprüft, NICHT in echter UI +gesehen (kein GUI-Toolchain auf dem Pi verfügbar – kein Android-SDK, kein +Linux-Desktop-Build, siehe `flutter doctor`). Der nächste PC-/Windows-Lauf +MUSS vor dem nächsten inhaltlichen Schritt einmal durch alle 5 betroffenen +Bereiche klicken (Einstellungen komplett, ein Chat mit allen +Nachrichtentypen, die Raumliste, eine Space-Verwaltung, ein normaler +Chat-Screen mit Header/Tippanzeige/Drag&Drop) – das Risiko einer reinen +`part`/`part-of`-Verschiebung ist strukturell gering (Dart-Compiler hätte +jeden fehlenden Import/jede falsche Sichtbarkeit sofort als Analyzer-Fehler +gemeldet, nicht erst zur Laufzeit), aber „gering" ist nicht „null", und +CLAUDE.md verlangt nach jedem Refactoring-Schritt einen echten App-Start. +Danach nächster ROADMAP-Punkt in M2: „Call-Schicht entwirren" – dafür +weiterhin Bernds Entscheidung zum Call-Pilot aus „M2: Modul-Schnitt +definiert" ausstehend, UND dieser Punkt braucht ohnehin einen PC/Windows-Test +(Verhaltensänderung an State-Management, nicht nur Datei-Verschiebung). + +**Stolperfallen:** Keine neuen. Generelle Lehre aus allen 5 Durchgängen: +Skript-gestützte Klammer-Zählung + automatisierter Verbatim-Abgleich +(„jeder Originalblock muss wortwörtlich in den neuen Dateien auftauchen") +war der entscheidende Sicherheitsnetz-Schritt, um trotz fehlendem GUI-Test +auf dem Pi mit vertretbarem Risiko fortzufahren – sollte bei künftigen +reinen Datei-Umbauten (z. B. `voice_channel.dart` beim Call-Pilot) als +Vorgehen wiederverwendet werden. + +--- + ## 2026-07-03 – M2: Gott-Datei `space_admin_dialog.dart` aufgeteilt (6 Dateien) **Erledigt:** Vierter Durchgang derselben `part`/`part of`-Technik: diff --git a/ROADMAP.md b/ROADMAP.md index 11651f4..d5f592a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -75,20 +75,23 @@ lassen, ohne dass andere Module angefasst werden müssen. - [ ] Call-Schicht entwirren: EINE klare Zuständigkeit pro Klasse (Signalisierung / LiveKit-Verbindung / UI-State sauber getrennt) - [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns) -- [ ] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen - (angefangen, PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, - doppelte `_formatTime` in `message_group.dart` zusammengeführt; - Gott-Datei `widgets/settings_modal.dart` (5541 Zeilen) in 12 - `part`-Dateien unter `widgets/settings/` aufgeteilt, siehe PROGRESS.md - 2026-07-03 „Settings-Datei aufgeteilt"; `features/chat/message_group.dart` - (2775 Zeilen) ebenso in 7 `part`-Dateien unter `features/chat/message/` - aufgeteilt (PROGRESS.md 2026-07-03 „message_group.dart aufgeteilt"); - `features/rooms/rooms_panel.dart` (2555 Zeilen) ebenso in 8 - `part`-Dateien unter `features/rooms/panel/` aufgeteilt (PROGRESS.md - 2026-07-03 „rooms_panel.dart aufgeteilt"); `features/spaces/space_admin_dialog.dart` - (2278 Zeilen) ebenso in 6 `part`-Dateien unter `features/spaces/admin/` - aufgeteilt (PROGRESS.md 2026-07-03 „space_admin_dialog.dart aufgeteilt") - – restliche Gott-Datei `chat_view.dart` aus der Ist-Analyse noch offen) +- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen + (PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte + `_formatTime` in `message_group.dart` zusammengeführt; alle 5 in der + Ist-Analyse gefundenen Gott-Dateien per `part`/`part of` in + thematische Dateien aufgeteilt – `widgets/settings_modal.dart` + (5541→270 Zeilen, 12 Teile unter `widgets/settings/`), + `features/chat/message_group.dart` (2775→30 Zeilen, 7 Teile unter + `features/chat/message/`), `features/rooms/rooms_panel.dart` + (2555→31 Zeilen, 8 Teile unter `features/rooms/panel/`), + `features/spaces/space_admin_dialog.dart` (2278→19 Zeilen, 6 Teile + unter `features/spaces/admin/`), `features/chat/chat_view.dart` + (2116→33 Zeilen, 6 Teile unter `features/chat/view/`) – jeweils + verifiziert per automatisiertem Blockvergleich gegen das Original und + `flutter analyze`, siehe PROGRESS.md-Einträge. UNGETESTET (Pi) im + Sinne von „nicht in echter UI gesehen" – nächster Windows-Lauf sollte + alle 5 betroffenen Bereiche einmal durchklicken, siehe „Fragen an + Bernd"/Nächster-Schritt in den jeweiligen PROGRESS.md-Einträgen) - [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit ## M3 – Calls: Logik, UI & Audio (Discord als Referenz) diff --git a/lib/features/chat/chat_view.dart b/lib/features/chat/chat_view.dart index ba8f281..3ff6f01 100644 --- a/lib/features/chat/chat_view.dart +++ b/lib/features/chat/chat_view.dart @@ -24,2093 +24,10 @@ import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/core/notification_service.dart'; import 'package:pyramid/core/voip_manager.dart'; -class ChatView extends ConsumerStatefulWidget { - final String roomId; - const ChatView({super.key, required this.roomId}); - @override - ConsumerState createState() => _ChatViewState(); -} - -class _ChatViewState extends ConsumerState { - final _scrollCtrl = ScrollController(); - Event? _replyTo; - bool _loadingHistory = false; - bool _historyExhausted = false; - bool _draggingOver = false; - bool _pinnedOpen = false; - bool _profileExpanded = false; - bool _showScrollDown = false; - final Set _selectedIds = {}; - - // Unread state captured at session open (before marking as read) - String? _sessionFullyReadId; - bool _sessionHasUnread = false; - bool _sessionCaptured = false; - bool _didScrollToUnread = false; - bool _loadingFuture = false; - // Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom. - bool _unreadFading = false; - Timer? _unreadFadeTimer; - - // Jump-to-message (from search): scroll to + briefly highlight a target event. - final _jumpKey = GlobalKey(); - String? _jumpEventId; - String? _highlightEventId; - bool _highlightOn = false; - bool _jumpInProgress = false; - Timer? _highlightTimer; - - void _startJump(String eventId) { - _highlightTimer?.cancel(); - setState(() { - _jumpEventId = eventId; - _highlightEventId = eventId; - _highlightOn = true; - _jumpInProgress = true; - }); - // Consume the request so it doesn't re-fire. - ref.read(pendingJumpEventProvider.notifier).state = null; - WidgetsBinding.instance.addPostFrameCallback((_) => _attemptJump(0)); - } - - void _attemptJump(int attempt) { - if (!mounted || _jumpEventId == null) return; - final ctx = _jumpKey.currentContext; - if (ctx != null) { - Scrollable.ensureVisible( - ctx, - alignment: 0.4, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - _jumpInProgress = false; - // Hold the highlight briefly, then fade it out smoothly. - _highlightTimer?.cancel(); - _highlightTimer = Timer(const Duration(milliseconds: 1300), () { - if (!mounted) return; - setState(() => _highlightOn = false); // triggers the AnimatedOpacity fade - _highlightTimer = Timer(const Duration(milliseconds: 950), () { - if (mounted) setState(() { _highlightEventId = null; _jumpEventId = null; }); - }); - }); - return; - } - if (attempt >= 40) { - // Couldn't reach the event — give up gracefully (room is at least open). - setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; }); - return; - } - // Not built yet — load older history toward the target every few tries. - final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; - if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) { - timeline.requestHistory(historyCount: 100).catchError((_) {}); - } - WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1)); - }); - } - - // GlobalKey for the "Neue Nachrichten" divider — used by ensureVisible to - // scroll precisely to the divider on chat open. - final _unreadDividerKey = GlobalKey(); - - bool get _inSelectionMode => _selectedIds.isNotEmpty; - - void _toggleSelect(String eventId) { - setState(() { - if (_selectedIds.contains(eventId)) { - _selectedIds.remove(eventId); - } else { - _selectedIds.add(eventId); - } - }); - } - - void _clearSelection() => setState(() => _selectedIds.clear()); - - @override - void initState() { - super.initState(); - _scrollCtrl.addListener(_onScroll); - } - - @override - void didUpdateWidget(ChatView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.roomId != widget.roomId) { - _profileExpanded = false; - _sessionFullyReadId = null; - _sessionHasUnread = false; - _sessionCaptured = false; - _didScrollToUnread = false; - _loadingFuture = false; - _historyExhausted = false; - _unreadFading = false; - _unreadFadeTimer?.cancel(); - _jumpEventId = null; - _highlightEventId = null; - _highlightOn = false; - _jumpInProgress = false; - _highlightTimer?.cancel(); - // The GlobalKey stays alive — it'll simply have no context until the - // new room's divider (if any) is built. - } - } - - @override - void dispose() { - _unreadFadeTimer?.cancel(); - _highlightTimer?.cancel(); - _scrollCtrl.dispose(); - super.dispose(); - } - - void _captureAndMarkRead(Timeline timeline) { - if (!_sessionCaptured) { - final room = timeline.room; - final fullyRead = room.fullyRead; // "" if never set - final newFullyReadId = fullyRead.isNotEmpty ? fullyRead : null; - - // Show the divider only if there's a genuinely unread VISIBLE message - // FROM SOMEONE ELSE after the read marker. Own messages and hidden events - // (reactions, receipts, edits) don't count — otherwise the divider ends - // up below all messages (e.g. right after one's own latest message). - final me = room.client.userID; - final events = timeline.events; // newest-first - final frIndex = - fullyRead.isEmpty ? -1 : events.indexWhere((e) => e.eventId == fullyRead); - // Indices [0, upTo) are newer than the read marker. - final upTo = frIndex >= 0 ? frIndex : events.length; - var newHasUnread = false; - for (var i = 0; i < upTo; i++) { - final e = events[i]; - if (_MessageList._isVisible(e) && e.senderId != me) { - newHasUnread = true; - break; - } - } - - _sessionCaptured = true; - - // setState so _MessageList rebuilds with the captured unread marker. - if (mounted) { - setState(() { - _sessionFullyReadId = newFullyReadId; - _sessionHasUnread = newHasUnread; - }); - if (newHasUnread) { - WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline)); - } - } - } - _markRead(timeline); - } - - void _scrollToUnread(Timeline timeline) { - if (_didScrollToUnread) return; - _attemptScrollToUnread(0); - } - - // The "Neue Nachrichten" divider may not be laid out yet on first open (lazy - // reverse ListView only builds visible + cacheExtent items). Retry until its - // context exists, nudging the scroll upward periodically so the list builds - // toward the divider when there are many unread messages. - void _attemptScrollToUnread(int attempt) { - if (_didScrollToUnread || !mounted) return; - final ctx = _unreadDividerKey.currentContext; - if (ctx != null) { - _didScrollToUnread = true; - // alignment 0.85: in a reverse list this lands the divider near the top - // of the viewport with a little read context above it and the unread - // messages filling the screen below. - Scrollable.ensureVisible( - ctx, - alignment: 0.85, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - ); - return; - } - if (attempt >= 24) { - _didScrollToUnread = true; // give up — stay at the newest messages - return; - } - if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) { - final pos = _scrollCtrl.position; - final target = (pos.pixels + 1500).clamp(0.0, pos.maxScrollExtent); - if (target > pos.pixels) _scrollCtrl.jumpTo(target); - } - WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(milliseconds: 70), - () => _attemptScrollToUnread(attempt + 1)); - }); - } - - void _markRead([Timeline? tl]) { - // Cancel any pending notification for this room (user is now reading it). - cancelNotificationForRoom(widget.roomId); - - final sendReceipts = ref.read(privacyReadReceiptsProvider); - if (!sendReceipts) return; - final room = ref.read(roomProvider(widget.roomId)); - if (room == null) return; - final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull; - final latest = timeline?.events.firstOrNull; - if (latest != null && latest.status.isSynced) { - room.setReadMarker(latest.eventId, mRead: latest.eventId) - .catchError((_) {}); - } else { - room.markUnread(false).catchError((_) {}); - } - } - - Future _onScroll() async { - if (!_scrollCtrl.hasClients) return; - final pixels = _scrollCtrl.position.pixels; - final maxExtent = _scrollCtrl.position.maxScrollExtent; - - if (pixels < 60) { - _markRead(null); - // Reached the newest messages → the "Neue Nachrichten" divider is no - // longer useful. Fade it out, then remove it. - if (_sessionHasUnread && !_unreadFading) { - setState(() => _unreadFading = true); - _unreadFadeTimer?.cancel(); - _unreadFadeTimer = Timer(const Duration(milliseconds: 900), () { - if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; }); - }); - } - } - final shouldShow = pixels > 400; - if (shouldShow != _showScrollDown) { - setState(() => _showScrollDown = shouldShow); - } - - final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; - if (timeline == null) return; - - // Backward pagination — older events near the top. We keep requesting until - // a request genuinely returns nothing new (real start of room), tracked via - // _historyExhausted, so we neither stop early nor hammer the server. - if (!_loadingHistory && - !_historyExhausted && - pixels >= maxExtent - 600) { - _loadingHistory = true; - final oldestBefore = timeline.events.isNotEmpty ? timeline.events.last.eventId : null; - final lenBefore = timeline.events.length; - try { - await timeline.requestHistory(historyCount: 60); - } catch (_) {} - final oldestAfter = timeline.events.isNotEmpty ? timeline.events.last.eventId : null; - // Nothing older arrived → we've reached the beginning of the room. - if (timeline.events.length == lenBefore && oldestBefore == oldestAfter) { - _historyExhausted = true; - } - await Future.delayed(const Duration(milliseconds: 200)); - _loadingHistory = false; - } - - // Forward pagination — only relevant after jumping to a historical anchor - // (e.g. from search). At the live edge canRequestFuture is false, so this - // won't fire during normal reading and can't pull the view back down. - if (!_loadingFuture && - timeline.canRequestFuture && - pixels < 200) { - _loadingFuture = true; - try { - await timeline.requestFuture(historyCount: 60); - } catch (_) {} - await Future.delayed(const Duration(milliseconds: 250)); - _loadingFuture = false; - } - } - - @override - Widget build(BuildContext context) { - final pt = PyramidTheme.of(context); - final room = ref.watch(roomProvider(widget.roomId)); - final timelineAsync = ref.watch(timelineProvider(widget.roomId)); - final membersOpen = ref.watch(membersPanelOpenProvider); - - // A search result requested a jump to a specific message. Using watch (not - // listen) so it also fires when this ChatView is freshly mounted for a - // DIFFERENT room — listen would miss a value set before registration. - final pendingJump = ref.watch(pendingJumpEventProvider); - if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && - !_jumpInProgress && - ref.read(pendingJumpEventProvider) == pendingJump) { - _startJump(pendingJump); - } - }); - } - - final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; - final body = Stack( - fit: StackFit.expand, - children: [ - Row( - children: [ - Expanded( - child: Container( - color: pt.bg1, - child: Column( - children: [ - if (_inSelectionMode) - _SelectionHeader( - pt: pt, - count: _selectedIds.length, - selectedIds: Set.from(_selectedIds), - room: room, - timeline: ref.read(timelineProvider(widget.roomId)).valueOrNull, - currentUserId: room?.client.userID ?? '', - onClear: _clearSelection, - onReply: () { - final tl = ref.read(timelineProvider(widget.roomId)).valueOrNull; - if (tl == null) return; - try { - final event = tl.events.firstWhere((e) => e.eventId == _selectedIds.first); - setState(() { _replyTo = event; _selectedIds.clear(); }); - } catch (_) {} - }, - ) - else if (room != null && room.isDirectChat) - // DM: aufklappbarer Banner-Header — Avatar und Name - // skalieren mit, Banner und Bio blenden ein. - // ValueKey: pro Raum ein frischer State, damit beim - // schnellen Kanalwechsel keinerlei Daten (Avatar, - // Banner, laufende Futures) vom Vorgänger übrigbleiben. - _DmExpandingHeader( - key: ValueKey('dmheader:${room.id}'), - room: room, - roomId: widget.roomId, - pt: pt, - expanded: _profileExpanded, - onToggle: () => setState( - () => _profileExpanded = !_profileExpanded), - membersOpen: membersOpen, - pinnedOpen: _pinnedOpen, - onToggleMembers: () => ref - .read(membersPanelOpenProvider.notifier) - .update((s) => !s), - onOpenSearch: () => - ref.read(activeModalProvider.notifier).state = - ModalKind.search, - onTogglePinned: () => - setState(() => _pinnedOpen = !_pinnedOpen), - ) - else - _ChatHeader( - room: room, - roomId: widget.roomId, - pt: pt, - membersOpen: membersOpen, - pinnedOpen: _pinnedOpen, - onToggleMembers: () => ref - .read(membersPanelOpenProvider.notifier) - .update((s) => !s), - onOpenSearch: () => - ref.read(activeModalProvider.notifier).state = - ModalKind.search, - onTogglePinned: () => - setState(() => _pinnedOpen = !_pinnedOpen), - ), - const _ConnectionBanner(), - Expanded( - child: Stack( - children: [ - timelineAsync.when( - loading: () => Center( - child: CircularProgressIndicator( - strokeWidth: 2, - color: pt.accent, - ), - ), - error: (e, _) => Center( - child: Text( - 'Could not load messages', - style: TextStyle(color: pt.fgDim, fontSize: 14), - ), - ), - data: (timeline) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => _captureAndMarkRead(timeline)); - return _MessageList( - timeline: timeline, - scrollCtrl: _scrollCtrl, - currentUserId: room?.client.userID ?? '', - pt: pt, - onReply: (event) => setState(() => _replyTo = event), - selectedIds: _selectedIds, - onToggleSelect: _toggleSelect, - sessionFullyReadId: _sessionFullyReadId, - sessionHasUnread: _sessionHasUnread, - unreadFading: _unreadFading, - unreadDividerKey: _sessionHasUnread ? _unreadDividerKey : null, - jumpEventId: _jumpEventId, - jumpKey: _jumpKey, - highlightEventId: _highlightEventId, - highlightOn: _highlightOn, - ); - }, - ), - if (_showScrollDown) - Positioned( - right: 16, - bottom: 12, - child: AnimatedOpacity( - opacity: _showScrollDown ? 1 : 0, - duration: const Duration(milliseconds: 150), - child: GestureDetector( - onTap: () => _scrollCtrl.animateTo( - 0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: pt.bg0, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: pt.border), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(80), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted), - ), - ), - ), - ), - ], - ), - ), - if (room != null) _TypingIndicator(room: room, pt: pt), - if (room != null) - ChatComposer( - room: room, - replyTo: _replyTo, - onClearReply: () => setState(() => _replyTo = null), - ), - ], - ), - ), - ), - if (_pinnedOpen) - PinnedMessagesPanel( - roomId: widget.roomId, - onClose: () => setState(() => _pinnedOpen = false), - ), - ], - ), - // Drag-and-drop overlay — bleibt gemountet und blendet animiert - // ein/aus, damit der Übergang flüssig statt abrupt ist. - Positioned.fill( - child: IgnorePointer( - child: AnimatedOpacity( - opacity: _draggingOver ? 1 : 0, - duration: const Duration(milliseconds: 160), - curve: Curves.easeOutCubic, - child: _DragOverlay(pt: pt, active: _draggingOver), - ), - ), - ), - ], - ); - - if (!isDesktop) return body; - - return DropTarget( - onDragEntered: (_) => setState(() => _draggingOver = true), - onDragExited: (_) => setState(() => _draggingOver = false), - onDragDone: (details) async { - setState(() => _draggingOver = false); - if (room == null) return; - final files = details.files.map((xf) => File(xf.path)).toList(); - if (files.isEmpty) return; - if (!mounted) return; - await AttachmentDialog.show(context, files, room, replyTo: _replyTo); - if (mounted) setState(() => _replyTo = null); - }, - child: body, - ); - } -} - -class _ChatHeader extends ConsumerWidget { - final Room? room; - final String roomId; - final PyramidTheme pt; - final bool membersOpen; - final bool pinnedOpen; - final VoidCallback onToggleMembers; - final VoidCallback onOpenSearch; - final VoidCallback onTogglePinned; - - const _ChatHeader({ - required this.room, - required this.roomId, - required this.pt, - required this.membersOpen, - required this.pinnedOpen, - required this.onToggleMembers, - required this.onOpenSearch, - required this.onTogglePinned, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final name = room?.getLocalizedDisplayname() ?? roomId; - final isDm = room?.isDirectChat ?? false; - final isEncrypted = room?.encrypted ?? false; - final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name'); - final client = ref.watch(matrixClientProvider).valueOrNull; - final size = MediaQuery.sizeOf(context); - final isMobile = size.width < 900 || size.height < 500; - - return Container( - height: 52, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: pt.bg1, - border: Border(bottom: BorderSide(color: pt.border)), - ), - child: Row( - children: [ - // Back button on mobile - if (isMobile) ...[ - GestureDetector( - onTap: () => ref.read(activeRoomIdProvider.notifier).state = null, - child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), - ), - const SizedBox(width: 12), - ], - // Room icon / avatar - if (isDm && client != null) ...[ - _DmHeaderAvatar(room: room, name: name, client: client), - ] else if (!isDm) ...[ - Icon( - isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded, - size: 16, - color: pt.fgDim, - ), - ], - const SizedBox(width: 8), - // Name + presence for DMs - if (isDm) - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - color: pt.fg, - fontSize: 15, - fontWeight: FontWeight.w600), - overflow: TextOverflow.ellipsis, - ), - if (room != null && client != null) - _PresenceLine( - userId: room!.directChatMatrixID, - client: client, - pt: pt, - ), - ], - ), - ) - else - Text( - name, - style: TextStyle( - color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), - ), - const SizedBox(width: 12), - if (!isDm && topic.isNotEmpty) ...[ - Container(width: 1, height: 18, color: pt.border), - const SizedBox(width: 12), - Expanded( - child: Text( - topic, - style: TextStyle(color: pt.fgMuted, fontSize: 13), - overflow: TextOverflow.ellipsis, - ), - ), - ] else if (!isDm) - const Spacer(), - // Right actions - Row( - children: [ - if (isDm) ...[ - PyrIconBtn( - size: 28, - icon: const Icon(Icons.phone_rounded), - tooltip: 'Voice call', - onPressed: () => _startCall(ref, room, cam: false), - ), - PyrIconBtn( - size: 28, - icon: const Icon(Icons.videocam_rounded), - tooltip: 'Video call', - onPressed: () => _startCall(ref, room, cam: true), - ), - if (size.width > 700) - Container( - width: 1, - height: 20, - margin: const EdgeInsets.symmetric(horizontal: 4), - color: pt.border, - ), - ], - if (size.width > 800) ...[ - PyrIconBtn( - size: 28, - icon: const Icon(Icons.inbox_rounded), - tooltip: 'Threads', - onPressed: () {}, - ), - PyrIconBtn( - size: 28, - icon: const Icon(Icons.push_pin_rounded), - tooltip: 'Angepinnte Nachrichten', - active: pinnedOpen, - onPressed: onTogglePinned, - ), - ], - PyrIconBtn( - size: 28, - icon: const Icon(Icons.search_rounded), - tooltip: 'Search', - onPressed: onOpenSearch, - ), - PyrIconBtn( - size: 28, - icon: const Icon(Icons.group_rounded), - tooltip: 'Members', - active: membersOpen, - onPressed: onToggleMembers, - ), - ], - ), - ], - ), - ); - } - - void _startCall(WidgetRef ref, Room? room, {required bool cam}) async { - if (room == null) return; - - if (room.isDirectChat) { - // Use native Matrix VoIP for 1:1 DMs - ref.read(viewModeProvider.notifier).state = ViewMode.voice; - await PyramidVoipManager.instance.startCall(room.id, video: cam); - return; - } - - // Use LiveKit for group voice rooms - ref.read(viewModeProvider.notifier).state = ViewMode.voice; - await ref.read(callStateProvider).startCall( - roomName: roomId, - roomDisplayName: room.getLocalizedDisplayname(), - identity: 'user_${DateTime.now().millisecondsSinceEpoch}', - audioOnly: !cam, - ); - } -} - -/// DM-Header, der sich per Klick aufklappt: Das Banner blendet als Hintergrund -/// ein, Avatar und Name skalieren mit, Handle + Bio erscheinen darunter -/// (Design nach Nutzer-Mockup, Twitter/Discord-artig). -class _DmExpandingHeader extends ConsumerStatefulWidget { - final Room room; - final String roomId; - final PyramidTheme pt; - final bool expanded; - final VoidCallback onToggle; - final bool membersOpen; - final bool pinnedOpen; - final VoidCallback onToggleMembers; - final VoidCallback onOpenSearch; - final VoidCallback onTogglePinned; - - const _DmExpandingHeader({ - super.key, - required this.room, - required this.roomId, - required this.pt, - required this.expanded, - required this.onToggle, - required this.membersOpen, - required this.pinnedOpen, - required this.onToggleMembers, - required this.onOpenSearch, - required this.onTogglePinned, - }); - - @override - ConsumerState<_DmExpandingHeader> createState() => - _DmExpandingHeaderState(); -} - -class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> { - static const _animDuration = Duration(milliseconds: 280); - static const _animCurve = Curves.easeOutCubic; - - Profile? _profile; - String? _bannerMxcUri; - String? _statusMsg; - bool _online = false; - - String? get _partnerId => widget.room.directChatMatrixID; - - @override - void initState() { - super.initState(); - // Direkt laden, damit das Banner beim ersten Aufklappen schon da ist. - _load(); - } - - @override - void didUpdateWidget(_DmExpandingHeader old) { - super.didUpdateWidget(old); - if (old.room.id != widget.room.id) { - _profile = null; - _bannerMxcUri = null; - _statusMsg = null; - _online = false; - _load(); - } - } - - Future _load() async { - final userId = _partnerId; - if (userId == null) return; - final client = widget.room.client; - // Race-Guard: Beim schnellen DM-Wechsel dürfen verspätete Antworten des - // VORHERIGEN Partners das Profil des aktuellen nicht überschreiben. - bool stillCurrent() => mounted && _partnerId == userId; - try { - final p = await client.getProfileFromUserId(userId); - if (stillCurrent()) setState(() => _profile = p); - } catch (_) {} - try { - final p = await client.fetchCurrentPresence(userId); - final last = p.lastActiveTimestamp; - final fresh = p.currentlyActive == true || - (last != null && - DateTime.now().difference(last) < const Duration(minutes: 5)); - if (stillCurrent()) { - setState(() { - _online = p.presence == PresenceType.online && fresh; - _statusMsg = p.statusMsg; - }); - } - } catch (_) {} - // Banner aus dem Profil-Custom-Field (io.pyramid.profile.banner_url). - try { - final apiUri = client.homeserver!.replace( - path: '/_matrix/client/v3/profile/${Uri.encodeComponent(userId)}', - ); - final resp = await client.httpClient.get( - apiUri, - headers: {'Authorization': 'Bearer ${client.accessToken}'}, - ); - if (resp.statusCode == 200) { - final data = jsonDecode(resp.body) as Map; - final url = data['io.pyramid.profile.banner_url'] as String?; - if (url != null && stillCurrent()) { - setState(() => _bannerMxcUri = url); - } - } - } catch (_) {} - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - final expanded = widget.expanded; - final size = MediaQuery.sizeOf(context); - final isMobile = size.width < 900 || size.height < 500; - final client = ref.watch(matrixClientProvider).valueOrNull; - - final userId = _partnerId ?? ''; - final member = widget.room.unsafeGetUserFromMemoryOrFallback(userId); - final name = _profile?.displayName ?? - member.displayName ?? - widget.room.getLocalizedDisplayname(); - final avatarUrl = - _profile?.avatarUrl ?? member.avatarUrl ?? widget.room.avatar; - final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; - - // Im Querformat (wenig Höhe) kompakter aufklappen. - final expandedHeight = size.height < 500 ? 140.0 : 190.0; - final avatarSize = expanded - ? (size.height < 500 ? 72.0 : 96.0) - : 32.0; - - return AnimatedContainer( - duration: _animDuration, - curve: _animCurve, - height: expanded ? expandedHeight : 52, - decoration: BoxDecoration( - color: pt.bg1, - border: Border(bottom: BorderSide(color: pt.border)), - ), - clipBehavior: Clip.hardEdge, - child: Stack( - fit: StackFit.expand, - children: [ - // Banner als Hintergrund — blendet beim Aufklappen ein. - if (_bannerMxcUri != null && client != null) - Positioned.fill( - child: AnimatedOpacity( - opacity: expanded ? 1 : 0, - duration: _animDuration, - curve: Curves.easeOut, - child: MxcImage( - mxcUri: _bannerMxcUri!, - client: client, - fit: BoxFit.cover, - placeholder: const SizedBox.shrink(), - ), - ), - ), - // Scrim: links dunkel, damit Avatar/Text lesbar bleiben (Mockup). - if (_bannerMxcUri != null) - Positioned.fill( - child: AnimatedOpacity( - opacity: expanded ? 1 : 0, - duration: _animDuration, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - pt.bg1, - pt.bg1.withAlpha(210), - pt.bg1.withAlpha(0), - ], - stops: const [0.0, 0.32, 0.72], - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - if (isMobile) ...[ - GestureDetector( - onTap: () => - ref.read(activeRoomIdProvider.notifier).state = null, - child: - Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), - ), - const SizedBox(width: 12), - ], - // Klickbarer Profilbereich: Avatar + Name + Details - Expanded( - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onToggle, - child: Row( - children: [ - // Avatar — skaliert mit (rund, wie im Mockup) - AnimatedContainer( - duration: _animDuration, - curve: _animCurve, - width: avatarSize, - height: avatarSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: pt.bg3, - ), - clipBehavior: Clip.antiAlias, - child: avatarUrl != null && client != null - ? MxcImage( - mxcUri: avatarUrl.toString(), - client: client, - fit: BoxFit.cover, - placeholder: _initialBox(initial, pt), - ) - : _initialBox(initial, pt), - ), - AnimatedContainer( - duration: _animDuration, - curve: _animCurve, - width: expanded ? 20 : 10, - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Name skaliert mit; Status blendet daneben ein - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flexible( - child: AnimatedDefaultTextStyle( - duration: _animDuration, - curve: _animCurve, - style: TextStyle( - color: pt.fg, - fontSize: expanded ? 24 : 15, - fontWeight: expanded - ? FontWeight.w700 - : FontWeight.w600, - ), - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - AnimatedOpacity( - opacity: expanded ? 1 : 0, - duration: _animDuration, - child: Padding( - padding: - const EdgeInsets.only(left: 8), - child: Text( - _online ? 'online' : 'offline', - style: TextStyle( - color: _online - ? pt.online - : pt.fgMuted, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ), - // Eingeklappt: "zuletzt online vor…", - // aufgeklappt: Handle + Biographie. - AnimatedCrossFade( - duration: _animDuration, - sizeCurve: _animCurve, - crossFadeState: expanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - firstChild: client != null - ? _PresenceLine( - userId: _partnerId, - client: client, - pt: pt, - ) - : const SizedBox.shrink(), - secondChild: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Text( - userId, - style: TextStyle( - color: pt.fgMuted, fontSize: 13), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (_statusMsg != null && - _statusMsg!.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - _statusMsg!, - style: TextStyle( - color: pt.fgMuted, - fontSize: 13, - height: 1.35), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: 12), - // Aktionen — bleiben immer sichtbar - Row( - children: [ - PyrIconBtn( - size: 28, - icon: const Icon(Icons.phone_rounded), - tooltip: 'Voice call', - onPressed: () => _startCall(cam: false), - ), - PyrIconBtn( - size: 28, - icon: const Icon(Icons.videocam_rounded), - tooltip: 'Video call', - onPressed: () => _startCall(cam: true), - ), - if (size.width > 700) - Container( - width: 1, - height: 20, - margin: const EdgeInsets.symmetric(horizontal: 4), - color: pt.border, - ), - if (size.width > 800) - PyrIconBtn( - size: 28, - icon: const Icon(Icons.push_pin_rounded), - tooltip: 'Angepinnte Nachrichten', - active: widget.pinnedOpen, - onPressed: widget.onTogglePinned, - ), - PyrIconBtn( - size: 28, - icon: const Icon(Icons.search_rounded), - tooltip: 'Search', - onPressed: widget.onOpenSearch, - ), - PyrIconBtn( - size: 28, - icon: const Icon(Icons.group_rounded), - tooltip: 'Members', - active: widget.membersOpen, - onPressed: widget.onToggleMembers, - ), - ], - ), - ], - ), - ), - ], - ), - ); - } - - Widget _initialBox(String initial, PyramidTheme pt) => Center( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Padding( - padding: const EdgeInsets.all(6), - child: Text( - initial, - style: TextStyle( - color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700), - ), - ), - ), - ); - - void _startCall({required bool cam}) async { - ref.read(viewModeProvider.notifier).state = ViewMode.voice; - await PyramidVoipManager.instance.startCall(widget.room.id, video: cam); - } -} - -class _DmHeaderAvatar extends StatelessWidget { - final Room? room; - final String name; - final Client client; - - const _DmHeaderAvatar({required this.room, required this.name, required this.client}); - - @override - Widget build(BuildContext context) { - final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; - const size = 32.0; - return MxcAvatar( - mxcUri: room?.avatar, - client: client, - size: size, - borderRadius: BorderRadius.circular(size / 2), - placeholder: (_) => _InitialCircle(initial: initial, size: size), - ); - } -} - -class _InitialCircle extends StatelessWidget { - final String initial; - final double size; - const _InitialCircle({required this.initial, required this.size}); - - @override - Widget build(BuildContext context) { - const color = Color(0xFF06B6D4); - return Container( - width: size, - height: size, - decoration: const BoxDecoration(color: color, shape: BoxShape.circle), - child: Center( - child: Text(initial, - style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)), - ), - ); - } -} - -class _PresenceLine extends StatefulWidget { - final String? userId; - final Client client; - final PyramidTheme pt; - - const _PresenceLine({required this.userId, required this.client, required this.pt}); - - @override - State<_PresenceLine> createState() => _PresenceLineState(); -} - -class _PresenceLineState extends State<_PresenceLine> { - CachedPresence? _presence; - StreamSubscription? _sub; - // Debounce timer: delay "online" updates 4s to filter out background-sync - // false positives (push notifications briefly wake the recipient's client). - Timer? _onlineDebounce; - - @override - void initState() { - super.initState(); - _load(widget.userId); - } - - @override - void didUpdateWidget(_PresenceLine oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.userId != widget.userId) { - _sub?.cancel(); - _sub = null; - _onlineDebounce?.cancel(); - setState(() => _presence = null); - _load(widget.userId); - } - } - - @override - void dispose() { - _sub?.cancel(); - _onlineDebounce?.cancel(); - super.dispose(); - } - - // "online" only when the server's presence enum says online AND the last - // activity is recent. Using currentlyActive (the old logic) reported online - // long after the user left, because that flag lingers. - static bool _isOnline(CachedPresence p) { - if (p.presence != PresenceType.online) return false; - final last = p.lastActiveTimestamp; - return last == null || DateTime.now().difference(last).inSeconds < 90; - } - - void _applyPresence(CachedPresence p) { - _onlineDebounce?.cancel(); - if (_isOnline(p)) { - // Wait 4 seconds before committing "online" to filter single-sync spikes - // (a backgrounded client briefly reports online when a push wakes it). - _onlineDebounce = Timer(const Duration(seconds: 4), () { - if (mounted) setState(() => _presence = p); - }); - } else { - if (mounted) setState(() => _presence = p); - } - } - - Future _load(String? userId) async { - if (userId == null) return; - // Never show our own presence in a DM header - if (userId == widget.client.userID) return; - try { - final p = await widget.client.fetchCurrentPresence(userId); - if (mounted) _applyPresence(p); - } catch (_) {} - _sub = widget.client.onPresenceChanged.stream - .where((p) => p.userid == userId) - .listen((p) => _applyPresence(p)); - } - - @override - Widget build(BuildContext context) { - final p = _presence; - if (p == null) return const SizedBox.shrink(); - - String text; - Color color = widget.pt.fgDim; - if (_isOnline(p)) { - text = 'online'; - color = const Color(0xFF3BA55D); - } else if (p.lastActiveTimestamp != null) { - text = 'zuletzt online ${_relativeTime(p.lastActiveTimestamp!)}'; - } else { - return const SizedBox.shrink(); - } - - return Text( - text, - style: TextStyle(color: color, fontSize: 11), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - } - - String _relativeTime(DateTime t) { - final diff = DateTime.now().difference(t); - if (diff.inMinutes < 1) return 'gerade eben'; - if (diff.inMinutes < 60) return 'vor ${diff.inMinutes}min'; - if (diff.inHours < 24) return 'vor ${diff.inHours}h'; - if (diff.inDays < 7) return 'vor ${diff.inDays}d'; - if (diff.inDays < 365) return 'vor ${(diff.inDays / 7).floor()}w'; - return 'vor ${(diff.inDays / 365).floor()}j'; - } -} - -// Shows a slim banner when the client loses its connection to the homeserver. -// Debounced so brief transient sync errors don't flicker a banner. -class _ConnectionBanner extends ConsumerStatefulWidget { - const _ConnectionBanner(); - @override - ConsumerState<_ConnectionBanner> createState() => _ConnectionBannerState(); -} - -class _ConnectionBannerState extends ConsumerState<_ConnectionBanner> { - StreamSubscription? _sub; - Timer? _debounce; - bool _lost = false; - - @override - void initState() { - super.initState(); - _attach(); - } - - Future _attach() async { - try { - final client = await ref.read(matrixClientProvider.future); - _sub = client.onSyncStatus.stream.listen((s) { - if (s.status == SyncStatus.error) { - // Only surface after the error persists a few seconds. - _debounce ??= Timer(const Duration(seconds: 4), () { - _debounce = null; - if (mounted) setState(() => _lost = true); - }); - } else if (s.status == SyncStatus.finished || - s.status == SyncStatus.processing) { - _debounce?.cancel(); - _debounce = null; - if (_lost && mounted) setState(() => _lost = false); - } - }); - } catch (_) {} - } - - @override - void dispose() { - _sub?.cancel(); - _debounce?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final pt = PyramidTheme.of(context); - return AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - alignment: Alignment.topCenter, - child: _lost - ? Container( - width: double.infinity, - color: pt.danger.withAlpha(36), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.cloud_off_rounded, size: 14, color: pt.danger), - const SizedBox(width: 8), - Flexible( - child: Text( - 'Keine Verbindung zum Server – Wiederverbindung läuft…', - style: TextStyle(color: pt.danger, fontSize: 12, fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ) - : const SizedBox.shrink(), - ); - } -} - -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)), - ], - ), - ), - ); - } -} - -// ─── Typing indicator ───────────────────────────────────────────────────────── - -class _TypingIndicator extends StatefulWidget { - final Room room; - final PyramidTheme pt; - const _TypingIndicator({required this.room, required this.pt}); - - @override - State<_TypingIndicator> createState() => _TypingIndicatorState(); -} - -class _TypingIndicatorState extends State<_TypingIndicator> { - StreamSubscription? _sub; - List _typing = []; - - StreamSubscription _listenTyping(Room room) { - // client.onSync fires after every sync — cheap to filter for typing events. - return room.client.onSync.stream.listen((_) { - if (!mounted) return; - final typing = room.typingUsers - .where((u) => u.id != room.client.userID) - .toList(); - if (typing.length != _typing.length || - !typing.every((u) => _typing.any((t) => t.id == u.id))) { - setState(() => _typing = typing); - } - }); - } - - @override - void initState() { - super.initState(); - _sub = _listenTyping(widget.room); - } - - @override - void didUpdateWidget(_TypingIndicator oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.room.id != widget.room.id) { - _sub?.cancel(); - setState(() => _typing = []); - _sub = _listenTyping(widget.room); - } - } - - @override - void dispose() { - _sub?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (_typing.isEmpty) return const SizedBox.shrink(); - final pt = widget.pt; - final names = _typing.map((u) => u.calcDisplayname()).toList(); - final label = names.length == 1 - ? '${names[0]} tippt…' - : names.length == 2 - ? '${names[0]} und ${names[1]} tippen…' - : '${names[0]} und ${names.length - 1} weitere tippen…'; - return Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 0), - child: Row( - children: [ - _TypingDots(color: pt.fgDim), - const SizedBox(width: 8), - Flexible( - child: Text( - label, - style: TextStyle(color: pt.fgDim, fontSize: 12, fontStyle: FontStyle.italic), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} - -class _TypingDots extends StatefulWidget { - final Color color; - const _TypingDots({required this.color}); - - @override - State<_TypingDots> createState() => _TypingDotsState(); -} - -class _TypingDotsState extends State<_TypingDots> - with SingleTickerProviderStateMixin { - late final AnimationController _ctrl; - - @override - void initState() { - super.initState(); - _ctrl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - )..repeat(); - } - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _ctrl, - builder: (context, child) { - return Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(3, (i) { - // Each dot peaks at a different phase. - final phase = ((_ctrl.value * 3) - i).clamp(0.0, 1.0); - final opacity = (phase < 0.5 ? phase * 2 : (1 - phase) * 2).clamp(0.3, 1.0); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 1), - child: Opacity( - opacity: opacity, - child: Container( - width: 4, - height: 4, - decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle), - ), - ), - ); - }), - ); - }, - ); - } -} - -// ─── Drag-and-drop overlay ──────────────────────────────────────────────────── - -class _DragOverlay extends StatefulWidget { - final PyramidTheme pt; - final bool active; - const _DragOverlay({required this.pt, required this.active}); - - @override - State<_DragOverlay> createState() => _DragOverlayState(); -} - -class _DragOverlayState extends State<_DragOverlay> - with SingleTickerProviderStateMixin { - // Sanftes Auf-und-ab des Icons, solange das Overlay aktiv ist. - late final AnimationController _bob = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - - @override - void initState() { - super.initState(); - if (widget.active) _bob.repeat(reverse: true); - } - - @override - void didUpdateWidget(_DragOverlay old) { - super.didUpdateWidget(old); - if (widget.active && !old.active) { - _bob.repeat(reverse: true); - } else if (!widget.active && old.active) { - _bob.stop(); - } - } - - @override - void dispose() { - _bob.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - return Container( - color: pt.bg0.withAlpha(200), - child: Center( - child: AnimatedScale( - scale: widget.active ? 1.0 : 0.88, - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutBack, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32), - decoration: BoxDecoration( - color: pt.bg2, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: pt.accent, width: 2), - boxShadow: [ - BoxShadow( - color: pt.accent.withAlpha(60), - blurRadius: 32, - spreadRadius: 4, - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedBuilder( - animation: _bob, - builder: (_, child) => Transform.translate( - offset: Offset( - 0, -7 * Curves.easeInOut.transform(_bob.value)), - child: child, - ), - child: - Icon(Icons.upload_file_rounded, size: 56, color: pt.accent), - ), - const SizedBox(height: 16), - Text( - 'Datei hier ablegen', - style: TextStyle( - color: pt.fg, - fontSize: 20, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - 'Loslassen zum Hochladen', - style: TextStyle(color: pt.fgMuted, fontSize: 14), - ), - ], - ), - ), - ), - ), - ); - } -} - -// Placeholder shown when no room is selected -class NoChatSelected extends StatelessWidget { - const NoChatSelected({super.key}); - - @override - Widget build(BuildContext context) { - final pt = PyramidTheme.of(context); - return Container( - color: pt.bg1, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.forum_outlined, size: 64, color: pt.fgDim), - const SizedBox(height: 16), - Text( - 'Select a room to start chatting', - style: TextStyle(color: pt.fgMuted, fontSize: 16), - ), - const SizedBox(height: 8), - Text( - 'Choose from the sidebar on the left', - style: TextStyle(color: pt.fgDim, fontSize: 13), - ), - ], - ), - ), - ); - } -} +part 'view/chat_view_core.dart'; +part 'view/chat_header.dart'; +part 'view/chat_connection.dart'; +part 'view/chat_message_list.dart'; +part 'view/chat_typing.dart'; +part 'view/chat_misc.dart'; diff --git a/lib/features/chat/view/chat_connection.dart b/lib/features/chat/view/chat_connection.dart new file mode 100644 index 0000000..80bd909 --- /dev/null +++ b/lib/features/chat/view/chat_connection.dart @@ -0,0 +1,78 @@ +part of '../chat_view.dart'; + +class _ConnectionBanner extends ConsumerStatefulWidget { + const _ConnectionBanner(); + @override + ConsumerState<_ConnectionBanner> createState() => _ConnectionBannerState(); +} + +class _ConnectionBannerState extends ConsumerState<_ConnectionBanner> { + StreamSubscription? _sub; + Timer? _debounce; + bool _lost = false; + + @override + void initState() { + super.initState(); + _attach(); + } + + Future _attach() async { + try { + final client = await ref.read(matrixClientProvider.future); + _sub = client.onSyncStatus.stream.listen((s) { + if (s.status == SyncStatus.error) { + // Only surface after the error persists a few seconds. + _debounce ??= Timer(const Duration(seconds: 4), () { + _debounce = null; + if (mounted) setState(() => _lost = true); + }); + } else if (s.status == SyncStatus.finished || + s.status == SyncStatus.processing) { + _debounce?.cancel(); + _debounce = null; + if (_lost && mounted) setState(() => _lost = false); + } + }); + } catch (_) {} + } + + @override + void dispose() { + _sub?.cancel(); + _debounce?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: _lost + ? Container( + width: double.infinity, + color: pt.danger.withAlpha(36), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_off_rounded, size: 14, color: pt.danger), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Keine Verbindung zum Server – Wiederverbindung läuft…', + style: TextStyle(color: pt.danger, fontSize: 12, fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ); + } +} diff --git a/lib/features/chat/view/chat_header.dart b/lib/features/chat/view/chat_header.dart new file mode 100644 index 0000000..0acab78 --- /dev/null +++ b/lib/features/chat/view/chat_header.dart @@ -0,0 +1,732 @@ +part of '../chat_view.dart'; + +class _ChatHeader extends ConsumerWidget { + final Room? room; + final String roomId; + final PyramidTheme pt; + final bool membersOpen; + final bool pinnedOpen; + final VoidCallback onToggleMembers; + final VoidCallback onOpenSearch; + final VoidCallback onTogglePinned; + + const _ChatHeader({ + required this.room, + required this.roomId, + required this.pt, + required this.membersOpen, + required this.pinnedOpen, + required this.onToggleMembers, + required this.onOpenSearch, + required this.onTogglePinned, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final name = room?.getLocalizedDisplayname() ?? roomId; + final isDm = room?.isDirectChat ?? false; + final isEncrypted = room?.encrypted ?? false; + final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name'); + final client = ref.watch(matrixClientProvider).valueOrNull; + final size = MediaQuery.sizeOf(context); + final isMobile = size.width < 900 || size.height < 500; + + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: pt.bg1, + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: Row( + children: [ + // Back button on mobile + if (isMobile) ...[ + GestureDetector( + onTap: () => ref.read(activeRoomIdProvider.notifier).state = null, + child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), + ), + const SizedBox(width: 12), + ], + // Room icon / avatar + if (isDm && client != null) ...[ + _DmHeaderAvatar(room: room, name: name, client: client), + ] else if (!isDm) ...[ + Icon( + isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded, + size: 16, + color: pt.fgDim, + ), + ], + const SizedBox(width: 8), + // Name + presence for DMs + if (isDm) + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + color: pt.fg, + fontSize: 15, + fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + if (room != null && client != null) + _PresenceLine( + userId: room!.directChatMatrixID, + client: client, + pt: pt, + ), + ], + ), + ) + else + Text( + name, + style: TextStyle( + color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), + ), + const SizedBox(width: 12), + if (!isDm && topic.isNotEmpty) ...[ + Container(width: 1, height: 18, color: pt.border), + const SizedBox(width: 12), + Expanded( + child: Text( + topic, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ), + ] else if (!isDm) + const Spacer(), + // Right actions + Row( + children: [ + if (isDm) ...[ + PyrIconBtn( + size: 28, + icon: const Icon(Icons.phone_rounded), + tooltip: 'Voice call', + onPressed: () => _startCall(ref, room, cam: false), + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.videocam_rounded), + tooltip: 'Video call', + onPressed: () => _startCall(ref, room, cam: true), + ), + if (size.width > 700) + Container( + width: 1, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: pt.border, + ), + ], + if (size.width > 800) ...[ + PyrIconBtn( + size: 28, + icon: const Icon(Icons.inbox_rounded), + tooltip: 'Threads', + onPressed: () {}, + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.push_pin_rounded), + tooltip: 'Angepinnte Nachrichten', + active: pinnedOpen, + onPressed: onTogglePinned, + ), + ], + PyrIconBtn( + size: 28, + icon: const Icon(Icons.search_rounded), + tooltip: 'Search', + onPressed: onOpenSearch, + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.group_rounded), + tooltip: 'Members', + active: membersOpen, + onPressed: onToggleMembers, + ), + ], + ), + ], + ), + ); + } + + void _startCall(WidgetRef ref, Room? room, {required bool cam}) async { + if (room == null) return; + + if (room.isDirectChat) { + // Use native Matrix VoIP for 1:1 DMs + ref.read(viewModeProvider.notifier).state = ViewMode.voice; + await PyramidVoipManager.instance.startCall(room.id, video: cam); + return; + } + + // Use LiveKit for group voice rooms + ref.read(viewModeProvider.notifier).state = ViewMode.voice; + await ref.read(callStateProvider).startCall( + roomName: roomId, + roomDisplayName: room.getLocalizedDisplayname(), + identity: 'user_${DateTime.now().millisecondsSinceEpoch}', + audioOnly: !cam, + ); + } +} + +class _DmExpandingHeader extends ConsumerStatefulWidget { + final Room room; + final String roomId; + final PyramidTheme pt; + final bool expanded; + final VoidCallback onToggle; + final bool membersOpen; + final bool pinnedOpen; + final VoidCallback onToggleMembers; + final VoidCallback onOpenSearch; + final VoidCallback onTogglePinned; + + const _DmExpandingHeader({ + super.key, + required this.room, + required this.roomId, + required this.pt, + required this.expanded, + required this.onToggle, + required this.membersOpen, + required this.pinnedOpen, + required this.onToggleMembers, + required this.onOpenSearch, + required this.onTogglePinned, + }); + + @override + ConsumerState<_DmExpandingHeader> createState() => + _DmExpandingHeaderState(); +} + +class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> { + static const _animDuration = Duration(milliseconds: 280); + static const _animCurve = Curves.easeOutCubic; + + Profile? _profile; + String? _bannerMxcUri; + String? _statusMsg; + bool _online = false; + + String? get _partnerId => widget.room.directChatMatrixID; + + @override + void initState() { + super.initState(); + // Direkt laden, damit das Banner beim ersten Aufklappen schon da ist. + _load(); + } + + @override + void didUpdateWidget(_DmExpandingHeader old) { + super.didUpdateWidget(old); + if (old.room.id != widget.room.id) { + _profile = null; + _bannerMxcUri = null; + _statusMsg = null; + _online = false; + _load(); + } + } + + Future _load() async { + final userId = _partnerId; + if (userId == null) return; + final client = widget.room.client; + // Race-Guard: Beim schnellen DM-Wechsel dürfen verspätete Antworten des + // VORHERIGEN Partners das Profil des aktuellen nicht überschreiben. + bool stillCurrent() => mounted && _partnerId == userId; + try { + final p = await client.getProfileFromUserId(userId); + if (stillCurrent()) setState(() => _profile = p); + } catch (_) {} + try { + final p = await client.fetchCurrentPresence(userId); + final last = p.lastActiveTimestamp; + final fresh = p.currentlyActive == true || + (last != null && + DateTime.now().difference(last) < const Duration(minutes: 5)); + if (stillCurrent()) { + setState(() { + _online = p.presence == PresenceType.online && fresh; + _statusMsg = p.statusMsg; + }); + } + } catch (_) {} + // Banner aus dem Profil-Custom-Field (io.pyramid.profile.banner_url). + try { + final apiUri = client.homeserver!.replace( + path: '/_matrix/client/v3/profile/${Uri.encodeComponent(userId)}', + ); + final resp = await client.httpClient.get( + apiUri, + headers: {'Authorization': 'Bearer ${client.accessToken}'}, + ); + if (resp.statusCode == 200) { + final data = jsonDecode(resp.body) as Map; + final url = data['io.pyramid.profile.banner_url'] as String?; + if (url != null && stillCurrent()) { + setState(() => _bannerMxcUri = url); + } + } + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final expanded = widget.expanded; + final size = MediaQuery.sizeOf(context); + final isMobile = size.width < 900 || size.height < 500; + final client = ref.watch(matrixClientProvider).valueOrNull; + + final userId = _partnerId ?? ''; + final member = widget.room.unsafeGetUserFromMemoryOrFallback(userId); + final name = _profile?.displayName ?? + member.displayName ?? + widget.room.getLocalizedDisplayname(); + final avatarUrl = + _profile?.avatarUrl ?? member.avatarUrl ?? widget.room.avatar; + final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; + + // Im Querformat (wenig Höhe) kompakter aufklappen. + final expandedHeight = size.height < 500 ? 140.0 : 190.0; + final avatarSize = expanded + ? (size.height < 500 ? 72.0 : 96.0) + : 32.0; + + return AnimatedContainer( + duration: _animDuration, + curve: _animCurve, + height: expanded ? expandedHeight : 52, + decoration: BoxDecoration( + color: pt.bg1, + border: Border(bottom: BorderSide(color: pt.border)), + ), + clipBehavior: Clip.hardEdge, + child: Stack( + fit: StackFit.expand, + children: [ + // Banner als Hintergrund — blendet beim Aufklappen ein. + if (_bannerMxcUri != null && client != null) + Positioned.fill( + child: AnimatedOpacity( + opacity: expanded ? 1 : 0, + duration: _animDuration, + curve: Curves.easeOut, + child: MxcImage( + mxcUri: _bannerMxcUri!, + client: client, + fit: BoxFit.cover, + placeholder: const SizedBox.shrink(), + ), + ), + ), + // Scrim: links dunkel, damit Avatar/Text lesbar bleiben (Mockup). + if (_bannerMxcUri != null) + Positioned.fill( + child: AnimatedOpacity( + opacity: expanded ? 1 : 0, + duration: _animDuration, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + pt.bg1, + pt.bg1.withAlpha(210), + pt.bg1.withAlpha(0), + ], + stops: const [0.0, 0.32, 0.72], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + if (isMobile) ...[ + GestureDetector( + onTap: () => + ref.read(activeRoomIdProvider.notifier).state = null, + child: + Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg), + ), + const SizedBox(width: 12), + ], + // Klickbarer Profilbereich: Avatar + Name + Details + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onToggle, + child: Row( + children: [ + // Avatar — skaliert mit (rund, wie im Mockup) + AnimatedContainer( + duration: _animDuration, + curve: _animCurve, + width: avatarSize, + height: avatarSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: pt.bg3, + ), + clipBehavior: Clip.antiAlias, + child: avatarUrl != null && client != null + ? MxcImage( + mxcUri: avatarUrl.toString(), + client: client, + fit: BoxFit.cover, + placeholder: _initialBox(initial, pt), + ) + : _initialBox(initial, pt), + ), + AnimatedContainer( + duration: _animDuration, + curve: _animCurve, + width: expanded ? 20 : 10, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name skaliert mit; Status blendet daneben ein + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: AnimatedDefaultTextStyle( + duration: _animDuration, + curve: _animCurve, + style: TextStyle( + color: pt.fg, + fontSize: expanded ? 24 : 15, + fontWeight: expanded + ? FontWeight.w700 + : FontWeight.w600, + ), + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + AnimatedOpacity( + opacity: expanded ? 1 : 0, + duration: _animDuration, + child: Padding( + padding: + const EdgeInsets.only(left: 8), + child: Text( + _online ? 'online' : 'offline', + style: TextStyle( + color: _online + ? pt.online + : pt.fgMuted, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + // Eingeklappt: "zuletzt online vor…", + // aufgeklappt: Handle + Biographie. + AnimatedCrossFade( + duration: _animDuration, + sizeCurve: _animCurve, + crossFadeState: expanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: client != null + ? _PresenceLine( + userId: _partnerId, + client: client, + pt: pt, + ) + : const SizedBox.shrink(), + secondChild: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + userId, + style: TextStyle( + color: pt.fgMuted, fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (_statusMsg != null && + _statusMsg!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + _statusMsg!, + style: TextStyle( + color: pt.fgMuted, + fontSize: 13, + height: 1.35), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 12), + // Aktionen — bleiben immer sichtbar + Row( + children: [ + PyrIconBtn( + size: 28, + icon: const Icon(Icons.phone_rounded), + tooltip: 'Voice call', + onPressed: () => _startCall(cam: false), + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.videocam_rounded), + tooltip: 'Video call', + onPressed: () => _startCall(cam: true), + ), + if (size.width > 700) + Container( + width: 1, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: pt.border, + ), + if (size.width > 800) + PyrIconBtn( + size: 28, + icon: const Icon(Icons.push_pin_rounded), + tooltip: 'Angepinnte Nachrichten', + active: widget.pinnedOpen, + onPressed: widget.onTogglePinned, + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.search_rounded), + tooltip: 'Search', + onPressed: widget.onOpenSearch, + ), + PyrIconBtn( + size: 28, + icon: const Icon(Icons.group_rounded), + tooltip: 'Members', + active: widget.membersOpen, + onPressed: widget.onToggleMembers, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _initialBox(String initial, PyramidTheme pt) => Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(6), + child: Text( + initial, + style: TextStyle( + color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700), + ), + ), + ), + ); + + void _startCall({required bool cam}) async { + ref.read(viewModeProvider.notifier).state = ViewMode.voice; + await PyramidVoipManager.instance.startCall(widget.room.id, video: cam); + } +} + +class _DmHeaderAvatar extends StatelessWidget { + final Room? room; + final String name; + final Client client; + + const _DmHeaderAvatar({required this.room, required this.name, required this.client}); + + @override + Widget build(BuildContext context) { + final initial = name.isNotEmpty ? name[0].toUpperCase() : '?'; + const size = 32.0; + return MxcAvatar( + mxcUri: room?.avatar, + client: client, + size: size, + borderRadius: BorderRadius.circular(size / 2), + placeholder: (_) => _InitialCircle(initial: initial, size: size), + ); + } +} + +class _InitialCircle extends StatelessWidget { + final String initial; + final double size; + const _InitialCircle({required this.initial, required this.size}); + + @override + Widget build(BuildContext context) { + const color = Color(0xFF06B6D4); + return Container( + width: size, + height: size, + decoration: const BoxDecoration(color: color, shape: BoxShape.circle), + child: Center( + child: Text(initial, + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)), + ), + ); + } +} + +class _PresenceLine extends StatefulWidget { + final String? userId; + final Client client; + final PyramidTheme pt; + + const _PresenceLine({required this.userId, required this.client, required this.pt}); + + @override + State<_PresenceLine> createState() => _PresenceLineState(); +} + +class _PresenceLineState extends State<_PresenceLine> { + CachedPresence? _presence; + StreamSubscription? _sub; + // Debounce timer: delay "online" updates 4s to filter out background-sync + // false positives (push notifications briefly wake the recipient's client). + Timer? _onlineDebounce; + + @override + void initState() { + super.initState(); + _load(widget.userId); + } + + @override + void didUpdateWidget(_PresenceLine oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.userId != widget.userId) { + _sub?.cancel(); + _sub = null; + _onlineDebounce?.cancel(); + setState(() => _presence = null); + _load(widget.userId); + } + } + + @override + void dispose() { + _sub?.cancel(); + _onlineDebounce?.cancel(); + super.dispose(); + } + + // "online" only when the server's presence enum says online AND the last + // activity is recent. Using currentlyActive (the old logic) reported online + // long after the user left, because that flag lingers. + static bool _isOnline(CachedPresence p) { + if (p.presence != PresenceType.online) return false; + final last = p.lastActiveTimestamp; + return last == null || DateTime.now().difference(last).inSeconds < 90; + } + + void _applyPresence(CachedPresence p) { + _onlineDebounce?.cancel(); + if (_isOnline(p)) { + // Wait 4 seconds before committing "online" to filter single-sync spikes + // (a backgrounded client briefly reports online when a push wakes it). + _onlineDebounce = Timer(const Duration(seconds: 4), () { + if (mounted) setState(() => _presence = p); + }); + } else { + if (mounted) setState(() => _presence = p); + } + } + + Future _load(String? userId) async { + if (userId == null) return; + // Never show our own presence in a DM header + if (userId == widget.client.userID) return; + try { + final p = await widget.client.fetchCurrentPresence(userId); + if (mounted) _applyPresence(p); + } catch (_) {} + _sub = widget.client.onPresenceChanged.stream + .where((p) => p.userid == userId) + .listen((p) => _applyPresence(p)); + } + + @override + Widget build(BuildContext context) { + final p = _presence; + if (p == null) return const SizedBox.shrink(); + + String text; + Color color = widget.pt.fgDim; + if (_isOnline(p)) { + text = 'online'; + color = const Color(0xFF3BA55D); + } else if (p.lastActiveTimestamp != null) { + text = 'zuletzt online ${_relativeTime(p.lastActiveTimestamp!)}'; + } else { + return const SizedBox.shrink(); + } + + return Text( + text, + style: TextStyle(color: color, fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + String _relativeTime(DateTime t) { + final diff = DateTime.now().difference(t); + if (diff.inMinutes < 1) return 'gerade eben'; + if (diff.inMinutes < 60) return 'vor ${diff.inMinutes}min'; + if (diff.inHours < 24) return 'vor ${diff.inHours}h'; + if (diff.inDays < 7) return 'vor ${diff.inDays}d'; + if (diff.inDays < 365) return 'vor ${(diff.inDays / 7).floor()}w'; + return 'vor ${(diff.inDays / 365).floor()}j'; + } +} diff --git a/lib/features/chat/view/chat_message_list.dart b/lib/features/chat/view/chat_message_list.dart new file mode 100644 index 0000000..13bb75a --- /dev/null +++ b/lib/features/chat/view/chat_message_list.dart @@ -0,0 +1,490 @@ +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)), + ], + ), + ), + ); + } +} diff --git a/lib/features/chat/view/chat_misc.dart b/lib/features/chat/view/chat_misc.dart new file mode 100644 index 0000000..a758426 --- /dev/null +++ b/lib/features/chat/view/chat_misc.dart @@ -0,0 +1,130 @@ +part of '../chat_view.dart'; + +class _DragOverlay extends StatefulWidget { + final PyramidTheme pt; + final bool active; + const _DragOverlay({required this.pt, required this.active}); + + @override + State<_DragOverlay> createState() => _DragOverlayState(); +} + +class _DragOverlayState extends State<_DragOverlay> + with SingleTickerProviderStateMixin { + // Sanftes Auf-und-ab des Icons, solange das Overlay aktiv ist. + late final AnimationController _bob = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + + @override + void initState() { + super.initState(); + if (widget.active) _bob.repeat(reverse: true); + } + + @override + void didUpdateWidget(_DragOverlay old) { + super.didUpdateWidget(old); + if (widget.active && !old.active) { + _bob.repeat(reverse: true); + } else if (!widget.active && old.active) { + _bob.stop(); + } + } + + @override + void dispose() { + _bob.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Container( + color: pt.bg0.withAlpha(200), + child: Center( + child: AnimatedScale( + scale: widget.active ? 1.0 : 0.88, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutBack, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: pt.accent, width: 2), + boxShadow: [ + BoxShadow( + color: pt.accent.withAlpha(60), + blurRadius: 32, + spreadRadius: 4, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _bob, + builder: (_, child) => Transform.translate( + offset: Offset( + 0, -7 * Curves.easeInOut.transform(_bob.value)), + child: child, + ), + child: + Icon(Icons.upload_file_rounded, size: 56, color: pt.accent), + ), + const SizedBox(height: 16), + Text( + 'Datei hier ablegen', + style: TextStyle( + color: pt.fg, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Loslassen zum Hochladen', + style: TextStyle(color: pt.fgMuted, fontSize: 14), + ), + ], + ), + ), + ), + ), + ); + } +} + +class NoChatSelected extends StatelessWidget { + const NoChatSelected({super.key}); + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + return Container( + color: pt.bg1, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.forum_outlined, size: 64, color: pt.fgDim), + const SizedBox(height: 16), + Text( + 'Select a room to start chatting', + style: TextStyle(color: pt.fgMuted, fontSize: 16), + ), + const SizedBox(height: 8), + Text( + 'Choose from the sidebar on the left', + style: TextStyle(color: pt.fgDim, fontSize: 13), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/chat/view/chat_typing.dart b/lib/features/chat/view/chat_typing.dart new file mode 100644 index 0000000..7a1f83a --- /dev/null +++ b/lib/features/chat/view/chat_typing.dart @@ -0,0 +1,136 @@ +part of '../chat_view.dart'; + +class _TypingIndicator extends StatefulWidget { + final Room room; + final PyramidTheme pt; + const _TypingIndicator({required this.room, required this.pt}); + + @override + State<_TypingIndicator> createState() => _TypingIndicatorState(); +} + +class _TypingIndicatorState extends State<_TypingIndicator> { + StreamSubscription? _sub; + List _typing = []; + + StreamSubscription _listenTyping(Room room) { + // client.onSync fires after every sync — cheap to filter for typing events. + return room.client.onSync.stream.listen((_) { + if (!mounted) return; + final typing = room.typingUsers + .where((u) => u.id != room.client.userID) + .toList(); + if (typing.length != _typing.length || + !typing.every((u) => _typing.any((t) => t.id == u.id))) { + setState(() => _typing = typing); + } + }); + } + + @override + void initState() { + super.initState(); + _sub = _listenTyping(widget.room); + } + + @override + void didUpdateWidget(_TypingIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.room.id != widget.room.id) { + _sub?.cancel(); + setState(() => _typing = []); + _sub = _listenTyping(widget.room); + } + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_typing.isEmpty) return const SizedBox.shrink(); + final pt = widget.pt; + final names = _typing.map((u) => u.calcDisplayname()).toList(); + final label = names.length == 1 + ? '${names[0]} tippt…' + : names.length == 2 + ? '${names[0]} und ${names[1]} tippen…' + : '${names[0]} und ${names.length - 1} weitere tippen…'; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 0), + child: Row( + children: [ + _TypingDots(color: pt.fgDim), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + style: TextStyle(color: pt.fgDim, fontSize: 12, fontStyle: FontStyle.italic), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _TypingDots extends StatefulWidget { + final Color color; + const _TypingDots({required this.color}); + + @override + State<_TypingDots> createState() => _TypingDotsState(); +} + +class _TypingDotsState extends State<_TypingDots> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (context, child) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + // Each dot peaks at a different phase. + final phase = ((_ctrl.value * 3) - i).clamp(0.0, 1.0); + final opacity = (phase < 0.5 ? phase * 2 : (1 - phase) * 2).clamp(0.3, 1.0); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Opacity( + opacity: opacity, + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle), + ), + ), + ); + }), + ); + }, + ); + } +} diff --git a/lib/features/chat/view/chat_view_core.dart b/lib/features/chat/view/chat_view_core.dart new file mode 100644 index 0000000..b9a85fa --- /dev/null +++ b/lib/features/chat/view/chat_view_core.dart @@ -0,0 +1,521 @@ +part of '../chat_view.dart'; + +class ChatView extends ConsumerStatefulWidget { + final String roomId; + const ChatView({super.key, required this.roomId}); + + @override + ConsumerState createState() => _ChatViewState(); +} + +class _ChatViewState extends ConsumerState { + final _scrollCtrl = ScrollController(); + Event? _replyTo; + bool _loadingHistory = false; + bool _historyExhausted = false; + bool _draggingOver = false; + bool _pinnedOpen = false; + bool _profileExpanded = false; + bool _showScrollDown = false; + final Set _selectedIds = {}; + + // Unread state captured at session open (before marking as read) + String? _sessionFullyReadId; + bool _sessionHasUnread = false; + bool _sessionCaptured = false; + bool _didScrollToUnread = false; + bool _loadingFuture = false; + // Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom. + bool _unreadFading = false; + Timer? _unreadFadeTimer; + + // Jump-to-message (from search): scroll to + briefly highlight a target event. + final _jumpKey = GlobalKey(); + String? _jumpEventId; + String? _highlightEventId; + bool _highlightOn = false; + bool _jumpInProgress = false; + Timer? _highlightTimer; + + void _startJump(String eventId) { + _highlightTimer?.cancel(); + setState(() { + _jumpEventId = eventId; + _highlightEventId = eventId; + _highlightOn = true; + _jumpInProgress = true; + }); + // Consume the request so it doesn't re-fire. + ref.read(pendingJumpEventProvider.notifier).state = null; + WidgetsBinding.instance.addPostFrameCallback((_) => _attemptJump(0)); + } + + void _attemptJump(int attempt) { + if (!mounted || _jumpEventId == null) return; + final ctx = _jumpKey.currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + alignment: 0.4, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + _jumpInProgress = false; + // Hold the highlight briefly, then fade it out smoothly. + _highlightTimer?.cancel(); + _highlightTimer = Timer(const Duration(milliseconds: 1300), () { + if (!mounted) return; + setState(() => _highlightOn = false); // triggers the AnimatedOpacity fade + _highlightTimer = Timer(const Duration(milliseconds: 950), () { + if (mounted) setState(() { _highlightEventId = null; _jumpEventId = null; }); + }); + }); + return; + } + if (attempt >= 40) { + // Couldn't reach the event — give up gracefully (room is at least open). + setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; }); + return; + } + // Not built yet — load older history toward the target every few tries. + final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; + if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) { + timeline.requestHistory(historyCount: 100).catchError((_) {}); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1)); + }); + } + + // GlobalKey for the "Neue Nachrichten" divider — used by ensureVisible to + // scroll precisely to the divider on chat open. + final _unreadDividerKey = GlobalKey(); + + bool get _inSelectionMode => _selectedIds.isNotEmpty; + + void _toggleSelect(String eventId) { + setState(() { + if (_selectedIds.contains(eventId)) { + _selectedIds.remove(eventId); + } else { + _selectedIds.add(eventId); + } + }); + } + + void _clearSelection() => setState(() => _selectedIds.clear()); + + @override + void initState() { + super.initState(); + _scrollCtrl.addListener(_onScroll); + } + + @override + void didUpdateWidget(ChatView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.roomId != widget.roomId) { + _profileExpanded = false; + _sessionFullyReadId = null; + _sessionHasUnread = false; + _sessionCaptured = false; + _didScrollToUnread = false; + _loadingFuture = false; + _historyExhausted = false; + _unreadFading = false; + _unreadFadeTimer?.cancel(); + _jumpEventId = null; + _highlightEventId = null; + _highlightOn = false; + _jumpInProgress = false; + _highlightTimer?.cancel(); + // The GlobalKey stays alive — it'll simply have no context until the + // new room's divider (if any) is built. + } + } + + @override + void dispose() { + _unreadFadeTimer?.cancel(); + _highlightTimer?.cancel(); + _scrollCtrl.dispose(); + super.dispose(); + } + + void _captureAndMarkRead(Timeline timeline) { + if (!_sessionCaptured) { + final room = timeline.room; + final fullyRead = room.fullyRead; // "" if never set + final newFullyReadId = fullyRead.isNotEmpty ? fullyRead : null; + + // Show the divider only if there's a genuinely unread VISIBLE message + // FROM SOMEONE ELSE after the read marker. Own messages and hidden events + // (reactions, receipts, edits) don't count — otherwise the divider ends + // up below all messages (e.g. right after one's own latest message). + final me = room.client.userID; + final events = timeline.events; // newest-first + final frIndex = + fullyRead.isEmpty ? -1 : events.indexWhere((e) => e.eventId == fullyRead); + // Indices [0, upTo) are newer than the read marker. + final upTo = frIndex >= 0 ? frIndex : events.length; + var newHasUnread = false; + for (var i = 0; i < upTo; i++) { + final e = events[i]; + if (_MessageList._isVisible(e) && e.senderId != me) { + newHasUnread = true; + break; + } + } + + _sessionCaptured = true; + + // setState so _MessageList rebuilds with the captured unread marker. + if (mounted) { + setState(() { + _sessionFullyReadId = newFullyReadId; + _sessionHasUnread = newHasUnread; + }); + if (newHasUnread) { + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline)); + } + } + } + _markRead(timeline); + } + + void _scrollToUnread(Timeline timeline) { + if (_didScrollToUnread) return; + _attemptScrollToUnread(0); + } + + // The "Neue Nachrichten" divider may not be laid out yet on first open (lazy + // reverse ListView only builds visible + cacheExtent items). Retry until its + // context exists, nudging the scroll upward periodically so the list builds + // toward the divider when there are many unread messages. + void _attemptScrollToUnread(int attempt) { + if (_didScrollToUnread || !mounted) return; + final ctx = _unreadDividerKey.currentContext; + if (ctx != null) { + _didScrollToUnread = true; + // alignment 0.85: in a reverse list this lands the divider near the top + // of the viewport with a little read context above it and the unread + // messages filling the screen below. + Scrollable.ensureVisible( + ctx, + alignment: 0.85, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + return; + } + if (attempt >= 24) { + _didScrollToUnread = true; // give up — stay at the newest messages + return; + } + if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) { + final pos = _scrollCtrl.position; + final target = (pos.pixels + 1500).clamp(0.0, pos.maxScrollExtent); + if (target > pos.pixels) _scrollCtrl.jumpTo(target); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + Future.delayed(const Duration(milliseconds: 70), + () => _attemptScrollToUnread(attempt + 1)); + }); + } + + void _markRead([Timeline? tl]) { + // Cancel any pending notification for this room (user is now reading it). + cancelNotificationForRoom(widget.roomId); + + final sendReceipts = ref.read(privacyReadReceiptsProvider); + if (!sendReceipts) return; + final room = ref.read(roomProvider(widget.roomId)); + if (room == null) return; + final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull; + final latest = timeline?.events.firstOrNull; + if (latest != null && latest.status.isSynced) { + room.setReadMarker(latest.eventId, mRead: latest.eventId) + .catchError((_) {}); + } else { + room.markUnread(false).catchError((_) {}); + } + } + + Future _onScroll() async { + if (!_scrollCtrl.hasClients) return; + final pixels = _scrollCtrl.position.pixels; + final maxExtent = _scrollCtrl.position.maxScrollExtent; + + if (pixels < 60) { + _markRead(null); + // Reached the newest messages → the "Neue Nachrichten" divider is no + // longer useful. Fade it out, then remove it. + if (_sessionHasUnread && !_unreadFading) { + setState(() => _unreadFading = true); + _unreadFadeTimer?.cancel(); + _unreadFadeTimer = Timer(const Duration(milliseconds: 900), () { + if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; }); + }); + } + } + final shouldShow = pixels > 400; + if (shouldShow != _showScrollDown) { + setState(() => _showScrollDown = shouldShow); + } + + final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull; + if (timeline == null) return; + + // Backward pagination — older events near the top. We keep requesting until + // a request genuinely returns nothing new (real start of room), tracked via + // _historyExhausted, so we neither stop early nor hammer the server. + if (!_loadingHistory && + !_historyExhausted && + pixels >= maxExtent - 600) { + _loadingHistory = true; + final oldestBefore = timeline.events.isNotEmpty ? timeline.events.last.eventId : null; + final lenBefore = timeline.events.length; + try { + await timeline.requestHistory(historyCount: 60); + } catch (_) {} + final oldestAfter = timeline.events.isNotEmpty ? timeline.events.last.eventId : null; + // Nothing older arrived → we've reached the beginning of the room. + if (timeline.events.length == lenBefore && oldestBefore == oldestAfter) { + _historyExhausted = true; + } + await Future.delayed(const Duration(milliseconds: 200)); + _loadingHistory = false; + } + + // Forward pagination — only relevant after jumping to a historical anchor + // (e.g. from search). At the live edge canRequestFuture is false, so this + // won't fire during normal reading and can't pull the view back down. + if (!_loadingFuture && + timeline.canRequestFuture && + pixels < 200) { + _loadingFuture = true; + try { + await timeline.requestFuture(historyCount: 60); + } catch (_) {} + await Future.delayed(const Duration(milliseconds: 250)); + _loadingFuture = false; + } + } + + @override + Widget build(BuildContext context) { + final pt = PyramidTheme.of(context); + final room = ref.watch(roomProvider(widget.roomId)); + final timelineAsync = ref.watch(timelineProvider(widget.roomId)); + final membersOpen = ref.watch(membersPanelOpenProvider); + + // A search result requested a jump to a specific message. Using watch (not + // listen) so it also fires when this ChatView is freshly mounted for a + // DIFFERENT room — listen would miss a value set before registration. + final pendingJump = ref.watch(pendingJumpEventProvider); + if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && + !_jumpInProgress && + ref.read(pendingJumpEventProvider) == pendingJump) { + _startJump(pendingJump); + } + }); + } + + final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux; + final body = Stack( + fit: StackFit.expand, + children: [ + Row( + children: [ + Expanded( + child: Container( + color: pt.bg1, + child: Column( + children: [ + if (_inSelectionMode) + _SelectionHeader( + pt: pt, + count: _selectedIds.length, + selectedIds: Set.from(_selectedIds), + room: room, + timeline: ref.read(timelineProvider(widget.roomId)).valueOrNull, + currentUserId: room?.client.userID ?? '', + onClear: _clearSelection, + onReply: () { + final tl = ref.read(timelineProvider(widget.roomId)).valueOrNull; + if (tl == null) return; + try { + final event = tl.events.firstWhere((e) => e.eventId == _selectedIds.first); + setState(() { _replyTo = event; _selectedIds.clear(); }); + } catch (_) {} + }, + ) + else if (room != null && room.isDirectChat) + // DM: aufklappbarer Banner-Header — Avatar und Name + // skalieren mit, Banner und Bio blenden ein. + // ValueKey: pro Raum ein frischer State, damit beim + // schnellen Kanalwechsel keinerlei Daten (Avatar, + // Banner, laufende Futures) vom Vorgänger übrigbleiben. + _DmExpandingHeader( + key: ValueKey('dmheader:${room.id}'), + room: room, + roomId: widget.roomId, + pt: pt, + expanded: _profileExpanded, + onToggle: () => setState( + () => _profileExpanded = !_profileExpanded), + membersOpen: membersOpen, + pinnedOpen: _pinnedOpen, + onToggleMembers: () => ref + .read(membersPanelOpenProvider.notifier) + .update((s) => !s), + onOpenSearch: () => + ref.read(activeModalProvider.notifier).state = + ModalKind.search, + onTogglePinned: () => + setState(() => _pinnedOpen = !_pinnedOpen), + ) + else + _ChatHeader( + room: room, + roomId: widget.roomId, + pt: pt, + membersOpen: membersOpen, + pinnedOpen: _pinnedOpen, + onToggleMembers: () => ref + .read(membersPanelOpenProvider.notifier) + .update((s) => !s), + onOpenSearch: () => + ref.read(activeModalProvider.notifier).state = + ModalKind.search, + onTogglePinned: () => + setState(() => _pinnedOpen = !_pinnedOpen), + ), + const _ConnectionBanner(), + Expanded( + child: Stack( + children: [ + timelineAsync.when( + loading: () => Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: pt.accent, + ), + ), + error: (e, _) => Center( + child: Text( + 'Could not load messages', + style: TextStyle(color: pt.fgDim, fontSize: 14), + ), + ), + data: (timeline) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _captureAndMarkRead(timeline)); + return _MessageList( + timeline: timeline, + scrollCtrl: _scrollCtrl, + currentUserId: room?.client.userID ?? '', + pt: pt, + onReply: (event) => setState(() => _replyTo = event), + selectedIds: _selectedIds, + onToggleSelect: _toggleSelect, + sessionFullyReadId: _sessionFullyReadId, + sessionHasUnread: _sessionHasUnread, + unreadFading: _unreadFading, + unreadDividerKey: _sessionHasUnread ? _unreadDividerKey : null, + jumpEventId: _jumpEventId, + jumpKey: _jumpKey, + highlightEventId: _highlightEventId, + highlightOn: _highlightOn, + ); + }, + ), + if (_showScrollDown) + Positioned( + right: 16, + bottom: 12, + child: AnimatedOpacity( + opacity: _showScrollDown ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: GestureDetector( + onTap: () => _scrollCtrl.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: pt.bg0, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: pt.border), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(80), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted), + ), + ), + ), + ), + ], + ), + ), + if (room != null) _TypingIndicator(room: room, pt: pt), + if (room != null) + ChatComposer( + room: room, + replyTo: _replyTo, + onClearReply: () => setState(() => _replyTo = null), + ), + ], + ), + ), + ), + if (_pinnedOpen) + PinnedMessagesPanel( + roomId: widget.roomId, + onClose: () => setState(() => _pinnedOpen = false), + ), + ], + ), + // Drag-and-drop overlay — bleibt gemountet und blendet animiert + // ein/aus, damit der Übergang flüssig statt abrupt ist. + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + opacity: _draggingOver ? 1 : 0, + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + child: _DragOverlay(pt: pt, active: _draggingOver), + ), + ), + ), + ], + ); + + if (!isDesktop) return body; + + return DropTarget( + onDragEntered: (_) => setState(() => _draggingOver = true), + onDragExited: (_) => setState(() => _draggingOver = false), + onDragDone: (details) async { + setState(() => _draggingOver = false); + if (room == null) return; + final files = details.files.map((xf) => File(xf.path)).toList(); + if (files.isEmpty) return; + if (!mounted) return; + await AttachmentDialog.show(context, files, room, replyTo: _replyTo); + if (mounted) setState(() => _replyTo = null); + }, + child: body, + ); + } +}