diff --git a/lib/api/marianumcloud/talk/chat/long_poll_chat.dart b/lib/api/marianumcloud/talk/chat/long_poll_chat.dart new file mode 100644 index 0000000..80a58aa --- /dev/null +++ b/lib/api/marianumcloud/talk/chat/long_poll_chat.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../../../errors/network_exception.dart'; +import '../../../errors/server_exception.dart'; +import '../../nextcloud_ocs.dart'; +import 'get_chat_params.dart'; +import 'get_chat_response.dart'; + +/// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi] +/// because that layer treats non-2xx as errors, and we need 304 to be a +/// normal "no new messages" outcome. `setReadMarker=on` lets the server +/// move the read cursor whenever the call returns messages. +class LongPollChat { + final String chatToken; + final int lastKnownMessageId; + final int timeoutSeconds; + + LongPollChat({ + required this.chatToken, + required this.lastKnownMessageId, + this.timeoutSeconds = 30, + }); + + /// Returns the response, or `null` on HTTP 304 (server timeout, nothing new). + Future run() async { + final params = GetChatParams( + lookIntoFuture: GetChatParamsSwitch.on, + timeout: timeoutSeconds, + lastKnownMessageId: lastKnownMessageId, + includeLastKnown: GetChatParamsSwitch.off, + setReadMarker: GetChatParamsSwitch.on, + limit: 100, + ); + final uri = NextcloudOcs.uri( + 'apps/spreed/api/v1/chat/$chatToken', + queryParameters: params.toJson(), + ); + final headers = NextcloudOcs.headers(); + + final http.Response response; + try { + response = await http + .get(uri, headers: headers) + .timeout(Duration(seconds: timeoutSeconds + 15)); + } on TimeoutException catch (e) { + throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e'); + } on SocketException catch (e) { + throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}'); + } on http.ClientException catch (e) { + throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}'); + } + + final status = response.statusCode; + if (status == 304) return null; + if (status >= 200 && status < 300) { + final decoded = jsonDecode(response.body) as Map; + return GetChatResponse.fromJson(decoded['ocs'] as Map) + ..headers = response.headers; + } + throw ServerException( + statusCode: status, + technicalDetails: 'LongPollChat $uri: HTTP $status', + ); + } +} diff --git a/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart index 847a96d..85da8d5 100644 --- a/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart @@ -26,11 +26,5 @@ class SetReadMarker extends TalkApi { Uri uri, Object? body, Map? headers, - ) { - if (readState) { - return http.post(uri, headers: headers); - } else { - return http.delete(uri, headers: headers); - } - } + ) => readState ? http.post(uri, headers: headers) : http.delete(uri, headers: headers); } diff --git a/lib/app.dart b/lib/app.dart index 7a9b2f6..94cb313 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -36,7 +36,6 @@ class App extends StatefulWidget { } class _AppState extends State with WidgetsBindingObserver { - late Timer _refetchChats; late Timer _updateTimings; StreamSubscription? _timetableWidgetSync; // Tracked via the bottom-nav controller's listener so it always reflects the @@ -45,8 +44,25 @@ class _AppState extends State with WidgetsBindingObserver { int _knownTotalTabs = 1; bool _userOnLastTab = false; + static const Duration _chatListActiveInterval = Duration(seconds: 15); + static const Duration _chatListIdleInterval = Duration(seconds: 60); + void _onTabControllerChanged() { _userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1; + _syncChatListPolling(); + } + + void _syncChatListPolling() { + if (!mounted) return; + final modules = AppModule.getBottomBarModules(context); + final talkSlot = modules.indexWhere((m) => m.module == Modules.talk); + final talkIsActive = + talkSlot >= 0 && Main.bottomNavigator.index == talkSlot; + final bloc = context.read(); + bloc.setAutoRefreshInterval( + talkIsActive ? _chatListActiveInterval : _chatListIdleInterval, + ); + if (talkIsActive) bloc.refresh(); } @override @@ -138,19 +154,13 @@ class _AppState extends State with WidgetsBindingObserver { ShareIntentListener.instance.attach(); ShareIntentListener.pending.addListener(_handlePendingShare); _handlePendingShare(); + _syncChatListPolling(); }); _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { if (mounted) setState(() {}); }); - _refetchChats = Timer.periodic(const Duration(seconds: 60), (_) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - context.read().refresh(); - }); - }); - UpdateUserIndex.index(); if (context.read().val().notificationSettings.enabled) { @@ -181,7 +191,6 @@ class _AppState extends State with WidgetsBindingObserver { @override void dispose() { - _refetchChats.cancel(); _updateTimings.cancel(); _timetableWidgetSync?.cancel(); ShareIntentListener.pending.removeListener(_handlePendingShare); diff --git a/lib/main.dart b/lib/main.dart index f003df9..8ea5ab0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,7 @@ import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; import 'model/account_data.dart'; +import 'routing/app_routes.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; @@ -153,7 +154,9 @@ Future main() async { ), BlocProvider(create: (_) => BreakerBloc()), BlocProvider(create: (_) => ChatListBloc()), - BlocProvider(create: (_) => ChatBloc()), + BlocProvider( + create: (ctx) => ChatBloc(chatListBloc: ctx.read()), + ), BlocProvider(create: (_) => TimetableBloc()), ], child: const Main(), @@ -199,6 +202,8 @@ class _MainState extends State
{ checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages, debugShowCheckedModeBanner: false, + // Used by ChatView.didPopNext to reclaim the global ChatBloc. + navigatorObservers: [AppRoutes.chatRouteObserver], localizationsDelegates: const [ ...GlobalMaterialLocalizations.delegates, GlobalWidgetsLocalizations.delegate, diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index 88cf348..e8a7a2c 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -1,11 +1,19 @@ +import 'dart:async'; + import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../widget/debug/debug_tile.dart'; import '../widget/debug/json_viewer.dart'; import '../widget/info_dialog.dart'; import 'notification_tasks.dart'; +// `vm:entry-point` on the class so AOT tree-shaking doesn't drop it: the +// FCM background handler runs in a fresh isolate that looks up the class +// by name from native code. +@pragma('vm:entry-point') class NotificationController { // Notification display is handled by the Firebase SDK using server-generated payloads. @pragma('vm:entry-point') @@ -17,8 +25,21 @@ class NotificationController { RemoteMessage message, BuildContext context, ) async { - NotificationTasks.updateProviders(context); + final pushToken = _extractChatToken(message); + final activeToken = context.read().state.data?.currentToken ?? ''; + final chatIsOpen = + pushToken != null && pushToken.isNotEmpty && pushToken == activeToken; + NotificationTasks.updateBadgeCount(message); + + if (chatIsOpen) { + // Long-poll already fetches the message and moves the marker; just + // dismiss any tray entry that slipped through foreground rendering. + unawaited(NotificationTasks.clearNotificationsForChat(pushToken)); + return; + } + + NotificationTasks.updateProviders(context); } static Future onAppOpenedByNotification( diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart index 9a2f167..12dc16d 100644 --- a/lib/notification/notification_tasks.dart +++ b/lib/notification/notification_tasks.dart @@ -1,11 +1,15 @@ +import 'dart:developer'; + +import 'package:eraser/eraser.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../routing/app_routes.dart'; -import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import 'notification_service.dart'; class NotificationTasks { static void updateBadgeCount(RemoteMessage notification) { @@ -14,9 +18,43 @@ class NotificationTasks { ); } + /// Per-chat tag scheme. MUST match the Notify backend, which sets this + /// value on `AndroidNotification.setTag` AND `apns-collapse-id`. + static String chatTag(String chatToken) => 'talk_$chatToken'; + + /// Removes tray notifications belonging to [chatToken]. Eraser handles + /// iOS (where the plugin's `getActiveNotifications` returns null ids + /// for FCM posts and can't cancel them); the local-notifications sweep + /// handles Android and acts as a fallback while Eraser's native side + /// isn't built in yet. + static Future clearNotificationsForChat(String chatToken) async { + final tag = chatTag(chatToken); + try { + await Eraser.clearAppNotificationsByTag(tag); + } on MissingPluginException { + // Eraser native code not yet linked — needs flutter clean + run. + } on Object catch (e) { + log('Eraser($tag) failed: $e'); + } + try { + final plugin = NotificationService().flutterLocalNotificationsPlugin; + final actives = await plugin.getActiveNotifications(); + for (final n in actives) { + final id = n.id; + if (id == null) continue; + if (n.tag == tag) await plugin.cancel(id: id, tag: n.tag); + } + } on Object catch (e) { + log('Active-notification sweep failed: $e'); + } + } + + /// Refreshes the chat list. Deliberately does NOT touch [ChatBloc] — + /// the open chat view manages its own state via long-poll, and refreshing + /// it here would re-fetch the last-opened chat with setReadMarker=on + /// even if the user has already left. static void updateProviders(BuildContext context) { context.read().refresh(); - context.read().refresh(); } /// Switches to the Talk tab. If [chatToken] is provided, also schedules diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 804d94e..69803d6 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -6,6 +6,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../main.dart'; import '../model/account_data.dart'; +import '../notification/notification_tasks.dart'; import '../share_intent/pending_share.dart'; import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/app_modules.dart'; @@ -39,6 +40,11 @@ class AppRoutes { /// by `ChatList` once the matching room is loaded. static final ValueNotifier pendingChatToken = ValueNotifier(null); + /// Root-navigator observer used by [ChatView] to reclaim the global + /// [ChatBloc] on `didPopNext` after a stacked chat is popped. + static final RouteObserver> chatRouteObserver = + RouteObserver>(); + static void openFolder(BuildContext context, List path) { pushScreen(context, withNavBar: false, screen: Files(path: path)); } @@ -177,6 +183,12 @@ class AppRoutes { required UserAvatar avatar, bool overrideToSingleSubScreen = true, }) { + // Local mark only. Server-side mark is sent later from + // ChatBloc._loadChat with the freshly-fetched maxId — sending one + // here too with the chat list's possibly-stale room.lastMessage.id + // would race the fresh one and could regress the server cursor. + context.read().markRoomAsRead(room.token, room.lastMessage.id); + NotificationTasks.clearNotificationsForChat(room.token); TalkNavigator.pushSplitView( context, ChatView(room: room, selfId: selfId, avatar: avatar), diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index a79d169..7a6422f 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -1,15 +1,54 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + import '../../../../../api/errors/error_mapper.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart'; +import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; +import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart'; import '../../../infrastructure/loadable_state/loading_error.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../../chat_list/bloc/chat_list_bloc.dart'; import '../repository/chat_repository.dart'; import 'chat_event.dart'; import 'chat_state.dart'; class ChatBloc - extends LoadableHydratedBloc { + extends LoadableHydratedBloc + with WidgetsBindingObserver { + final ChatListBloc? _chatListBloc; + + String? _pollingToken; + int _backoffMs = 0; + int _lastKnownMessageId = 0; + bool _appResumed = true; + + /// Distinguishes "the bloc tracks a chat the user has open" from "the + /// bloc remembers the last opened chat". App-resume only refreshes when + /// true — otherwise we'd silently mark a long-since-left chat as read + /// on the server. Can't reuse `currentToken` for this signal because + /// clearing it on leaveChat raced with setToken-from-didPopNext when + /// popping a stacked chat. + bool _chatViewActive = false; + DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0); + ChatBloc({ChatListBloc? chatListBloc}) : _chatListBloc = chatListBloc { + WidgetsBinding.instance.addObserver(this); + } + + @override + Future close() { + WidgetsBinding.instance.removeObserver(this); + _stopLongPoll(); + return super.close(); + } + @override ChatRepository repository() => ChatRepository(); @@ -33,24 +72,74 @@ class ChatBloc } void setToken(String token) { + _chatViewActive = true; if (token == (innerState?.currentToken ?? '')) { refresh(); return; } + _stopLongPoll(); add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null))); add(RefetchStarted()); - _loadChat(token); - } - - void setReferenceMessageId(int? messageId) { - add(Emit((s) => s.copyWith(referenceMessageId: messageId))); + _scheduleLoad(token); } void refresh() { final token = innerState?.currentToken ?? ''; if (token.isEmpty) return; add(RefetchStarted()); - _loadChat(token); + _scheduleLoad(token); + } + + void setReferenceMessageId(int? messageId) { + add(Emit((s) => s.copyWith(referenceMessageId: messageId))); + } + + /// Token-aware: only acts when the bloc still points at [fromToken]. + /// When popping a stacked chat (notification opened B over A), A's + /// didPopNext has already run setToken(A) by the time B's dispose + /// fires — at that point currentToken is A and we must leave it alone. + void leaveChat(String fromToken) { + if ((innerState?.currentToken ?? '') != fromToken) return; + _chatViewActive = false; + _stopLongPoll(); + } + + /// Fire-and-forget server-side read-marker. Exposed so view-side + /// callers (long-press menu, ChatView dispose) hit the same path. + Future sendServerReadMarker(String token, int messageId) async { + try { + await SetReadMarker( + token, + true, + setReadMarkerParams: SetReadMarkerParams(lastReadMessage: messageId), + ).run(); + } on Object catch (e) { + log('Server read-marker for $token failed: $e'); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final wasResumed = _appResumed; + _appResumed = state == AppLifecycleState.resumed; + if (!_appResumed) { + _stopLongPoll(); + return; + } + if (wasResumed) return; + final token = innerState?.currentToken ?? ''; + if (token.isNotEmpty && _chatViewActive) refresh(); + } + + /// Defer _loadChat by one microtask so the Bloc worker processes the + /// preceding Emit/RefetchStarted before any cache/network callback + /// fires — otherwise a quick cache hit can run with the previous + /// token in state, fail stillCurrent(), and never emit a DataGathered. + void _scheduleLoad(String token) { + Future.microtask(() { + if (isClosed) return; + _loadChat(token).then((_) => _startLongPoll(token)); + }); } Future _loadChat(String token) async { @@ -69,14 +158,21 @@ class ChatBloc token: token, onCacheData: (data) { if (!stillCurrent()) return; - // Cache hit: show data immediately but preserve lastFetch — the - // cached payload may be stale and we don't want the UI to claim a - // fresh fetch just happened. + // Only paint cache when the state is empty — restoring a stale + // disk snapshot over already-merged long-poll data would visibly + // drop those messages until the network call resolves. + if (innerState?.chatResponse != null) return; add(Emit((s) => s.copyWith(chatResponse: data))); }, onNetworkData: (data) { + // Server-side mark runs unconditionally with the freshly-fetched + // maxId. Skipping it on stillCurrent==false would leave the + // server cursor wherever a quick navigation away left it. + final maxId = _maxMessageId(data); + if (maxId > 0) unawaited(sendServerReadMarker(token, maxId)); if (!stillCurrent()) return; - add(DataGathered((s) => s.copyWith(chatResponse: data))); + _applyChatResponse(data); + if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId); }, onError: (e) => capturedError = e, ); @@ -98,4 +194,115 @@ class ChatBloc ); } } + + // --------------------------------------------------------------------------- + // Long-poll loop + // --------------------------------------------------------------------------- + + void _startLongPoll(String token) { + if (!_appResumed) return; + if (_pollingToken == token) return; + _stopLongPoll(); + _pollingToken = token; + _backoffMs = 0; + _lastKnownMessageId = _maxMessageId(innerState?.chatResponse); + unawaited(_pollLoop(token)); + } + + void _stopLongPoll() { + _pollingToken = null; + _backoffMs = 0; + } + + Future _pollLoop(String token) async { + while (_pollingToken == token && !isClosed) { + try { + final response = await LongPollChat( + chatToken: token, + lastKnownMessageId: _lastKnownMessageId, + ).run(); + + if (_pollingToken != token || isClosed) return; + _backoffMs = 0; + + if (response == null) continue; + + final headerId = int.tryParse( + response.headers?[_kLongPollLastGivenHeader] ?? '', + ); + if (headerId != null && headerId > _lastKnownMessageId) { + _lastKnownMessageId = headerId; + } + + if (response.data.isEmpty) continue; + _applyChatResponse(response); + final maxId = _maxMessageId(response); + if (maxId > _lastKnownMessageId) _lastKnownMessageId = maxId; + // Long-poll's setReadMarker=on already moved the server cursor; + // mirror locally. + final preview = _pickDisplayMessage(response); + if (preview != null) { + _chatListBloc?.applyIncomingMessage(token, preview); + } else { + _chatListBloc?.markRoomAsRead(token, _lastKnownMessageId); + } + } on Object catch (e) { + if (_pollingToken != token || isClosed) return; + log('LongPoll error for $token: $e'); + _backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000); + await Future.delayed(Duration(milliseconds: _backoffMs)); + } + } + } + + /// Merges [incoming] into the existing chatResponse and emits as a + /// fresh fetch. Dedups by id (newer wins, so server edits/deletes + /// propagate). Shared by initial-load and long-poll so neither wipes + /// messages the other already committed. + void _applyChatResponse(GetChatResponse incoming) { + final current = innerState?.chatResponse; + if (current == null) { + add(DataGathered((s) => s.copyWith(chatResponse: incoming))); + return; + } + final byId = {}; + for (final m in current.data) { + byId[m.id] = m; + } + for (final m in incoming.data) { + byId[m.id] = m; + } + final merged = GetChatResponse(byId.values.toSet()) + ..headers = incoming.headers; + add(DataGathered((s) => s.copyWith(chatResponse: merged))); + } + + int _maxMessageId(GetChatResponse? response) { + if (response == null) return 0; + var max = 0; + for (final m in response.data) { + if (m.id > max) max = m.id; + } + return max; + } + + /// Highest-id message worth showing as the room preview — comments + /// and voice messages, matching what the server picks for `lastMessage`. + GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) { + GetChatResponseObject? best; + for (final m in response.data) { + switch (m.messageType) { + case GetRoomResponseObjectMessageType.comment: + case GetRoomResponseObjectMessageType.voiceMessage: + if (best == null || m.id > best.id) best = m; + case GetRoomResponseObjectMessageType.deletedComment: + case GetRoomResponseObjectMessageType.system: + case GetRoomResponseObjectMessageType.command: + break; + } + } + return best; + } } + +const _kLongPollLastGivenHeader = 'x-chat-last-given'; diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index d05895d..0c4a353 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -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 { bool _forceRenew = false; + Timer? _autoRefreshTimer; + Duration? _autoRefreshInterval; @override void retry() { @@ -22,6 +26,27 @@ class ChatListBloc super.retry(); } + @override + Future 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 refresh({bool renew = true}) async { - add(RefetchStarted()); + Future refresh({bool renew = true, bool silent = false}) async { + if (!silent) add(RefetchStarted()); 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( diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 0eb72c8..d249b64 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -7,7 +7,6 @@ import '../../../notification/notify_updater.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; -import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -19,15 +18,13 @@ import 'search_chat.dart'; import 'widgets/chat_tile.dart'; import 'widgets/split_view_placeholder.dart'; +// Reads from the global ChatListBloc in main.dart — re-providing a local +// one here would shadow it and split the state in two. class ChatList extends StatelessWidget { const ChatList({super.key}); @override - Widget build(BuildContext context) => - BlocModule>( - create: (_) => ChatListBloc(), - child: (context, bloc, _) => const _ChatListView(), - ); + Widget build(BuildContext context) => const _ChatListView(); } class _ChatListView extends StatefulWidget { @@ -65,14 +62,6 @@ class _ChatListViewState extends State<_ChatListView> { final resolved = AppRoutes.resolvePendingChat(context); if (resolved == null) return; AppRoutes.pendingChatToken.value = null; - - // Replace any chat already pushed on top of the chat list so a freshly - // tapped notification doesn't stack indefinitely on previous chats. - final navigator = Navigator.of(context); - if (navigator.canPop()) { - navigator.popUntil((route) => route.isFirst); - } - AppRoutes.openChatView( context, room: resolved.room, @@ -193,7 +182,14 @@ class _ChatListViewState extends State<_ChatListView> { .talkSettings .drafts .containsKey(room.token); - return ChatTile(data: room, hasDraft: hasDraft); + // Stable key keeps element identity across re-sorts so the + // inner UserAvatar reuses its cached bytes instead of + // flashing on every list update. + return ChatTile( + key: ValueKey(room.token), + data: room, + hasDraft: hasDraft, + ); }).toList(), ); }, diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index ff92ecc..900b222 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -7,9 +8,12 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../extensions/date_time.dart'; +import '../../../notification/notification_tasks.dart'; +import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; +import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../theming/app_theme.dart'; import '../../../widget/clickable_app_bar.dart'; import '../../../widget/user_avatar.dart'; @@ -36,7 +40,7 @@ class ChatView extends StatefulWidget { State createState() => _ChatViewState(); } -class _ChatViewState extends State { +class _ChatViewState extends State with RouteAware { final ItemScrollController _itemScrollController = ItemScrollController(); final TextEditingController _searchTextController = TextEditingController(); final Map _matchIndices = {}; @@ -48,12 +52,73 @@ class _ChatViewState extends State { GetChatResponse? _matchesComputedFor; String? _matchesComputedQuery; + // Captured in initState because the framework has unmounted us by the + // time dispose runs. + ChatBloc? _chatBlocRef; + ChatListBloc? _chatListBlocRef; + PageRoute? _subscribedRoute; + + @override + void initState() { + super.initState(); + _chatBlocRef = context.read(); + _chatListBlocRef = context.read(); + NotificationTasks.clearNotificationsForChat(widget.room.token); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is PageRoute && route != _subscribedRoute) { + if (_subscribedRoute != null) { + AppRoutes.chatRouteObserver.unsubscribe(this); + } + AppRoutes.chatRouteObserver.subscribe(this, route); + _subscribedRoute = route; + } + } + + @override + void didPopNext() { + super.didPopNext(); + // A stacked chat above us was just popped (typical: notification tap + // opened another chat). The global ChatBloc currently points at that + // other chat's token, so our isReady predicate fails until we re-claim. + _chatBlocRef?.setToken(widget.room.token); + } + @override void dispose() { + if (_subscribedRoute != null) { + AppRoutes.chatRouteObserver.unsubscribe(this); + } + _markAsReadFinal(); + _chatBlocRef?.leaveChat(widget.room.token); _searchTextController.dispose(); super.dispose(); } + /// Defensive final mark-as-read so a back-out before the long-poll + /// could fire doesn't leave the room as unread. Skipped when the bloc + /// has already moved on to another chat — the response data there + /// belongs to a different room, and writing its max-id as our marker + /// would regress our server cursor. + void _markAsReadFinal() { + final state = _chatBlocRef?.state.data; + if (state == null) return; + if (state.currentToken != widget.room.token) return; + final response = state.chatResponse; + if (response == null) return; + var maxId = 0; + for (final m in response.data) { + if (m.id > maxId) maxId = m.id; + } + if (maxId == 0) return; + _chatListBlocRef?.markRoomAsRead(widget.room.token, maxId); + unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId)); + } + @override void didUpdateWidget(covariant ChatView oldWidget) { super.didUpdateWidget(oldWidget); diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index bea3098..67a413b 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -7,9 +7,10 @@ import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; -import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart'; import '../../../../extensions/date_time.dart'; import '../../../../model/account_data.dart'; +import '../../../../notification/notification_tasks.dart'; +import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../widget/async_action_button.dart'; @@ -17,7 +18,6 @@ import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/user_avatar.dart'; -import '../chat_view.dart'; import '../talk_navigator.dart'; class ChatTile extends StatefulWidget { @@ -61,13 +61,11 @@ class _ChatTileState extends State { void _refreshList() => context.read().refresh(); Future _setCurrentAsRead() async { - await SetReadMarker( - widget.data.token, - true, - setReadMarkerParams: SetReadMarkerParams( - lastReadMessage: widget.data.lastMessage.id, - ), - ).run(); + final token = widget.data.token; + final lastId = widget.data.lastMessage.id; + context.read().markRoomAsRead(token, lastId); + unawaited(NotificationTasks.clearNotificationsForChat(token)); + await context.read().sendServerReadMarker(token, lastId); if (!mounted) return; _refreshList(); } @@ -154,18 +152,17 @@ class _ChatTileState extends State { return; } if (selfUsername == null) return; - unawaited(_setCurrentAsRead()); - final view = ChatView( + // openChatView is the single entry point for opening a chat — + // it handles optimistic mark-as-read, tray cleanup, push, and + // setToken in one place so the notification-tap path gets the + // same treatment as a tile tap. + AppRoutes.openChatView( + context, room: widget.data, selfId: selfUsername!, avatar: circleAvatar, - ); - TalkNavigator.pushSplitView( - context, - view, overrideToSingleSubScreen: true, ); - context.read().setToken(widget.data.token); }, onLongPress: () { if (widget.disableContextActions) return; diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 61d08c0..2931630 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -37,40 +37,10 @@ class AppointmentTile extends StatelessWidget { borderRadius: _radius, color: color, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.max, - children: [ - _AdaptiveTitle( - text: appointment.subject, - fontSize: kAppointmentTitleFontSize, - minFontSize: kAppointmentTitleMinFontSize, - fontWeight: FontWeight.w500, - ), - if (isCustom) ...[ - if (description.isNotEmpty) - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 1), - child: _WrappingBody( - text: description, - fontSize: kAppointmentBodyFontSize, - lineHeight: kAppointmentBodyLineHeight, - ), - ), - ), - ] else ...[ - for (final line - in description - .split('\n') - .where((p) => p.isNotEmpty) - .take(2)) - _ScaledLine( - text: line, - fontSize: kAppointmentBodyFontSize, - ), - ], - ], + child: _TileContent( + title: appointment.subject, + description: description, + isCustom: isCustom, ), ), ), @@ -96,6 +66,91 @@ class AppointmentTile extends StatelessWidget { } } +/// Picks how many lines fit into the calendar slot's height. Title gets +/// first dibs; if not even one minimum-size title line fits, the column +/// collapses to keep the slot from overflowing. +class _TileContent extends StatelessWidget { + final String title; + final String description; + final bool isCustom; + + const _TileContent({ + required this.title, + required this.description, + required this.isCustom, + }); + + @override + Widget build(BuildContext context) { + final scaler = MediaQuery.textScalerOf(context); + final titleLineHeight = scaler.scale(kAppointmentTitleMinFontSize) * 1.1; + final bodyLineHeight = scaler.scale(kAppointmentBodyFontSize) * 1.1; + + final titleWidget = _AdaptiveTitle( + text: title, + fontSize: kAppointmentTitleFontSize, + minFontSize: kAppointmentTitleMinFontSize, + fontWeight: FontWeight.w500, + ); + + return LayoutBuilder( + builder: (context, constraints) { + final available = constraints.maxHeight; + // Slot too short for even one min-size title line — drop text + // entirely; the coloured rectangle is enough. + if (available < titleLineHeight) return const SizedBox.shrink(); + + final remaining = + (available - titleLineHeight).clamp(0.0, double.infinity); + final bodyLineCapacity = (remaining / bodyLineHeight).floor(); + + if (isCustom) { + if (description.isEmpty || bodyLineCapacity <= 0) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [titleWidget], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.max, + children: [ + titleWidget, + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 1), + child: _WrappingBody( + text: description, + fontSize: kAppointmentBodyFontSize, + lineHeight: kAppointmentBodyLineHeight, + ), + ), + ), + ], + ); + } + + final maxBodyLines = bodyLineCapacity.clamp(0, 2); + final lines = description + .split('\n') + .where((p) => p.isNotEmpty) + .take(maxBodyLines) + .toList(growable: false); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + titleWidget, + for (final line in lines) + _ScaledLine(text: line, fontSize: kAppointmentBodyFontSize), + ], + ); + }, + ); + } +} + /// Renders the appointment title. Scales down to fit the available width via /// [FittedBox], but never below [minFontSize] — when even the minimum size /// overflows, the text is rendered at [minFontSize] with an ellipsis. diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index 7e8cf4d..a853b39 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -29,15 +29,18 @@ class _AvatarPayload { _AvatarPayload(this.bytes, this.isSvg); } -final Map> _avatarCache = {}; +// Resolved payloads are cached so re-mounts render synchronously; in-flight +// requests are deduped so concurrent mounts share one HTTP call. +final Map _resolvedAvatars = {}; +final Map> _pendingAvatars = {}; class _UserAvatarState extends State { - late Future<_AvatarPayload?> _payload; + _AvatarPayload? _payload; @override void initState() { super.initState(); - _payload = _load(); + _attach(); } @override @@ -46,7 +49,7 @@ class _UserAvatarState extends State { if (oldWidget.id != widget.id || oldWidget.isGroup != widget.isGroup || oldWidget.size != widget.size) { - _payload = _load(); + _attach(); } } @@ -58,9 +61,20 @@ class _UserAvatarState extends State { return 'https://$host/avatar/${widget.id}/${widget.size}'; } - Future<_AvatarPayload?> _load() { + void _attach() { final url = _url(); - return _avatarCache.putIfAbsent(url, () => _fetch(url)); + if (_resolvedAvatars.containsKey(url)) { + _payload = _resolvedAvatars[url]; + return; + } + _payload = null; + final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url)); + pending.then((p) { + _resolvedAvatars[url] = p; + _pendingAvatars.remove(url); + if (!mounted || _url() != url) return; + setState(() => _payload = p); + }); } Future<_AvatarPayload?> _fetch(String url) async { @@ -97,49 +111,45 @@ class _UserAvatarState extends State { Widget build(BuildContext context) { final radius = widget.size.toDouble(); final theme = Theme.of(context); + final payload = _payload; - return FutureBuilder<_AvatarPayload?>( - future: _payload, - builder: (context, snapshot) { - final payload = snapshot.data; - - Widget content; - if (payload == null) { - content = Icon( - widget.isGroup ? Icons.group : Icons.person, - size: radius, - color: Colors.white, - ); - } else if (payload.isSvg) { - content = SvgPicture.memory( - payload.bytes, - width: radius * 2, - height: radius * 2, - fit: BoxFit.cover, - ); - } else { - content = Image.memory( - payload.bytes, - width: radius * 2, - height: radius * 2, - fit: BoxFit.cover, - gaplessPlayback: true, - ); - } - - return CircleAvatar( - radius: radius, - backgroundColor: theme.primaryColor, - foregroundColor: Colors.white, - child: ClipOval( - child: SizedBox( - width: radius * 2, - height: radius * 2, - child: content, - ), - ), + Widget content; + if (payload != null) { + if (payload.isSvg) { + content = SvgPicture.memory( + payload.bytes, + width: radius * 2, + height: radius * 2, + fit: BoxFit.cover, ); - }, + } else { + content = Image.memory( + payload.bytes, + width: radius * 2, + height: radius * 2, + fit: BoxFit.cover, + gaplessPlayback: true, + ); + } + } else { + content = Icon( + widget.isGroup ? Icons.group : Icons.person, + size: radius, + color: Colors.white, + ); + } + + return CircleAvatar( + radius: radius, + backgroundColor: theme.primaryColor, + foregroundColor: Colors.white, + child: ClipOval( + child: SizedBox( + width: radius * 2, + height: radius * 2, + child: content, + ), + ), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 660c69b..4077c39 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,11 @@ dependencies: flutter_linkify: ^6.0.0 linkify: ^5.0.0 flutter_local_notifications: ^21.0.0 + # Cancels FCM-rendered notifications by their server-set tag + # (Android NotificationManager.cancel, iOS removeDeliveredNotifications via + # apns-collapse-id). Used to dismiss a chat's notification when the user + # opens or marks the chat read. + eraser: ^3.0.0 scrollable_positioned_list: ^0.3.8 flutter_split_view: ^0.1.2 flutter_svg: ^2.0.10