diff --git a/lib/controls/notifications_control.dart b/lib/controls/notifications_control.dart index 0f51a34..8ac55f8 100644 --- a/lib/controls/notifications_control.dart +++ b/lib/controls/notifications_control.dart @@ -1,9 +1,13 @@ 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 '../globals.dart'; import '../models/user_notification.dart'; +import '../routes.dart'; import '../services/notifications_manager.dart'; +import '../services/timeline_manager.dart'; import '../utils/dateutils.dart'; import '../utils/snackbar_builder.dart'; @@ -19,12 +23,36 @@ class NotificationControl extends StatelessWidget { Widget build(BuildContext context) { final manager = context.watch(); return ListTile( - tileColor: notification.seen ? null : Colors.black12, - leading: Text(notification.fromName), - title: HtmlWidget(notification.content), + tileColor: notification.dismissed ? null : Colors.black12, + leading: GestureDetector( + onTap: () async { + context.pushNamed(ScreenPaths.userProfile, + params: {'id': notification.fromId}); + }, + child: Text(notification.fromName), + ), + title: GestureDetector( + onTap: () async { + print('Open reference status: ${notification.iid}'); + final manager = getIt(); + final existingPostData = manager.getPostTreeEntryBy(notification.iid); + if (existingPostData.isSuccess) { + context.push('/post/view/${existingPostData.value.id}'); + return; + } + final loadedPost = await manager.refreshStatusChain(notification.iid); + if (loadedPost.isSuccess) { + context.push('/post/view/${loadedPost.value.id}'); + return; + } + buildSnackbar(context, + 'Error getting data for notification: ${loadedPost.error}'); + }, + child: HtmlWidget(notification.content), + ), subtitle: Text(ElapsedDateUtils.epochSecondsToString(notification.timestamp)), - trailing: notification.seen + trailing: notification.dismissed ? null : IconButton( onPressed: () async { diff --git a/lib/controls/timeline/status_control.dart b/lib/controls/timeline/status_control.dart index 3458cd9..0e84d3a 100644 --- a/lib/controls/timeline/status_control.dart +++ b/lib/controls/timeline/status_control.dart @@ -85,7 +85,7 @@ class _StatusControlState extends State { ), if (isPost && hasComments) TextButton( - onPressed: () async => await manager.refreshPost(item.id), + onPressed: () async => await manager.refreshStatusChain(item.id), child: Text('Load Comments'), ), if (item.totalChildren > 0) buildChildComments(context), diff --git a/lib/friendica_client.dart b/lib/friendica_client.dart index e1c89c9..457bf7e 100644 --- a/lib/friendica_client.dart +++ b/lib/friendica_client.dart @@ -11,7 +11,7 @@ import 'models/exec_error.dart'; import 'models/timeline_entry.dart'; import 'models/user_notification.dart'; import 'serializers/friendica/connection_friendica_extensions.dart'; -import 'serializers/friendica/notification_friendica_extension.dart'; +import 'serializers/mastodon/notification_mastodon_extension.dart'; import 'serializers/mastodon/timeline_entry_mastodon_extensions.dart'; class FriendicaClient { @@ -32,12 +32,12 @@ class FriendicaClient { } FutureResult, ExecError> getNotifications() async { - final url = 'https://$serverName/api/friendica/notifications'; + final url = 'https://$serverName/api/v1/notifications?include_all=true'; final request = Uri.parse(url); _logger.finest(() => 'Getting new notifications'); return (await _getApiListRequest(request).andThenSuccessAsync( (notificationsJson) async => notificationsJson - .map((json) => NotificationFriendicaExtension.fromJson(json)) + .map((json) => NotificationMastodonExtension.fromJson(json)) .toList())) .mapError((error) { if (error is ExecError) { @@ -48,20 +48,18 @@ class FriendicaClient { }); } - FutureResult clearNotifications( - List notifications) async { - var result = true; - for (final n in notifications) { - result &= (await clearNotification(n)).isSuccess; - } - - return Result.ok(result); + FutureResult clearNotifications() async { + final url = 'https://$serverName/api/v1/notifications/clear'; + final request = Uri.parse(url); + _logger.finest(() => 'Clearing unread notification'); + final response = await _postUrl(request, {}); + return response.mapValue((value) => true); } FutureResult clearNotification( UserNotification notification) async { final url = - 'https://$serverName/api/friendica/notification/seen?id=${notification.id}'; + 'https://$serverName/api/v1/notifications/${notification.id}/dismiss'; final request = Uri.parse(url); _logger.finest(() => 'Clearing unread notification for $notification'); final response = await _postUrl(request, {}); diff --git a/lib/main.dart b/lib/main.dart index 0c7296d..14415c5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,9 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart'; +import 'package:http_parser/http_parser.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import 'package:result_monad/result_monad.dart'; @@ -19,6 +23,10 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: '.env'); Logger.root.level = Level.ALL; + + MultipartFile.fromBytes('file', Uint8List.fromList([]), + filename: 'hello.jpg', contentType: MediaType('image', 'jpeg')); + // Logger.root.onRecord.listen((event) { // final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName; // final msg = diff --git a/lib/models/user_notification.dart b/lib/models/user_notification.dart index 7ff187b..265d071 100644 --- a/lib/models/user_notification.dart +++ b/lib/models/user_notification.dart @@ -10,28 +10,30 @@ enum NotificationType { class UserNotification { final String id; final String type; + final String fromId; final String fromName; final String fromUrl; final int timestamp; final String iid; - final bool seen; + final bool dismissed; final String content; final String link; UserNotification({ required this.id, required this.type, + required this.fromId, required this.fromName, required this.fromUrl, required this.timestamp, required this.iid, - required this.seen, + required this.dismissed, required this.content, required this.link, }); @override String toString() { - return 'UserNotification{id: $id, seen: $seen, fromName: $fromName, content: $content}'; + return 'UserNotification{id: $id, seen: $dismissed, fromName: $fromName, content: $content}'; } } diff --git a/lib/screens/notifications_screen.dart b/lib/screens/notifications_screen.dart index ffe3b2a..4f1702b 100644 --- a/lib/screens/notifications_screen.dart +++ b/lib/screens/notifications_screen.dart @@ -24,14 +24,13 @@ class NotificationsScreen extends StatelessWidget { manager.updateNotifications(); title = 'Notifications'; body = Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: ListView( children: [ - Text('No notifications'), + Center(child: Text('No notifications')), ], )); } else { - final unreadCount = notifications.where((e) => !e.seen).length; + final unreadCount = notifications.where((e) => !e.dismissed).length; title = 'Notifications ($unreadCount)'; body = ListView.separated( itemBuilder: (context, index) { diff --git a/lib/screens/post_screen.dart b/lib/screens/post_screen.dart index bd8123d..1f1c694 100644 --- a/lib/screens/post_screen.dart +++ b/lib/screens/post_screen.dart @@ -13,9 +13,11 @@ class PostScreen extends StatelessWidget { Widget build(BuildContext context) { final manager = context.watch(); final body = manager.getPostTreeEntryBy(id).fold( - onSuccess: (post) => StatusControl( - originalItem: post, - showActionBar: false, + onSuccess: (post) => SingleChildScrollView( + child: StatusControl( + originalItem: post, + showActionBar: false, + ), ), onError: (error) => Text('Error getting post: $error')); return Scaffold( diff --git a/lib/serializers/friendica/notification_friendica_extension.dart b/lib/serializers/friendica/notification_friendica_extension.dart index 38d303d..b11684c 100644 --- a/lib/serializers/friendica/notification_friendica_extension.dart +++ b/lib/serializers/friendica/notification_friendica_extension.dart @@ -5,11 +5,12 @@ extension NotificationFriendicaExtension on UserNotification { UserNotification( id: json['id'].toString(), type: json['type'].toString(), + fromId: json['uid'], fromName: json['name'], fromUrl: json['url'], timestamp: int.tryParse(json['timestamp'] ?? '') ?? 0, iid: json['iid'].toString(), - seen: json['seen'] ?? false, + dismissed: json['seen'] ?? false, content: json['msg_html'], link: json['link'], ); diff --git a/lib/serializers/mastodon/connection_mastodon_extensions.dart b/lib/serializers/mastodon/connection_mastodon_extensions.dart index cee6fb8..105adc0 100644 --- a/lib/serializers/mastodon/connection_mastodon_extensions.dart +++ b/lib/serializers/mastodon/connection_mastodon_extensions.dart @@ -5,7 +5,7 @@ extension ConnectionMastodonExtensions on Connection { final name = json['display_name'] ?? ''; final id = json['id'] ?? ''; final profileUrl = Uri.parse(json['url'] ?? ''); - const network = 'Mastodon'; + const network = 'Unknown'; final avatar = Uri.tryParse(json['avatar_static'] ?? '') ?? Uri(); return Connection( diff --git a/lib/serializers/mastodon/notification_mastodon_extension.dart b/lib/serializers/mastodon/notification_mastodon_extension.dart new file mode 100644 index 0000000..73bb3cf --- /dev/null +++ b/lib/serializers/mastodon/notification_mastodon_extension.dart @@ -0,0 +1,45 @@ +import 'package:logging/logging.dart'; + +import '../../globals.dart'; +import '../../models/user_notification.dart'; +import '../../services/connections_manager.dart'; +import '../../utils/dateutils.dart'; +import '../../utils/string_utils.dart'; +import 'connection_mastodon_extensions.dart'; +import 'timeline_entry_mastodon_extensions.dart'; + +final _logger = Logger('NotificationMastodonExtension'); + +extension NotificationMastodonExtension on UserNotification { + static UserNotification fromJson(Map json) { + final int timestamp = json.containsKey('created_at') + ? OffsetDateTimeUtils.epochSecTimeFromTimeZoneString(json['created_at']) + .fold( + onSuccess: (value) => value, + onError: (error) { + _logger.severe("Couldn't read date time string: $error"); + return 0; + }) + : 0; + + final type = json['type']; + final from = ConnectionMastodonExtensions.fromJson(json['account']); + getIt().addConnection(from); + final status = TimelineEntryMastodonExtensions.fromJson(json['status']); + final referenceType = status.parentId == null ? 'post' : 'comment'; + final content = + "${from.name} $type on ${status.author}'s $referenceType: ${status.body.truncate()}"; + return UserNotification( + id: json['id'].toString(), + type: type, + fromId: from.id, + fromName: from.name, + fromUrl: from.profileUrl.toString(), + timestamp: timestamp, + iid: status.id, + dismissed: json['dismissed'] ?? false, + content: content, + link: status.externalLink, + ); + } +} diff --git a/lib/services/entry_manager_service.dart b/lib/services/entry_manager_service.dart index 14da9b4..5893218 100644 --- a/lib/services/entry_manager_service.dart +++ b/lib/services/entry_manager_service.dart @@ -21,10 +21,24 @@ class EntryManagerService extends ChangeNotifier { _parentPostIds.clear(); } + _Node? _getPostRootNode(String id) { + final fromPosts = _postNodes[id]; + if (fromPosts != null) { + return fromPosts; + } + + final parentId = _parentPostIds[id]; + if (parentId == null) { + return null; + } + + return _postNodes[parentId]; + } + Result getPostTreeEntryBy(String id) { _logger.finest('Getting post: $id'); final auth = getIt(); - final postNode = _postNodes[id]; + final postNode = _getPostRootNode(id); if (postNode == null) { return Result.error(ExecError( type: ErrorType.notFound, @@ -74,7 +88,7 @@ class EntryManagerService extends ChangeNotifier { } else { rootPostId = _parentPostIds[inReplyToId]; } - await refreshPost(rootPostId); + await refreshStatusChain(rootPostId); } return item; }); @@ -131,6 +145,7 @@ class EntryManagerService extends ChangeNotifier { String currentId, FriendicaClient? client, ) async { + items.sort((i1, i2) => int.parse(i1.id).compareTo(int.parse(i2.id))); final allSeenItems = [...items]; for (final item in items) { _entries[item.id] = item; @@ -198,7 +213,7 @@ class EntryManagerService extends ChangeNotifier { return updatedPosts; } - FutureResult refreshPost(String id) async { + FutureResult refreshStatusChain(String id) async { _logger.finest('Refreshing post: $id'); final auth = getIt(); final clientResult = auth.currentClient; @@ -210,18 +225,17 @@ class EntryManagerService extends ChangeNotifier { final client = clientResult.value; final result = await client .getPostOrComment(id, fullContext: false) + .andThenAsync((rootItems) async => await client + .getPostOrComment(id, fullContext: true) + .andThenSuccessAsync( + (contextItems) async => [...rootItems, ...contextItems])) .andThenSuccessAsync((items) async { - await processNewItems(items, client.credentials.username, null); - }) - .andThenAsync( - (_) async => await client.getPostOrComment(id, fullContext: true)) - .andThenSuccessAsync((items) async { - await processNewItems(items, client.credentials.username, null); - }); + await processNewItems(items, client.credentials.username, null); + }); return result.mapValue((_) { _logger.finest('$id post updated'); - return _nodeToTreeItem(_postNodes[id]!, auth.currentId); + return _nodeToTreeItem(_getPostRootNode(id)!, auth.currentId); }).mapError( (error) { _logger.finest('$id error updating: $error'); diff --git a/lib/services/notifications_manager.dart b/lib/services/notifications_manager.dart index 584c856..fdeef58 100644 --- a/lib/services/notifications_manager.dart +++ b/lib/services/notifications_manager.dart @@ -14,11 +14,11 @@ class NotificationsManager extends ChangeNotifier { List get notifications { final result = List.from(_notifications.values); result.sort((n1, n2) { - if (n1.seen == n2.seen) { + if (n1.dismissed == n2.dismissed) { return n2.timestamp.compareTo(n1.timestamp); } - if (n1.seen && !n2.seen) { + if (n1.dismissed && !n2.dismissed) { return 1; } @@ -77,9 +77,7 @@ class NotificationsManager extends ChangeNotifier { } final client = clientResult.value; - final unreadNotifications = - _notifications.values.where((n) => !n.seen).toList(); - final result = await client.clearNotifications(unreadNotifications); + final result = await client.clearNotifications(); if (result.isFailure) { return result.errorCast(); } diff --git a/lib/services/timeline_manager.dart b/lib/services/timeline_manager.dart index 4054abe..0929605 100644 --- a/lib/services/timeline_manager.dart +++ b/lib/services/timeline_manager.dart @@ -64,9 +64,11 @@ class TimelineManager extends ChangeNotifier { return Result.ok([]); } - FutureResult refreshPost(String id) async { + /// + /// id is the id of a post or comment in the chain, including the original post. + FutureResult refreshStatusChain(String id) async { _logger.finest('Refreshing post $id'); - final result = await getIt().refreshPost(id); + final result = await getIt().refreshStatusChain(id); if (result.isSuccess) { for (final t in cachedTimelines.values) { t.addOrUpdate([result.value]); diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart new file mode 100644 index 0000000..6fba466 --- /dev/null +++ b/lib/utils/string_utils.dart @@ -0,0 +1,9 @@ +extension StringUtils on String { + String truncate({int length = 32}) { + if (length <= length) { + return this; + } + + return '${substring(0, length)}...'; + } +}