implemented chat long-polling and optimistic updates, centralized notification management, optimized avatar caching

This commit is contained in:
2026-05-10 15:47:55 +02:00
parent 6ae396e605
commit 1458d8ce49
15 changed files with 712 additions and 146 deletions
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -15,6 +17,8 @@ class ChatListBloc
extends
LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
bool _forceRenew = false;
Timer? _autoRefreshTimer;
Duration? _autoRefreshInterval;
@override
void retry() {
@@ -22,6 +26,27 @@ class ChatListBloc
super.retry();
}
@override
Future<void> close() {
_autoRefreshTimer?.cancel();
return super.close();
}
/// Sets (or clears) the recurring background refresh. Silent so the
/// loading bar doesn't blink several times a minute; pull-to-refresh
/// and tab-activation refreshes are non-silent for explicit feedback.
void setAutoRefreshInterval(Duration? interval) {
if (interval == _autoRefreshInterval) return;
_autoRefreshInterval = interval;
_autoRefreshTimer?.cancel();
_autoRefreshTimer = null;
if (interval == null) return;
_autoRefreshTimer = Timer.periodic(interval, (_) {
if (isClosed) return;
refresh(silent: true);
});
}
@override
ChatListRepository repository() => ChatListRepository();
@@ -51,8 +76,8 @@ class ChatListBloc
if (capturedError != null) throw capturedError!;
}
Future<void> refresh({bool renew = true}) async {
add(RefetchStarted<ChatListState>());
Future<void> refresh({bool renew = true, bool silent = false}) async {
if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError;
try {
final rooms = await repo.data.getRooms(
@@ -82,6 +107,64 @@ class ChatListBloc
await refresh();
}
/// Optimistically clears the unread counter for [token] so the tile
/// reacts before a refresh roundtrip lands. Server-side mark-as-read
/// is the caller's job (see [ChatBloc.sendServerReadMarker]).
void markRoomAsRead(String token, int lastMessageId) {
_mutateRoom(token, (r) {
if (r.unreadMessages == 0 && r.lastReadMessage >= lastMessageId) {
return false;
}
r.unreadMessages = 0;
r.unreadMention = false;
r.unreadMentionDirect = false;
if (lastMessageId > r.lastReadMessage) r.lastReadMessage = lastMessageId;
return true;
});
}
/// Pushes a freshly-received message into the matching room tile so the
/// list shows the right preview text + activity timestamp before the
/// next full refresh lands. Also clears unread because the long-poll
/// only feeds this in for an actively-open chat.
void applyIncomingMessage(String token, GetChatResponseObject message) {
_mutateRoom(token, (r) {
final wasRead =
r.unreadMessages == 0 && r.lastReadMessage >= message.id;
final hasNewer = r.lastMessage.id >= message.id;
if (wasRead && hasNewer) return false;
r.unreadMessages = 0;
r.unreadMention = false;
r.unreadMentionDirect = false;
if (message.id > r.lastReadMessage) r.lastReadMessage = message.id;
if (message.id > r.lastMessage.id) r.lastMessage = message;
if (message.timestamp > r.lastActivity) r.lastActivity = message.timestamp;
return true;
});
}
/// Mutates the room with [token] in-place via [mutator] (returning
/// true if anything changed) and re-emits if so. Builds a fresh
/// [GetRoomResponse] so equality-by-identity in the bloc state
/// recognises the change and rebuilds.
void _mutateRoom(
String token,
bool Function(GetRoomResponseObject room) mutator,
) {
final rooms = innerState?.rooms;
if (rooms == null) return;
var changed = false;
final updated = rooms.data.map((r) {
if (r.token != token) return r;
if (mutator(r)) changed = true;
return r;
}).toSet();
if (!changed) return;
final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
add(Emit((s) => s.copyWith(rooms: newRooms)));
_updateAppBadge(newRooms);
}
void _updateAppBadge(GetRoomResponse rooms) {
try {
final unread = rooms.data.fold<int>(