Files
pyramid/lib/features/chat/chat_view.dart
T
Bernd Steckmeister 057ac1e4ea style: verbleibende flutter-analyze Deprecations beheben
withOpacity/activeColor/cacheExtent/PublicRoomsChunk durch die neuen
Flutter-3.44-Äquivalente ersetzt.
2026-07-03 09:40:10 +02:00

2117 lines
75 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/rendering.dart' show ScrollCacheExtent;
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/attachment_dialog.dart';
import 'package:pyramid/features/chat/chat_composer.dart';
import 'package:pyramid/features/chat/chat_provider.dart';
import 'package:pyramid/features/chat/message_group.dart';
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
import 'package:pyramid/widgets/hover_region.dart';
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<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends ConsumerState<ChatView> {
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<String> _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<void> _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<void> _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<String, dynamic>;
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<CachedPresence>? _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<void> _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<void> _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<Event> onReply;
final Set<String> selectedIds;
final ValueChanged<String> onToggleSelect;
final String? sessionFullyReadId;
final bool sessionHasUnread;
final bool unreadFading;
/// Attached to the group-level _UnreadDivider so ensureVisible can scroll to it.
final GlobalKey? unreadDividerKey;
/// Jump-to-message target (from search): scroll to + highlight this event.
final String? jumpEventId;
final GlobalKey? jumpKey;
final String? highlightEventId;
final bool highlightOn;
const _MessageList({
required this.timeline,
required this.scrollCtrl,
required this.currentUserId,
required this.pt,
required this.onReply,
required this.selectedIds,
required this.onToggleSelect,
this.sessionFullyReadId,
this.sessionHasUnread = false,
this.unreadFading = false,
this.unreadDividerKey,
this.jumpEventId,
this.jumpKey,
this.highlightEventId,
this.highlightOn = false,
});
@override
Widget build(BuildContext context) {
// Dedupe by eventId — during concurrent forward/backward pagination the
// timeline can transiently contain the same event twice, which would
// produce duplicate ListView keys and crash the sliver (indexOf assertion).
final seenIds = <String>{};
final events = timeline.events
.where(_isVisible)
.where((e) => seenIds.add(e.eventId))
.toList();
if (events.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
const SizedBox(height: 16),
Text(
'No messages yet',
style: TextStyle(color: pt.fgMuted, fontSize: 14),
),
],
),
);
}
final groups = _buildGroups(events);
final hasUnread = sessionHasUnread;
// Resolve the effective fullyReadId: if the captured ID points to a
// non-visible event (reaction, edit, state) walk the timeline forward
// (toward older events) until we find the nearest visible one.
String? fullyReadId = sessionFullyReadId;
if (fullyReadId != null) {
final inGroups = groups.any((g) => g.any((e) => e.eventId == fullyReadId));
if (!inGroups) {
final tlEvents = timeline.events; // newest-first
final idx = tlEvents.indexWhere((e) => e.eventId == fullyReadId);
if (idx >= 0) {
fullyReadId = null;
for (int i = idx; i < tlEvents.length; i++) {
if (_isVisible(tlEvents[i])) {
fullyReadId = tlEvents[i].eventId;
break;
}
}
} else {
fullyReadId = null; // event not even in loaded timeline
}
}
}
// Read-receipt display — matching Element behaviour:
// • The newest unread own group shows ✓ (sent but not read)
// • The newest own group AT or BEFORE the partner's receipt shows ✓✓
// • If those are the same group → just ✓✓
final room = timeline.room;
// Read receipts (Element-style):
// • ✓✓ at the single newest event another member has read (any sender) —
// moves next to the partner's own reply when they reply/read everything.
// • grey ✓ only on my newest message while it's still unread by them.
// timeline.events is newest-first → lower index = newer.
//
// Receipts können je nach Client in `global` ODER `mainThread` stehen
// (threaded read receipts) — beide zusammenführen, neuere ts gewinnt.
final mergedReceipts = <String, LatestReceiptStateData>{};
for (final source in [
room.receiptState.global.otherUsers,
room.receiptState.mainThread?.otherUsers ?? const {},
]) {
source.forEach((userId, data) {
final existing = mergedReceipts[userId];
if (existing == null || data.ts > existing.ts) {
mergedReceipts[userId] = data;
}
});
}
int readIndex = -1;
int latestReceiptTs = 0;
mergedReceipts.forEach((userId, data) {
if (userId == currentUserId) return;
if (data.ts > latestReceiptTs) latestReceiptTs = data.ts;
var ri = timeline.events.indexWhere((e) => e.eventId == data.eventId);
// Receipt kann auf ein unsichtbares Event zeigen (Reaktion, Edit) —
// zum nächstälteren sichtbaren Event vorrücken, sonst geht der Marker
// verloren.
while (ri >= 0 &&
ri < timeline.events.length &&
!_isVisible(timeline.events[ri])) {
ri++;
}
if (ri >= timeline.events.length) ri = -1;
if (ri >= 0 && (readIndex < 0 || ri < readIndex)) readIndex = ri;
});
final readEventIds =
readIndex >= 0 ? {timeline.events[readIndex].eventId} : <String>{};
// Newest own visible message, and whether it's still unread (newer than the
// partner's read position, or no read position yet).
final newestOwnIndex =
timeline.events.indexWhere((e) => _isVisible(e) && e.senderId == currentUserId);
var ownUnread = newestOwnIndex >= 0 && (readIndex < 0 || newestOwnIndex < readIndex);
// Fallback: Das Receipt-Event liegt außerhalb des geladenen Fensters
// (z.B. sehr alte Nachricht) — wenn der Receipt-Zeitstempel jünger ist als
// meine neueste Nachricht, gilt sie trotzdem als gelesen.
if (ownUnread &&
readIndex < 0 &&
latestReceiptTs >=
timeline.events[newestOwnIndex].originServerTs.millisecondsSinceEpoch) {
readEventIds.add(timeline.events[newestOwnIndex].eventId);
ownUnread = false;
}
final lastOwnGroupKey = (ownUnread)
? () {
for (final g in groups) {
if (g.first.senderId == currentUserId) return 'g:${g.first.eventId}';
}
return null;
}()
: null;
// Flatten groups + date dividers + unread divider into independent list items.
// itemFullyReadIds[i] is non-null only for a group item that contains the
// fullyRead event at a non-last position — MessageGroup renders an inline
// divider after that event.
final itemKeys = <String>[];
final itemGroups = <List<Event>?>[];
final itemDates = <DateTime?>[];
final itemUnread = <bool>[];
final itemFullyReadIds = <String?>[];
bool unreadInserted = false;
for (var i = 0; i < groups.length; i++) {
final group = groups[i];
// Use the newest event (group.last after reversal) for date comparison.
final groupNewest = group.last.originServerTs;
final nextGroupNewest = i < groups.length - 1 ? groups[i + 1].last.originServerTs : null;
final isLastOfDay = nextGroupNewest == null || !_sameDay(groupNewest, nextGroupNewest);
// Check if this group contains the effective fullyRead event.
final groupContainsFullyRead = fullyReadId != null &&
group.any((e) => e.eventId == fullyReadId);
if (!unreadInserted && hasUnread && groupContainsFullyRead) {
if (group.last.eventId == fullyReadId) {
// fullyRead is the NEWEST event in the group → the whole group is
// read. Insert a group-level divider between this group and the
// newer (lower-index) groups.
itemKeys.add('unread');
itemGroups.add(null);
itemDates.add(null);
itemUnread.add(true);
itemFullyReadIds.add(null);
}
// Whether group-level or inline, we've now handled the marker.
unreadInserted = true;
}
// Key based on oldest event (group.first) — stable identifier.
itemKeys.add('g:${group.first.eventId}');
itemGroups.add(group);
itemDates.add(null);
itemUnread.add(false);
// Pass fullyReadId to the group only when the event is mid-group
// (not the last event) so MessageGroup can render an inline divider.
// groupContainsFullyRead already guarantees fullyReadId != null.
final passedFullyReadId =
(groupContainsFullyRead && group.last.eventId != fullyReadId)
? fullyReadId
: null;
itemFullyReadIds.add(passedFullyReadId);
if (isLastOfDay) {
final d = groupNewest;
itemKeys.add('d:${d.year}-${d.month}-${d.day}');
itemGroups.add(null);
itemDates.add(d);
itemUnread.add(false);
itemFullyReadIds.add(null);
}
}
// Fallback: fullyRead event is older than all loaded events — all visible
// messages are unread. Insert divider above the oldest loaded group (top of list).
if (!unreadInserted && hasUnread) {
itemKeys.add('unread');
itemGroups.add(null);
itemDates.add(null);
itemUnread.add(true);
itemFullyReadIds.add(null);
}
final keyIndex = <String, int>{
for (var i = 0; i < itemKeys.length; i++) itemKeys[i]: i,
};
return ListView.builder(
controller: scrollCtrl,
reverse: true,
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 8),
scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
itemCount: itemKeys.length,
findChildIndexCallback: (Key key) {
// Only return an index when the key still maps to that exact slot —
// returning a stale/duplicate index trips the sliver indexOf assertion.
if (key is ValueKey<String>) {
final idx = keyIndex[key.value];
if (idx != null && idx < itemKeys.length && itemKeys[idx] == key.value) {
return idx;
}
}
return null;
},
itemBuilder: (context, i) {
final key = ValueKey(itemKeys[i]);
if (itemUnread[i]) {
// Wrap in SizedBox so the list can use the ValueKey for optimisation
// while _UnreadDivider carries the GlobalKey for ensureVisible.
return SizedBox(
key: key,
child: _UnreadDivider(key: unreadDividerKey, pt: pt, fading: unreadFading),
);
}
final date = itemDates[i];
if (date != null) return DateDivider(key: key, date: date);
final group = itemGroups[i]!;
final groupKey = itemKeys[i];
// Show the sending/sent indicator on the newest own group.
final showStatus = groupKey == lastOwnGroupKey;
return MessageGroup(
key: key,
events: group,
currentUserId: currentUserId,
room: room,
timeline: timeline,
onReply: () => onReply(group.last),
selectedIds: selectedIds,
onToggleSelect: onToggleSelect,
showStatusIndicator: showStatus,
readEventIds: readEventIds,
fullyReadId: itemFullyReadIds[i],
jumpEventId: jumpEventId,
jumpKey: jumpKey,
highlightEventId: highlightEventId,
highlightOn: highlightOn,
);
},
);
}
List<List<Event>> _buildGroups(List<Event> events) {
final groups = <List<Event>>[];
List<Event>? current;
for (final event in events) {
if (current == null ||
current.first.senderId != event.senderId ||
event.originServerTs.difference(current.last.originServerTs).abs() >
const Duration(minutes: 5)) {
current = [event];
groups.add(current);
} else {
current.add(event);
}
}
// The timeline is newest-first, so each group is [newest, ..., oldest].
// Reverse each group so messages render oldest-to-newest (top to bottom).
return groups.map((g) => g.reversed.toList()).toList();
}
bool _sameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
static bool _isVisible(Event e) {
// NOTE: error-status events are kept visible so a failed send shows a
// "not sent" state with retry/delete instead of silently disappearing.
if (!{
EventTypes.Message,
EventTypes.Sticker,
EventTypes.Encrypted,
}.contains(e.type)) { return false; }
if ({
RelationshipTypes.edit,
RelationshipTypes.reaction,
}.contains(e.relationshipType)) { return false; }
if (e.type == EventTypes.Redaction) { return false; }
return true;
}
}
class _SelectionHeader extends StatelessWidget {
final PyramidTheme pt;
final int count;
final Set<String> selectedIds;
final Room? room;
final Timeline? timeline;
final String currentUserId;
final VoidCallback onClear;
final VoidCallback onReply;
const _SelectionHeader({
required this.pt,
required this.count,
required this.selectedIds,
required this.room,
required this.timeline,
required this.currentUserId,
required this.onClear,
required this.onReply,
});
List<Event> _resolveEvents() {
if (timeline == null) return [];
return selectedIds
.map((id) {
try { return timeline!.events.firstWhere((e) => e.eventId == id); } catch (_) { return null; }
})
.whereType<Event>()
.toList();
}
void _copyAll() {
final texts = _resolveEvents().map((e) => e.body).join('\n');
Clipboard.setData(ClipboardData(text: texts));
}
void _deleteAll(BuildContext context) {
for (final e in _resolveEvents()) {
if (e.senderId == currentUserId) {
e.redactEvent(reason: 'Vom Nutzer gelöscht').catchError((_) => null);
}
}
onClear();
}
void _pinAll() {
if (room == null) return;
final pinned = List<String>.from(
room!.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
);
for (final e in _resolveEvents()) {
if (!pinned.contains(e.eventId)) pinned.add(e.eventId);
}
room!.setPinnedEvents(pinned).catchError((_) => '');
onClear();
}
@override
Widget build(BuildContext context) {
final events = _resolveEvents();
final canDelete = events.isNotEmpty && events.every((e) => e.senderId == currentUserId);
final isTextOnly = events.every((e) => e.messageType == 'm.text');
return Container(
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: pt.accentSoft,
border: Border(bottom: BorderSide(color: pt.accent.withAlpha(80))),
),
child: Row(
children: [
IconButton(
onPressed: onClear,
icon: Icon(Icons.close_rounded, size: 20, color: pt.accent),
tooltip: 'Auswahl aufheben',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
const SizedBox(width: 4),
Text(
'$count ausgewählt',
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
),
const Spacer(),
if (count == 1)
_SelBtn(icon: Icons.reply_rounded, tooltip: 'Antworten', pt: pt, onTap: onReply),
if (isTextOnly)
_SelBtn(icon: Icons.copy_rounded, tooltip: 'Kopieren', pt: pt, onTap: _copyAll),
_SelBtn(icon: Icons.push_pin_outlined, tooltip: 'Anpinnen', pt: pt, onTap: _pinAll),
if (canDelete)
_SelBtn(
icon: Icons.delete_outline_rounded,
tooltip: 'Löschen',
pt: pt,
color: pt.danger,
onTap: () => _deleteAll(context),
),
],
),
);
}
}
class _SelBtn extends StatelessWidget {
final IconData icon;
final String tooltip;
final PyramidTheme pt;
final Color? color;
final VoidCallback onTap;
const _SelBtn({required this.icon, required this.tooltip, required this.pt, required this.onTap, this.color});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: onTap,
icon: Icon(icon, size: 20, color: color ?? pt.fg),
tooltip: tooltip,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
);
}
}
class _UnreadDivider extends StatelessWidget {
final PyramidTheme pt;
final bool fading;
const _UnreadDivider({super.key, required this.pt, this.fading = false});
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
opacity: fading ? 0.0 : 1.0,
duration: const Duration(milliseconds: 700),
curve: Curves.easeOut,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
child: Row(
children: [
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'Neue Nachrichten',
style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600),
),
),
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
],
),
),
);
}
}
// ─── 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<User> _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),
),
],
),
),
);
}
}