refactor: Gott-Datei chat_view.dart in 6 Dateien aufgeteilt
Fünfter und letzter Durchgang der part/part-of-Aufteilung - reine Verschiebung, keine Logikänderung. chat_view.dart schrumpft von 2116 auf 33 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze. Damit sind alle 5 in der M2-Ist-Analyse gefundenen Gott-Dateien aufgeteilt; ROADMAP-Punkt "Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen" abgehakt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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<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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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';
|
||||
}
|
||||
}
|
||||
@@ -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<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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user