import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:result_monad/result_monad.dart'; import '../friendica_client/friendica_client.dart'; import '../friendica_client/paged_response.dart'; import '../friendica_client/pages_manager.dart'; import '../friendica_client/paging_data.dart'; import '../globals.dart'; import '../models/auth/profile.dart'; import '../models/exec_error.dart'; import '../models/user_notification.dart'; import '../serializers/mastodon/follow_request_mastodon_extensions.dart'; import '../utils/active_profile_selector.dart'; import 'auth_service.dart'; import 'direct_message_service.dart'; import 'feature_version_checker.dart'; import 'follow_requests_manager.dart'; import 'network_status_service.dart'; class NotificationsManager extends ChangeNotifier { static const itemsPerQuery = 50; static const minimumDmsAndCrsUpdateDuration = Duration(seconds: 30); static final _logger = Logger('NotificationManager'); final Profile profile; final dms = []; final connectionRequests = []; final unread = []; final read = []; var lastDmsUpdate = DateTime(1900); var lastCrUpdate = DateTime(1900); NotificationsManager(this.profile); var _firstLoad = true; List get notifications { if (_firstLoad) { loadUnreadNotifications(true); _firstLoad = false; } return [...connectionRequests, ...dms, ...unread, ...read]; } void clear({bool withListenerNotification = true}) { dms.clear(); connectionRequests.clear(); unread.clear(); read.clear(); _firstLoad = true; notifyListeners(); } void refreshNotifications() async { clear(withListenerNotification: false); await loadUnreadNotifications(false); if (unread.isEmpty && read.isEmpty) { await loadOlderNotifications(withListenerNotification: false); } notifyListeners(); } FutureResult, ExecError> loadUnreadNotifications( bool withListenerNotification) async { final notificationsFromRefresh = []; final pm = _buildPageManager(profile, false); final useActualRequests = getIt() .canUseFeature(RelaticaFeatures.usingActualFollowRequests); var hasMore = true; var first = true; while (hasMore) { final result = first ? await pm.initialize(itemsPerQuery) : await pm.nextFromEnd(); first = false; result.match( onSuccess: (nd) => print('Got ${nd.data.length} notifications'), onError: (e) => debugPrint('Error getting notification: $e')); final response = result.getValueOrElse(() => PagedResponse([])); response.data .where((n) => !useActualRequests || n.type != NotificationType.follow_request) .forEach(notificationsFromRefresh.add); hasMore = response.next != null; } // filter out connection requests if going to use the real service for that when doing the query // get earliest and latest notification ID from unread notifications // query all notifications over that in page increments of 25 // query unread notifications in increments of 25 after the latest ID return await _postFetchOperations( notificationsFromRefresh, withListenerNotification, ); } FutureResult, ExecError> loadNewerNotifications({ bool withListenerNotification = true, }) async { final (_, highestId) = unread.isNotEmpty ? calcLowHigh(unread) : calcLowHigh(read); final pm = _buildPageManager( profile, true, initialPages: [ PagedResponse( [], next: PagingData(minId: highestId), ) ], ); final result = await (unread.isEmpty && read.isEmpty ? pm.initialize(itemsPerQuery) : pm.previousFromBeginning()) .andThenAsync( (page) async => await _postFetchOperations(page.data, withListenerNotification), ) .withError( (error) => _logger.info('Error getting more updates: $error')); return result.execErrorCast(); } FutureResult, ExecError> loadOlderNotifications( {bool withListenerNotification = true}) async { final (lowestId, _) = read.isNotEmpty ? calcLowHigh(read) : calcLowHigh(unread); final pm = _buildPageManager( profile, true, initialPages: read.isEmpty ? [] : [ PagedResponse( [], next: PagingData(maxId: lowestId), ) ], ); final notifications = []; if (read.isEmpty) { var hasReadNotification = false; var hasMorePages = false; do { await (notifications.isEmpty ? pm.initialize(itemsPerQuery) : pm.nextFromEnd()) .match(onSuccess: (r) { notifications.addAll(r.data); hasMorePages = r.next != null; hasReadNotification = r.data.map((e) => e.dismissed).firstWhere( (t) => t == true, orElse: () => false, ); }, onError: (e) { hasMorePages = false; print('Error getting older notifications: $e'); }); } while (!hasReadNotification && hasMorePages); } else { await pm.nextFromEnd().withResult((r) => notifications.addAll(r.data)); } return _postFetchOperations(notifications, withListenerNotification); } FutureResult markSeen(UserNotification notification) async { final result = await NotificationsClient(profile) .clearNotification(notification) .withResult((_) { unread.remove(notification); read.add(notification.copy(dismissed: true)); read.sort(); notifyListeners(); }); return result.execErrorCast(); } FutureResult markAllAsRead() async { final result = await NotificationsClient(getIt().currentProfile) .clearNotifications() .withResult((_) { unread.map((n) => n.copy(dismissed: true)).forEach(read.add); unread.clear(); read.sort(); notifyListeners(); }); return result.execErrorCast(); } List buildUnreadMessageNotifications( bool useActualRequests) { final myId = profile.userId; final dmsResult = getIt>() .getForProfile(profile) .transform((d) => d.getThreads(unreadyOnly: true).map((t) { final fromAccount = t.participants.firstWhere((p) => p.id != myId); final latestMessage = t.messages .reduce((s, m) => s.createdAt > m.createdAt ? s : m); return UserNotification( id: (fromAccount.hashCode ^ t.parentUri.hashCode ^ t.title.hashCode) .toString(), type: NotificationType.direct_message, fromId: fromAccount.id, fromName: fromAccount.name, fromUrl: fromAccount.profileUrl, timestamp: latestMessage.createdAt, iid: t.parentUri, dismissed: false, content: '${fromAccount.name} sent you a direct message', link: ''); }).toList()) .getValueOrElse(() => []); final followRequestResult = !useActualRequests ? [] : getIt>() .getForProfile(profile) .transform( (fm) => fm.requests.map((r) => r.toUserNotification()).toList()) .getValueOrElse(() => []); return [...dmsResult, ...followRequestResult]; } void updateNotification(UserNotification notification) {} FutureResult, ExecError> _postFetchOperations( List notificationsFromRefresh, bool withListenerNotification, ) async { getIt().startNotificationUpdate(); if (DateTime.now().difference(lastDmsUpdate) > minimumDmsAndCrsUpdateDuration) { await getIt>() .getForProfile(profile) .transformAsync((dms) async => await dms.updateThreads()); lastDmsUpdate = DateTime.now(); } final useActualRequests = getIt() .canUseFeature(RelaticaFeatures.usingActualFollowRequests); if (useActualRequests) { if (DateTime.now().difference(lastCrUpdate) > minimumDmsAndCrsUpdateDuration) { await getIt>() .getForProfile(profile) .transformAsync((fm) async => fm.update()); lastCrUpdate = DateTime.now(); } } final notifications = {}; notificationsFromRefresh.removeWhere((n) => n.type == NotificationType.direct_message || (useActualRequests && n.type == NotificationType.follow_request)); for (final n in notificationsFromRefresh) { notifications[n.id] = n; } getIt().finishNotificationUpdate(); for (final n in buildUnreadMessageNotifications(useActualRequests)) { notifications[n.id] = n; } _processNewNotifications(notifications.values); if (withListenerNotification) { notifyListeners(); } return Result.ok(notifications.values.toList()); } Future _processNewNotifications( Iterable notifications) async { final dmsMap = {}; final crMap = {}; final unreadMap = {}; final readMap = {}; final st = Stopwatch()..start(); for (int i = 0; i < dms.length; i++) { dmsMap[dms[i].id] = dms[i]; } if (st.elapsedMilliseconds > maxProcessingMillis) { await Future.delayed(processingSleep, () => st.reset()); } for (int i = 0; i < connectionRequests.length; i++) { crMap[connectionRequests[i].id] = connectionRequests[i]; } if (st.elapsedMilliseconds > maxProcessingMillis) { await Future.delayed(processingSleep, () => st.reset()); } for (int i = 0; i < unread.length; i++) { unreadMap[unread[i].id] = unread[i]; } if (st.elapsedMilliseconds > maxProcessingMillis) { await Future.delayed(processingSleep, () => st.reset()); } for (int i = 0; i < read.length; i++) { readMap[read[i].id] = read[i]; } dms.clear(); connectionRequests.clear(); unread.clear(); read.clear(); for (final n in notifications) { if (st.elapsedMilliseconds > maxProcessingMillis) { await Future.delayed(processingSleep, () => st.reset()); } dmsMap.remove(n.id); crMap.remove(n.id); unreadMap.remove(n.id); readMap.remove(n.id); if (n.dismissed) { readMap[n.id] = n; continue; } switch (n.type) { case NotificationType.direct_message: dmsMap[n.id] = n; break; case NotificationType.follow_request: crMap[n.id] = n; break; default: unreadMap[n.id] = n; } } dms ..addAll(dmsMap.values) ..sort(); connectionRequests ..addAll(crMap.values) ..sort(); unread ..addAll(unreadMap.values) ..sort(); read ..addAll(readMap.values) ..sort(); } } (int lowest, int highest) calcLowHigh(List notifications) { int highestNotificationId = -1; int lowestNotificationId = 0x7FFFFFFFFFFFFFFF; final ids = notifications .where((n) => n.type != NotificationType.direct_message && n.type != NotificationType.follow_request) .map((n) => int.parse(n.id)); for (var id in ids) { if (id > highestNotificationId) { highestNotificationId = id; } if (id < lowestNotificationId) { lowestNotificationId = id; } } return (lowestNotificationId, highestNotificationId); } PagesManager, String> _buildPageManager( Profile profile, bool includeAll, {List initialPages = const []}) => PagesManager, String>( initialPages: initialPages, idMapper: (nn) => nn.map((n) => n.id).toList(), onRequest: (pd) async => await NotificationsClient(profile).getNotifications(pd, includeAll), );