From bd02a01d08449699949c17b4b975061262a10621 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Tue, 21 Mar 2023 14:27:38 -0400 Subject: [PATCH] Refactor follow requests to use actual follow request system if available --- lib/di_initialization.dart | 3 + lib/friendica_client/friendica_client.dart | 18 ++ lib/friendica_client/pages_manager.dart | 6 + lib/main.dart | 6 + lib/models/follow_request.dart | 21 ++ lib/models/user_notification.dart | 15 +- lib/screens/editor.dart | 2 +- .../follow_request_adjudication_screen.dart | 219 +++++++++++++----- lib/screens/user_profile_screen.dart | 14 +- .../link_preview_friendica_extensions.dart | 10 +- .../follow_request_mastodon_extensions.dart | 30 +++ .../notification_mastodon_extension.dart | 9 +- lib/services/feature_version_checker.dart | 2 + lib/services/follow_requests_manager.dart | 61 +++++ lib/services/notifications_manager.dart | 133 +++++++---- lib/utils/active_profile_selector.dart | 4 +- lib/utils/html_to_edit_text_helper.dart | 2 +- lib/utils/url_opening_utils.dart | 14 ++ test/html_to_edit_text_helper_test.dart | 2 +- 19 files changed, 438 insertions(+), 133 deletions(-) create mode 100644 lib/models/follow_request.dart create mode 100644 lib/serializers/mastodon/follow_request_mastodon_extensions.dart create mode 100644 lib/services/follow_requests_manager.dart diff --git a/lib/di_initialization.dart b/lib/di_initialization.dart index 3abde18..122910c 100644 --- a/lib/di_initialization.dart +++ b/lib/di_initialization.dart @@ -16,6 +16,7 @@ import 'services/connections_manager.dart'; import 'services/direct_message_service.dart'; import 'services/entry_manager_service.dart'; import 'services/feature_version_checker.dart'; +import 'services/follow_requests_manager.dart'; import 'services/gallery_service.dart'; import 'services/hashtag_service.dart'; import 'services/interactions_manager.dart'; @@ -83,6 +84,8 @@ Future dependencyInjectionInitialization() async { ))); getIt.registerSingleton>( ActiveProfileSelector((_) => NotificationsManager())); + getIt.registerSingleton>( + ActiveProfileSelector((_) => FollowRequestsManager())); getIt.registerSingleton>( ActiveProfileSelector((p) => DirectMessageService())); getIt.registerSingleton>( diff --git a/lib/friendica_client/friendica_client.dart b/lib/friendica_client/friendica_client.dart index d506dff..58e7339 100644 --- a/lib/friendica_client/friendica_client.dart +++ b/lib/friendica_client/friendica_client.dart @@ -13,6 +13,7 @@ import '../models/auth/profile.dart'; import '../models/connection.dart'; import '../models/direct_message.dart'; import '../models/exec_error.dart'; +import '../models/follow_request.dart'; import '../models/gallery_data.dart'; import '../models/group_data.dart'; import '../models/image_entry.dart'; @@ -26,6 +27,7 @@ import '../serializers/friendica/gallery_data_friendica_extensions.dart'; import '../serializers/friendica/image_entry_friendica_extensions.dart'; import '../serializers/friendica/visibility_friendica_extensions.dart'; import '../serializers/mastodon/connection_mastodon_extensions.dart'; +import '../serializers/mastodon/follow_request_mastodon_extensions.dart'; import '../serializers/mastodon/group_data_mastodon_extensions.dart'; import '../serializers/mastodon/instance_info_mastodon_extensions.dart'; import '../serializers/mastodon/notification_mastodon_extension.dart'; @@ -361,6 +363,22 @@ class RelationshipsClient extends FriendicaClient { .execErrorCast(); } + FutureResult>, ExecError> getFollowRequests( + PagingData page) async { + _logger.finest(() => 'Getting follow requests with paging data $page'); + _networkStatusService.startConnectionUpdateStatus(); + final baseUrl = 'https://$serverName/api/v1/follow_requests'; + final result = await _getApiListRequest( + Uri.parse('$baseUrl?${page.toQueryParameters()}'), + ); + _networkStatusService.finishConnectionUpdateStatus(); + return result + .andThenSuccess((response) => response.map((jsonArray) => jsonArray + .map((json) => FollowRequestMastodonExtension.fromJson(json)) + .toList())) + .execErrorCast(); + } + FutureResult>, ExecError> getMyFollowers( PagingData page) async { _logger.finest(() => 'Getting followers data with page data $page'); diff --git a/lib/friendica_client/pages_manager.dart b/lib/friendica_client/pages_manager.dart index e72af95..604a360 100644 --- a/lib/friendica_client/pages_manager.dart +++ b/lib/friendica_client/pages_manager.dart @@ -70,11 +70,17 @@ class PagesManager { } FutureResult, ExecError> nextFromEnd() async { + if (_pages.isEmpty) { + return buildErrorResult(type: ErrorType.rangeError); + } return _previousOrNext(_pages.last.id, false); } FutureResult, ExecError> previousFromBeginning() async { + if (_pages.isEmpty) { + return buildErrorResult(type: ErrorType.rangeError); + } return _previousOrNext(_pages.first.id, true); } diff --git a/lib/main.dart b/lib/main.dart index 983ab02..b00f87c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'services/auth_service.dart'; import 'services/connections_manager.dart'; import 'services/direct_message_service.dart'; import 'services/entry_manager_service.dart'; +import 'services/follow_requests_manager.dart'; import 'services/gallery_service.dart'; import 'services/hashtag_service.dart'; import 'services/interactions_manager.dart'; @@ -84,6 +85,11 @@ class App extends StatelessWidget { create: (_) => getIt>(), ), + ChangeNotifierProvider< + ActiveProfileSelector>( + create: (_) => + getIt>(), + ), ChangeNotifierProvider< ActiveProfileSelector>( create: (_) => diff --git a/lib/models/follow_request.dart b/lib/models/follow_request.dart new file mode 100644 index 0000000..de9674b --- /dev/null +++ b/lib/models/follow_request.dart @@ -0,0 +1,21 @@ +import 'connection.dart'; + +class FollowRequest { + final Connection connection; + final DateTime createdAt; + + const FollowRequest({ + required this.connection, + required this.createdAt, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FollowRequest && + runtimeType == other.runtimeType && + connection == other.connection; + + @override + int get hashCode => connection.hashCode; +} diff --git a/lib/models/user_notification.dart b/lib/models/user_notification.dart index 1571f02..1bb05d8 100644 --- a/lib/models/user_notification.dart +++ b/lib/models/user_notification.dart @@ -50,7 +50,7 @@ enum NotificationType { } } -class UserNotification { +class UserNotification implements Comparable { final String id; final NotificationType type; final String fromId; @@ -79,4 +79,17 @@ class UserNotification { String toString() { return 'UserNotification{id: $id, seen: $dismissed, fromName: $fromName, content: $content}'; } + + @override + int compareTo(UserNotification other) { + if (dismissed == other.dismissed) { + return -timestamp.compareTo(other.timestamp); + } + + if (dismissed && !other.dismissed) { + return 1; + } + + return -1; + } } diff --git a/lib/screens/editor.dart b/lib/screens/editor.dart index ecb6082..a30781b 100644 --- a/lib/screens/editor.dart +++ b/lib/screens/editor.dart @@ -98,7 +98,7 @@ class _EditorScreenState extends State { .andThenAsync((manager) async => await manager.getEntryById(widget.id)); result.match(onSuccess: (entry) { _logger.fine('Loading status ${widget.id} information into fields'); - contentController.text = toEditTextField(entry.body); + contentController.text = htmlToSimpleText(entry.body); spoilerController.text = entry.spoilerText; existingMediaItems .addAll(entry.mediaAttachments.map((e) => e.toImageEntry())); diff --git a/lib/screens/follow_request_adjudication_screen.dart b/lib/screens/follow_request_adjudication_screen.dart index 00ef74c..0a74ecb 100644 --- a/lib/screens/follow_request_adjudication_screen.dart +++ b/lib/screens/follow_request_adjudication_screen.dart @@ -1,12 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; -import 'package:relatica/utils/active_profile_selector.dart'; +import 'package:result_monad/result_monad.dart'; import '../controls/login_aware_cached_network_image.dart'; import '../controls/padding.dart'; +import '../globals.dart'; import '../models/connection.dart'; +import '../models/exec_error.dart'; +import '../routes.dart'; import '../services/connections_manager.dart'; +import '../services/feature_version_checker.dart'; +import '../services/follow_requests_manager.dart'; +import '../services/notifications_manager.dart'; +import '../utils/active_profile_selector.dart'; +import '../utils/url_opening_utils.dart'; class FollowRequestAdjudicationScreen extends StatefulWidget { final String userId; @@ -24,30 +33,47 @@ class _FollowRequestAdjudicationScreenState @override Widget build(BuildContext context) { - final manager = context + final fm = + getIt>().activeEntry.value; + final cm = context .watch>() .activeEntry .value; - final connResult = manager.getById(widget.userId); - late final Widget body; - if (connResult.isFailure) { - body = Text('Error getting contact information: ${connResult.error}'); + + late final Result result; + if (getIt() + .canUseFeature(RelaticaFeatures.usingActualFollowRequests)) { + result = fm + .getByUserId(widget.userId) + .mapValue((request) => request.connection); + } else { + result = cm.getById(widget.userId); } - final contact = connResult.value; - switch (contact.status) { - case ConnectionStatus.theyFollowYou: - case ConnectionStatus.youFollowThem: - case ConnectionStatus.none: - body = _buildMainPanel(context, manager, contact); - break; - case ConnectionStatus.mutual: - body = const Text('Already allowed them to connect'); - break; - case ConnectionStatus.you: - case ConnectionStatus.unknown: - body = Text('Invalid state, nothing to do here: ${contact.status}'); - break; + late final Widget body; + if (result.isFailure) { + body = Text('Error getting request info: ${result.error}'); + } else { + final contact = result.value; + final contactStatus = cm + .getById(widget.userId) + .getValueOrElse(() => Connection(status: ConnectionStatus.none)) + .status; + + switch (contactStatus) { + case ConnectionStatus.theyFollowYou: + case ConnectionStatus.youFollowThem: + case ConnectionStatus.none: + body = _buildMainPanel(context, contact, cm, fm); + break; + case ConnectionStatus.mutual: + body = const Text('Already allowed them to connect'); + break; + case ConnectionStatus.you: + case ConnectionStatus.unknown: + body = Text('Invalid state, nothing to do here: ${contact.status}'); + break; + } } return Scaffold( @@ -63,7 +89,11 @@ class _FollowRequestAdjudicationScreenState } Widget _buildMainPanel( - BuildContext context, ConnectionsManager manager, Connection contact) { + BuildContext context, + Connection contact, + ConnectionsManager connectionsManager, + FollowRequestsManager followRequestsManager, + ) { // Options are: // Accept and follow back // Accept and don't follow back @@ -71,50 +101,102 @@ class _FollowRequestAdjudicationScreenState // Back with no action // Calling method should check if completed (true) or not (false) to decide if updating their view of that item - return Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - LoginAwareCachedNetworkImage(imageUrl: contact.avatarUrl.toString()), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - contact.name, - style: Theme.of(context).textTheme.titleLarge, - ), - const HorizontalPadding(), - ], - ), - const VerticalPadding(), - ElevatedButton( - onPressed: processing - ? null - : () async => await accept(manager, contact, true), - child: const Text('Accept and follow back'), - ), - const VerticalPadding(), - ElevatedButton( - onPressed: - processing ? null : () async => accept(manager, contact, false), - child: const Text("Accept but don't follow back"), - ), - const VerticalPadding(), - ElevatedButton( - onPressed: processing ? null : () async => reject(manager, contact), - child: const Text('Reject'), - ), - const VerticalPadding(), - ElevatedButton( - onPressed: processing ? null : () async => ignore(manager, contact), - child: const Text('Ignore (Rejects but user cannot ask again)'), - ), - ], + return SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + LoginAwareCachedNetworkImage(imageUrl: contact.avatarUrl.toString()), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${contact.name}(${contact.handle})', + style: Theme.of(context).textTheme.titleLarge, + ), + const HorizontalPadding(), + ], + ), + const VerticalPadding(), + Wrap( + runSpacing: 5.0, + spacing: 5.0, + alignment: WrapAlignment.center, + children: [ + ElevatedButton( + onPressed: processing + ? null + : () async => await accept(connectionsManager, + followRequestsManager, contact, true), + child: const Text('Accept and follow back'), + ), + ElevatedButton( + onPressed: processing + ? null + : () async => await accept(connectionsManager, + followRequestsManager, contact, true), + child: const Text('Accept and follow back'), + ), + ElevatedButton( + onPressed: processing + ? null + : () async => accept(connectionsManager, + followRequestsManager, contact, false), + child: const Text("Accept but don't follow back"), + ), + ElevatedButton( + onPressed: processing + ? null + : () async => reject( + connectionsManager, followRequestsManager, contact), + child: const Text('Reject'), + ), + ElevatedButton( + onPressed: processing + ? null + : () async => ignore( + connectionsManager, followRequestsManager, contact), + child: const Text('Ignore (Rejects but user cannot ask again)'), + ), + ], + ), + const VerticalPadding(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: () => context.pushNamed( + ScreenPaths.userPosts, + params: {'id': contact.id}, + ), + child: const Text('Posts')), + ElevatedButton( + onPressed: () async => + await openProfileExternal(context, contact), + child: const Text('Open In Browser'), + ), + ], + ), + const VerticalPadding(), + HtmlWidget( + contact.note, + onTapUrl: (url) async { + return await openUrlStringInSystembrowser(context, url, 'link'); + }, + ), + const VerticalPadding(), + Text( + '#Followers: ${contact.followerCount} followers, #Following, ${contact.followingCount}, #Statuses: ${contact.statusesCount}'), + const VerticalPadding(), + Text('Last Status: ${contact.lastStatus ?? "Unknown"}'), + ], + ), ); } Future accept( ConnectionsManager manager, + FollowRequestsManager followRequestsManager, Connection contact, bool followBack, ) async { @@ -127,6 +209,8 @@ class _FollowRequestAdjudicationScreenState await manager.follow(contact); } + _performUpdates(followRequestsManager); + setState(() { processing = false; }); @@ -136,13 +220,15 @@ class _FollowRequestAdjudicationScreenState } } - Future reject(ConnectionsManager manager, Connection contact) async { + Future reject(ConnectionsManager manager, + FollowRequestsManager followRequestsManager, Connection contact) async { setState(() { processing = true; }); await manager.rejectFollowRequest(contact); + _performUpdates(followRequestsManager); setState(() { processing = false; }); @@ -152,12 +238,14 @@ class _FollowRequestAdjudicationScreenState } } - Future ignore(ConnectionsManager manager, Connection contact) async { + Future ignore(ConnectionsManager manager, + FollowRequestsManager followRequestsManager, Connection contact) async { setState(() { processing = true; }); await manager.ignoreFollowRequest(contact); + _performUpdates(followRequestsManager); setState(() { processing = false; @@ -167,4 +255,11 @@ class _FollowRequestAdjudicationScreenState context.pop(); } } + + void _performUpdates(FollowRequestsManager followRequestsManager) { + followRequestsManager.update(); + getIt>() + .activeEntry + .andThenSuccess((m) => m.updateNotifications()); + } } diff --git a/lib/screens/user_profile_screen.dart b/lib/screens/user_profile_screen.dart index 9ed91a0..7be7b46 100644 --- a/lib/screens/user_profile_screen.dart +++ b/lib/screens/user_profile_screen.dart @@ -25,18 +25,6 @@ class UserProfileScreen extends StatefulWidget { } class _UserProfileScreenState extends State { - Future openProfileExternal( - BuildContext context, - Connection connection, - ) async { - final openInBrowser = - await showYesNoDialog(context, 'Open profile in browser?'); - if (openInBrowser == true) { - await openUrlStringInSystembrowser( - context, connection.profileUrl.toString(), 'Post'); - } - } - var isUpdating = false; @override @@ -107,7 +95,7 @@ class _UserProfileScreenState extends State { Text( '#Followers: ${profile.followerCount} followers, #Following, ${profile.followingCount}, #Statuses: ${profile.statusesCount}'), const VerticalPadding(), - Text('Last Status: ${profile.lastStatus}'), + Text('Last Status: ${profile.lastStatus ?? "Unknown"}'), const VerticalPadding(), if (profile.status == ConnectionStatus.mutual || profile.status == ConnectionStatus.youFollowThem) diff --git a/lib/serializers/friendica/link_preview_friendica_extensions.dart b/lib/serializers/friendica/link_preview_friendica_extensions.dart index 52085e4..81e2864 100644 --- a/lib/serializers/friendica/link_preview_friendica_extensions.dart +++ b/lib/serializers/friendica/link_preview_friendica_extensions.dart @@ -1,7 +1,6 @@ -import 'package:relatica/utils/html_to_edit_text_helper.dart'; -import 'package:relatica/utils/string_utils.dart'; - import '../../models/link_preview_data.dart'; +import '../../utils/html_to_edit_text_helper.dart'; +import '../../utils/string_utils.dart'; extension LinkPreviewExtension on LinkPreviewData { String toBodyAttachment() { @@ -9,8 +8,9 @@ extension LinkPreviewExtension on LinkPreviewData { return "[attachment type='link' url='$link' title='$title']$description[/attachment]"; } - final sanitizedTitle = toEditTextField(title).stripHyperlinks(); - final sanitizedDescription = toEditTextField(description).stripHyperlinks(); + final sanitizedTitle = htmlToSimpleText(title).stripHyperlinks(); + final sanitizedDescription = + htmlToSimpleText(description).stripHyperlinks(); return "[attachment type='link' url='$link' title='$sanitizedTitle' image='$selectedImageUrl']$sanitizedDescription[/attachment]"; } diff --git a/lib/serializers/mastodon/follow_request_mastodon_extensions.dart b/lib/serializers/mastodon/follow_request_mastodon_extensions.dart new file mode 100644 index 0000000..e75f881 --- /dev/null +++ b/lib/serializers/mastodon/follow_request_mastodon_extensions.dart @@ -0,0 +1,30 @@ +import 'package:uuid/uuid.dart'; + +import '../../models/follow_request.dart'; +import '../../models/user_notification.dart'; +import 'connection_mastodon_extensions.dart'; + +extension FollowRequestMastodonExtension on FollowRequest { + static FollowRequest fromJson(Map json) { + final connection = ConnectionMastodonExtensions.fromJson(json); + final createdAt = + DateTime.tryParse(json['created_at'] ?? '') ?? DateTime.now(); + return FollowRequest(connection: connection, createdAt: createdAt); + } + + UserNotification toUserNotification() { + return UserNotification( + id: Uuid().v4(), + type: NotificationType.follow_request, + fromId: connection.id, + fromName: connection.name, + fromUrl: connection.profileUrl, + timestamp: createdAt.millisecondsSinceEpoch, + iid: '', + dismissed: false, + content: + '${connection.name}(${connection.handle}) submitted a follow request ', + link: '', + ); + } +} diff --git a/lib/serializers/mastodon/notification_mastodon_extension.dart b/lib/serializers/mastodon/notification_mastodon_extension.dart index f7ca071..fc880d5 100644 --- a/lib/serializers/mastodon/notification_mastodon_extension.dart +++ b/lib/serializers/mastodon/notification_mastodon_extension.dart @@ -5,6 +5,8 @@ import '../../models/user_notification.dart'; import '../../services/connections_manager.dart'; import '../../utils/active_profile_selector.dart'; import '../../utils/dateutils.dart'; +import '../../utils/html_to_edit_text_helper.dart'; +import '../../utils/string_utils.dart'; import 'connection_mastodon_extensions.dart'; import 'timeline_entry_mastodon_extensions.dart'; @@ -32,10 +34,10 @@ extension NotificationMastodonExtension on UserNotification { var content = ''; switch (type) { case NotificationType.follow: - content = '${from.name} is now following you'; + content = '${from.name}(${from.handle}) is now following you'; break; case NotificationType.follow_request: - content = '${from.name} submitted a follow request '; + content = '${from.name}(${from.handle}) submitted a follow request '; break; case NotificationType.unknown: content = '${from.name} has unknown interaction notification'; @@ -59,7 +61,8 @@ extension NotificationMastodonExtension on UserNotification { final shareInfo = status.reshareAuthorId.isNotEmpty ? "reshare of ${status.reshareAuthor}'s" : ''; - content = "$baseContent $shareInfo $referenceType: ${status.body}"; + final bodyText = htmlToSimpleText(status.body).truncate(length: 100); + content = "$baseContent $shareInfo $referenceType: $bodyText"; break; case NotificationType.direct_message: // this is a Relatica internal type so nothing to do here diff --git a/lib/services/feature_version_checker.dart b/lib/services/feature_version_checker.dart index 5ba4c8d..e834153 100644 --- a/lib/services/feature_version_checker.dart +++ b/lib/services/feature_version_checker.dart @@ -8,6 +8,7 @@ import '../models/friendica_version.dart'; enum RelaticaFeatures { postSpoilerText, statusEditing, + usingActualFollowRequests, } class FriendicaVersionChecker { @@ -46,5 +47,6 @@ class FriendicaVersionChecker { static final featureVersionRequirement = { RelaticaFeatures.postSpoilerText: v2023_03, RelaticaFeatures.statusEditing: v2023_03, + RelaticaFeatures.usingActualFollowRequests: v2023_03, }; } diff --git a/lib/services/follow_requests_manager.dart b/lib/services/follow_requests_manager.dart new file mode 100644 index 0000000..01e1b29 --- /dev/null +++ b/lib/services/follow_requests_manager.dart @@ -0,0 +1,61 @@ +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:result_monad/result_monad.dart'; + +import '../friendica_client/friendica_client.dart'; +import '../friendica_client/paged_response.dart'; +import '../friendica_client/paging_data.dart'; +import '../globals.dart'; +import '../models/exec_error.dart'; +import '../models/follow_request.dart'; +import 'auth_service.dart'; + +class FollowRequestsManager extends ChangeNotifier { + static const maxIterations = 20; + final _requests = {}; + + List get requests => UnmodifiableListView(_requests.values); + + Result getByUserId(String id) { + final request = _requests[id]; + return request != null + ? Result.ok(request) + : buildErrorResult( + type: ErrorType.rangeError, + message: 'Request for $id not found', + ); + } + + Future update() async { + var result = await _processPage(PagingData()); + var count = 0; + final updatedRequests = {}; + while (result.isSuccess && + result.value.hasMorePages && + count < maxIterations) { + result + .andThenSuccess((requests) => updatedRequests.addAll(requests.data)); + result = await _processPage(result.value.next); + count++; + } + + _requests.clear(); + for (final r in updatedRequests) { + _requests[r.connection.id] = r; + } + notifyListeners(); + } + + FutureResult>, ExecError> _processPage( + PagingData? page) async { + if (page == null) { + return buildErrorResult(type: ErrorType.rangeError); + } + final result = + await RelationshipsClient(getIt().currentProfile) + .getFollowRequests(page); + + return result; + } +} diff --git a/lib/services/notifications_manager.dart b/lib/services/notifications_manager.dart index be848f4..818cafc 100644 --- a/lib/services/notifications_manager.dart +++ b/lib/services/notifications_manager.dart @@ -10,9 +10,12 @@ import '../friendica_client/paging_data.dart'; import '../globals.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 { @@ -29,20 +32,34 @@ class NotificationsManager extends ChangeNotifier { updateNotifications(); _firstLoad = false; } - final result = List.from(_notifications.values); - result.sort((n1, n2) { - if (n1.dismissed == n2.dismissed) { - return n2.timestamp.compareTo(n1.timestamp); + final dms = []; + final connectionRequests = []; + final unread = []; + final read = []; + for (final n in _notifications.values) { + if (n.dismissed) { + read.add(n); + continue; } - if (n1.dismissed && !n2.dismissed) { - return 1; + switch (n.type) { + case NotificationType.direct_message: + dms.add(n); + break; + case NotificationType.follow: + case NotificationType.follow_request: + connectionRequests.add(n); + break; + default: + unread.add(n); } + } + dms.sort(); + connectionRequests.sort(); + unread.sort(); + read.sort(); - return -1; - }); - - return result; + return [...connectionRequests, ...dms, ...unread, ...read]; } void clear() { @@ -52,10 +69,11 @@ class NotificationsManager extends ChangeNotifier { FutureResult, ExecError> updateNotifications() async { const initialPull = 100; - final nn = []; + final notificationsFromRefresh = []; if (_pm.pages.isEmpty) { final result = await _pm.initialize(initialPull); - result.andThenSuccess((response) => nn.addAll(response.data)); + result.andThenSuccess( + (response) => notificationsFromRefresh.addAll(response.data)); } else { for (var i = 0; i < _pm.pages.length; i++) { if (i > 0 && i == _pm.pages.length - 1) { @@ -78,8 +96,7 @@ class NotificationsManager extends ChangeNotifier { _logger.severe( 'Next page returned no results and no previous page so need to re-initalize'); } else { - final response = - await _clientGetNotificationsRequest(page.previous!); + final response = await _clientGetNotificationsRequest(pd!); response.match( onSuccess: (response) => pd = response.next, onError: (error) => @@ -91,7 +108,8 @@ class NotificationsManager extends ChangeNotifier { 'Previous and next page both returned nulls so need to reinitialize'); _pm.clear(); final result = await _pm.initialize(initialPull); - result.andThenSuccess((response) => nn.addAll(response.data)); + result.andThenSuccess( + (response) => notificationsFromRefresh.addAll(response.data)); } } @@ -105,25 +123,38 @@ class NotificationsManager extends ChangeNotifier { final response = await _clientGetNotificationsRequest(page.next!); response.match( - onSuccess: (response) => nn.addAll(response.data), + onSuccess: (response) => + notificationsFromRefresh.addAll(response.data), onError: (error) => _logger.severe('Error getting previous page: $error')); } } - for (final n in nn) { - _notifications[n.id] = n; - } - - _notifications.removeWhere( - (key, value) => value.type == NotificationType.direct_message, - ); getIt().startNotificationUpdate(); await getIt>() .activeEntry .andThenSuccessAsync((dms) async => await dms.updateThreads()); + + final useActualRequests = getIt() + .canUseFeature(RelaticaFeatures.usingActualFollowRequests); + + if (useActualRequests) { + await getIt>() + .activeEntry + .andThenSuccessAsync((fm) async => fm.update()); + } + + _notifications.clear(); + + 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()) { + for (final n in buildUnreadMessageNotifications(useActualRequests)) { _notifications[n.id] = n; } @@ -135,6 +166,9 @@ class NotificationsManager extends ChangeNotifier { loadNewerNotifications() async { final result = await _pm.previousFromBeginning(); result.match(onSuccess: (response) { + if (response.data.isEmpty) { + return; + } for (final n in response.data) { _notifications[n.id] = n; } @@ -186,30 +220,39 @@ class NotificationsManager extends ChangeNotifier { return updateNotifications(); } - List buildUnreadMessageNotifications() { + List buildUnreadMessageNotifications( + bool useActualRequests) { final myId = getIt().currentProfile.userId; - final result = getIt>() + final dmsResult = getIt>() .activeEntry - .value - .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: const Uuid().v4(), - 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(); + .andThenSuccess((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: const Uuid().v4(), + 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(() => []); - return result; + final followRequestResult = !useActualRequests + ? [] + : getIt>() + .activeEntry + .andThenSuccess( + (fm) => fm.requests.map((r) => r.toUserNotification()).toList()) + .getValueOrElse(() => []); + + return [...dmsResult, ...followRequestResult]; } static FutureResult>, ExecError> diff --git a/lib/utils/active_profile_selector.dart b/lib/utils/active_profile_selector.dart index b2a52ff..39bfd4d 100644 --- a/lib/utils/active_profile_selector.dart +++ b/lib/utils/active_profile_selector.dart @@ -46,7 +46,9 @@ class ActiveProfileSelector extends ChangeNotifier { T _buildNewEntry(Profile p) { final newEntry = _entryBuilder!(p); if (newEntry is ChangeNotifier) { - newEntry.addListener(() => notifyListeners()); + newEntry.addListener(() { + notifyListeners(); + }); } return newEntry; diff --git a/lib/utils/html_to_edit_text_helper.dart b/lib/utils/html_to_edit_text_helper.dart index d21a6b9..b12f652 100644 --- a/lib/utils/html_to_edit_text_helper.dart +++ b/lib/utils/html_to_edit_text_helper.dart @@ -1,7 +1,7 @@ import 'package:html/dom.dart'; import 'package:html/parser.dart'; -String toEditTextField(String htmlContentFragment) { +String htmlToSimpleText(String htmlContentFragment) { final dom = parseFragment(htmlContentFragment); final segments = dom.nodes .map((n) => n is Element ? n.elementToEditText() : n.nodeToEditText()) diff --git a/lib/utils/url_opening_utils.dart b/lib/utils/url_opening_utils.dart index 2936db3..c9c3f3e 100644 --- a/lib/utils/url_opening_utils.dart +++ b/lib/utils/url_opening_utils.dart @@ -1,6 +1,8 @@ import 'package:flutter/widgets.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../globals.dart'; +import '../models/connection.dart'; import 'snackbar_builder.dart'; Future openUrlStringInSystembrowser( @@ -28,3 +30,15 @@ Future openUrlStringInSystembrowser( } return true; } + +Future openProfileExternal( + BuildContext context, + Connection connection, +) async { + final openInBrowser = + await showYesNoDialog(context, 'Open profile in browser?'); + if (openInBrowser == true && context.mounted) { + await openUrlStringInSystembrowser( + context, connection.profileUrl.toString(), 'Post'); + } +} diff --git a/test/html_to_edit_text_helper_test.dart b/test/html_to_edit_text_helper_test.dart index 755e1fd..3f3e936 100644 --- a/test/html_to_edit_text_helper_test.dart +++ b/test/html_to_edit_text_helper_test.dart @@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:relatica/utils/html_to_edit_text_helper.dart'; void testConversion(String original, String expectedOutput) { - final output = toEditTextField(original); + final output = htmlToSimpleText(original); if (output != expectedOutput) { print(output); }