fix: Haken live, Einladung im Chat annehmen, Trenner verschwindet zuverlässig

Bernds Test mit der PC-Testinstanz:
- ✓✓ erschien erst nach erneutem Öffnen: eine Lesebestätigung ohne neue
  Nachricht zeichnete nichts neu (Raum-Update kommt im SDK vor der
  Bestätigung). Jetzt nach jedem Sync Fingerabdruck der Lesepositionen
  prüfen und bei Änderung neu zeichnen.
- Einladung: Chat zeigte leeren Verlauf, Annehmen ging nur in der Liste, und
  danach blieb der Chat leer bis zum App-Neustart. Jetzt Annehmen/Ablehnen
  direkt im Chat; Annehmen wartet auf den Beitritts-Sync, der Verlauf baut
  sich bei invite→join selbst neu auf.
- Trenner blieb stehen, wenn nach dem Öffnen/Zurückkommen nicht gescrollt
  wurde. Jetzt: neueste Nachrichten sichtbar (≤ halbe Bildschirmhöhe
  hochgescrollt) → nach 6 s weich ausblenden, auch ohne Scrollen.
- Live-Test mit beiden Testkonten (Gegenprobe: ohne Fix rot) in
  scripts/test.ps1 -Ui; Testlauf-Sperre erkennt nur noch eigene Testläufe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 23:33:59 +02:00
co-authored by Claude Opus 5.5
parent a6461fe23c
commit c817de924a
7 changed files with 479 additions and 16 deletions
+139
View File
@@ -151,3 +151,142 @@ class _FloatingComposerArea extends StatelessWidget {
);
}
}
/// Einladung in diesen Chat (z. B. aus einer Benachrichtigung geöffnet):
/// Annehmen/Ablehnen direkt hier statt eines leeren Verlaufs – vorher ging
/// das nur in der Chatliste (Bernd 2026-10-07).
class _InvitePanel extends ConsumerStatefulWidget {
final Room room;
final PyramidTheme pt;
const _InvitePanel({required this.room, required this.pt});
@override
ConsumerState<_InvitePanel> createState() => _InvitePanelState();
}
class _InvitePanelState extends ConsumerState<_InvitePanel> {
bool _busy = false;
String? _error;
String get _inviter {
final me = widget.room.client.userID;
final sender = me == null
? null
: widget.room.getState(EventTypes.RoomMember, me)?.senderId;
if (sender == null) return 'Jemand';
return widget.room.unsafeGetUserFromMemoryOrFallback(sender).calcDisplayname();
}
Future<void> _accept() async {
setState(() {
_busy = true;
_error = null;
});
try {
await acceptInvite(ref, widget.room);
} catch (_) {
if (mounted) {
setState(() {
_busy = false;
_error = 'Annehmen hat nicht geklappt – bitte noch einmal versuchen.';
});
}
}
}
Future<void> _decline() async {
setState(() {
_busy = true;
_error = null;
});
try {
await widget.room.leave();
if (mounted) ref.read(activeRoomIdProvider.notifier).state = null;
} catch (_) {
if (mounted) {
setState(() {
_busy = false;
_error = 'Ablehnen hat nicht geklappt – bitte noch einmal versuchen.';
});
}
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final isDm = widget.room.isDirectChat;
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isDm ? Icons.person_add_alt_1_rounded : Icons.group_add_rounded,
size: 48,
color: pt.accent,
),
const SizedBox(height: 16),
Text(
isDm
? '$_inviter möchte mit dir chatten'
: '$_inviter lädt dich in „${widget.room.getLocalizedDisplayname()}“ ein',
textAlign: TextAlign.center,
style: TextStyle(
color: pt.fg,
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Nach dem Annehmen siehst du die Nachrichten und kannst antworten.',
textAlign: TextAlign.center,
style: TextStyle(color: pt.fgMuted, fontSize: 13.5),
),
const SizedBox(height: 24),
if (_busy)
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5, color: pt.accent),
)
else
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: [
OutlinedButton(
onPressed: _decline,
style: OutlinedButton.styleFrom(
foregroundColor: pt.fgMuted,
side: BorderSide(color: pt.border),
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 14),
),
child: const Text('Ablehnen'),
),
FilledButton(
onPressed: _accept,
style: FilledButton.styleFrom(
backgroundColor: pt.accent,
foregroundColor: pt.accentFg,
padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 14),
),
child: const Text('Annehmen'),
),
],
),
if (_error != null) ...[
const SizedBox(height: 14),
Text(_error!,
textAlign: TextAlign.center,
style: TextStyle(color: pt.danger, fontSize: 13)),
],
],
),
),
);
}
}
+25 -12
View File
@@ -284,7 +284,7 @@ class _ChatViewState extends ConsumerState<ChatView> {
cancelNotificationForRoom(widget.roomId);
final room = ref.read(roomProvider(widget.roomId));
if (room == null) return;
if (room == null || room.membership != Membership.join) return;
if (room.markedUnread) room.markUnread(false).catchError((_) {});
final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
final latest = timeline?.events
@@ -326,14 +326,21 @@ class _ChatViewState extends ConsumerState<ChatView> {
_sessionHasUnread = true;
_unreadFading = false;
});
_scheduleUnreadFadeIfAtNewest();
}
}
if (_sessionHasUnread) _scheduleUnreadFadeIfAtNewest();
_markRead(timeline);
}
bool get _atNewest =>
!_scrollCtrl.hasClients || _scrollCtrl.position.pixels < 60;
// Neueste Nachrichten sind zu sehen: höchstens eine halbe Bildschirmhöhe
// hochgescrollt. (Nach dem Sprung zum Trenner steht die Liste meist ein
// Stück oberhalb des Endes – mit der strengen 60-px-Grenze blieb der
// Trenner dann für immer stehen.)
bool get _atNewest {
if (!_scrollCtrl.hasClients) return true;
final pos = _scrollCtrl.position;
return pos.pixels < 60 || pos.pixels < pos.viewportDimension * 0.5;
}
/// „Neue Nachrichten“-Trenner nach [_unreadDwell] weich ausblenden – nur
/// wenn man dann noch bei den neuesten Nachrichten ist. Wer zwischendurch
@@ -342,7 +349,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
if (!_sessionHasUnread || _unreadFading) return;
if (_unreadDwellTimer?.isActive ?? false) return;
_unreadDwellTimer = Timer(_unreadDwell, () {
if (!mounted || !_sessionHasUnread || !_atNewest) return;
if (!mounted || !_sessionHasUnread) return;
// App im Hintergrund oder weggescrollt: stehen lassen, beim nächsten
// Scrollen/Zurückkommen beginnt die Wartezeit neu.
if (!_atNewest || !ref.read(windowFocusedProvider)) return;
setState(() => _unreadFading = true);
_unreadFadeTimer?.cancel();
_unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
@@ -365,12 +375,10 @@ class _ChatViewState extends ConsumerState<ChatView> {
final pixels = _scrollCtrl.position.pixels;
final maxExtent = _scrollCtrl.position.maxScrollExtent;
if (pixels < 60) {
_markRead(null);
// Bei den neuesten Nachrichten angekommen → Trenner nach einer
// Verweildauer ausblenden.
_scheduleUnreadFade();
}
if (pixels < 60) _markRead(null);
// Bei den neuesten Nachrichten angekommen → Trenner nach einer
// Verweildauer ausblenden.
if (_atNewest) _scheduleUnreadFade();
final shouldShow = pixels > 400;
if (shouldShow != _showScrollDown) {
setState(() => _showScrollDown = shouldShow);
@@ -439,6 +447,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
}
final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
// Noch nicht beigetreten (Einladung): statt eines leeren Verlaufs gleich
// hier Annehmen/Ablehnen anbieten.
final isInvite = room?.membership == Membership.invite;
final body = Stack(
fit: StackFit.expand,
children: [
@@ -510,7 +521,9 @@ class _ChatViewState extends ConsumerState<ChatView> {
),
const _ConnectionBanner(),
Expanded(
child: Stack(
child: isInvite
? _InvitePanel(room: room!, pt: pt)
: Stack(
children: [
timelineAsync.when(
loading: () => Center(