diff --git a/lib/controls/timeline/interactions_bar_control.dart b/lib/controls/timeline/interactions_bar_control.dart index 2f20760..743feee 100644 --- a/lib/controls/timeline/interactions_bar_control.dart +++ b/lib/controls/timeline/interactions_bar_control.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; import '../../globals.dart'; import '../../models/timeline_entry.dart'; -import '../../services/entry_manager_service.dart'; +import '../../services/timeline_manager.dart'; import '../../utils/snackbar_builder.dart'; class InteractionsBarControl extends StatefulWidget { final TimelineEntry entry; + const InteractionsBarControl({super.key, required this.entry}); @override @@ -14,29 +16,25 @@ class InteractionsBarControl extends StatefulWidget { } class _InteractionsBarControlState extends State { - bool isFavorited = false; - int reshares = 0; - int comments = 0; - int likes = 0; + static final _logger = Logger('$InteractionsBarControl'); - @override - void initState() { - super.initState(); - isFavorited = widget.entry.isFavorited; - comments = widget.entry.engagementSummary.repliesCount; - reshares = widget.entry.engagementSummary.rebloggedCount; - likes = widget.entry.engagementSummary.favoritesCount; - } + bool get isFavorited => widget.entry.isFavorited; + + int get reshares => widget.entry.engagementSummary.rebloggedCount; + + int get comments => widget.entry.engagementSummary.repliesCount; + + int get likes => widget.entry.engagementSummary.favoritesCount; Future toggleFavorited() async { final newState = !isFavorited; - print('Trying to toggle favorite from $isFavorited to $newState'); - final result = await getIt() + _logger.finest('Trying to toggle favorite from $isFavorited to $newState'); + final result = await getIt() .toggleFavorited(widget.entry.id, newState); result.match(onSuccess: (update) { setState(() { - print('Success toggling! $isFavorited -> ${update.entry.isFavorited}'); - isFavorited = update.entry.isFavorited; + _logger.finest( + 'Success toggling! $isFavorited -> ${update.entry.isFavorited}'); }); }, onError: (error) { buildSnackbar(context, 'Error toggling like status: $error'); @@ -45,13 +43,14 @@ class _InteractionsBarControlState extends State { @override Widget build(BuildContext context) { + _logger.finest('Building: ${widget.entry.toShortString()}'); return Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('$likes likes, $reshares reshares, $comments comments'), IconButton( - onPressed: toggleFavorited, + onPressed: () async => await toggleFavorited(), icon: isFavorited ? Icon(Icons.thumb_up) : Icon(Icons.thumb_up_outlined)), diff --git a/lib/controls/timeline/status_control.dart b/lib/controls/timeline/status_control.dart index f228e1d..ba3917a 100644 --- a/lib/controls/timeline/status_control.dart +++ b/lib/controls/timeline/status_control.dart @@ -1,7 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_portal/services/entry_manager_service.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; +import 'package:logging/logging.dart'; import 'package:result_monad/result_monad.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -12,6 +12,7 @@ import '../../models/entry_tree_item.dart'; import '../../models/timeline_entry.dart'; import '../../screens/image_viewer_screen.dart'; import '../../services/connections_manager.dart'; +import '../../services/timeline_manager.dart'; import '../../utils/dateutils.dart'; import '../../utils/snackbar_builder.dart'; import '../padding.dart'; @@ -27,17 +28,15 @@ class StatusControl extends StatefulWidget { } class _StatusControlState extends State { - late EntryTreeItem item; + static final _logger = Logger('$StatusControl'); + + EntryTreeItem get item => widget.originalItem; + TimelineEntry get entry => item.entry; - @override - void initState() { - super.initState(); - item = widget.originalItem; - } - @override Widget build(BuildContext context) { + _logger.finest('Building ${item.entry.toShortString()}'); return Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -196,12 +195,9 @@ class _StatusControlState extends State { if (entry.parentId.isEmpty) ElevatedButton( onPressed: () async { - await getIt() - .refreshPost(widget.originalItem) - .andThenSuccessAsync((newItem) async => setState(() { - print('Updated item'); - item = newItem; - })); + await getIt() + .refreshPost(item.id) + .andThenSuccessAsync((newItem) async => setState(() {})); }, child: Center( child: Text('Load All'), diff --git a/lib/friendica_client.dart b/lib/friendica_client.dart index 05eec3e..ea0f2c1 100644 --- a/lib/friendica_client.dart +++ b/lib/friendica_client.dart @@ -92,12 +92,12 @@ class FriendicaClient { FutureResult, ExecError> getPostOrComment(String id, {bool fullContext = false}) async { - _logger.finest( - () => 'Getting entry for status $id, full context? $fullContext'); return (await runCatchingAsync(() async { final baseUrl = 'https://$serverName/api/v1/statuses/$id'; final url = fullContext ? '$baseUrl/context' : baseUrl; final request = Uri.parse(url); + _logger.finest(() => + 'Getting entry for status $id, full context? $fullContext : $url'); return (await _getApiRequest(request).andThenSuccessAsync((json) async { if (fullContext) { final ancestors = json['ancestors'] as List; @@ -167,8 +167,8 @@ class FriendicaClient { } FutureResult _getUrl(Uri url) async { + _logger.finest('GET: $url'); try { - //SecurityContext.defaultContext.setTrustedCertificates('/etc/apache2/certificate/apache-certificate.crt'); final response = await http.get( url, headers: { @@ -191,6 +191,7 @@ class FriendicaClient { FutureResult _postUrl( Uri url, Map body) async { + _logger.finest('POST: $url'); try { final response = await http.post( url, diff --git a/lib/main.dart b/lib/main.dart index 5ae873a..4f7df94 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,12 +19,12 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: '.env'); Logger.root.level = Level.ALL; - Logger.root.onRecord.listen((event) { - final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName; - final msg = - '${event.level.name} - $logName @ ${event.time}: ${event.message}'; - print(msg); - }); + // Logger.root.onRecord.listen((event) { + // final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName; + // final msg = + // '${event.level.name} - $logName @ ${event.time}: ${event.message}'; + // print(msg); + // }); final authService = AuthService(); final secretsService = SecretsService(); diff --git a/lib/models/entry_tree_item.dart b/lib/models/entry_tree_item.dart index 361ec78..9a5f7f9 100644 --- a/lib/models/entry_tree_item.dart +++ b/lib/models/entry_tree_item.dart @@ -7,20 +7,41 @@ class EntryTreeItem { final _children = {}; - EntryTreeItem(this.entry, {this.isMine = true, this.isOrphaned = false}); + EntryTreeItem(this.entry, + {this.isMine = true, + this.isOrphaned = false, + Map? initialChildren}) { + _children.addAll(initialChildren ?? {}); + } EntryTreeItem copy({required TimelineEntry entry}) => EntryTreeItem( entry, isMine: isMine, isOrphaned: isOrphaned, + initialChildren: _children, ); String get id => entry.id; - void addChild(EntryTreeItem child) { + void addOrUpdate(EntryTreeItem child) { _children[child.id] = child; } + EntryTreeItem? getChildById(String id) { + if (_children.containsKey(id)) { + return _children[id]!; + } + + for (final c in _children.values) { + final result = c.getChildById(id); + if (result != null) { + return result; + } + } + + return null; + } + int get totalChildren { int t = _children.length; for (final c in _children.values) { @@ -37,8 +58,15 @@ class EntryTreeItem { identical(this, other) || other is EntryTreeItem && runtimeType == other.runtimeType && - entry == other.entry; + entry == other.entry && + isMine == other.isMine && + isOrphaned == other.isOrphaned && + _children == other._children; @override - int get hashCode => entry.hashCode; + int get hashCode => + entry.hashCode ^ + isMine.hashCode ^ + isOrphaned.hashCode ^ + _children.hashCode; } diff --git a/lib/models/timeline.dart b/lib/models/timeline.dart index d5eb47a..26ba092 100644 --- a/lib/models/timeline.dart +++ b/lib/models/timeline.dart @@ -4,7 +4,7 @@ import 'entry_tree_item.dart'; class Timeline { final TimelineIdentifiers id; final List _posts = []; - final Set _postsSet = {}; + final Map _postsById = {}; int _lowestStatusId = 0; int _highestStatusId = 0; @@ -12,13 +12,13 @@ class Timeline { Timeline(this.id, {List? initialPosts}) { if (initialPosts != null) { - addPosts(initialPosts); + addOrUpdate(initialPosts); } } List get posts => List.unmodifiable(_posts); - void addPosts(List newPosts) { + void addOrUpdate(List newPosts) { for (final p in newPosts) { final id = int.parse(p.id); if (_lowestStatusId > id) { @@ -28,18 +28,33 @@ class Timeline { if (_highestStatusId < id) { _highestStatusId = id; } - _postsSet.add(p); + _postsById[p.id] = p; } _posts.clear(); - _posts.addAll(_postsSet); + _posts.addAll(_postsById.values); _posts.sort((p1, p2) { return p2.entry.backdatedTimestamp.compareTo(p1.entry.backdatedTimestamp); }); } + bool tryUpdateComment(EntryTreeItem comment) { + var changed = false; + final parentId = comment.entry.parentId; + for (final p in _posts) { + final parent = + p.id == parentId ? p : p.getChildById(comment.entry.parentId); + if (parent != null) { + parent.addOrUpdate(comment); + changed = true; + } + } + + return changed; + } + void clear() { _posts.clear(); - _postsSet.clear(); + _postsById.clear(); _lowestStatusId = 0; _highestStatusId = 0; } diff --git a/lib/models/timeline_entry.dart b/lib/models/timeline_entry.dart index b32e157..697dff2 100644 --- a/lib/models/timeline_entry.dart +++ b/lib/models/timeline_entry.dart @@ -139,7 +139,11 @@ class TimelineEntry { @override String toString() { - return 'TimelineEntry{id: $id, isReshare: $isReshare, parentId: $parentId, creationTimestamp: $creationTimestamp, modificationTimestamp: $modificationTimestamp, backdatedTimeStamp: $backdatedTimestamp, post: $body, title: $title, author: $author, parentAuthor: $parentAuthor externalLink:$externalLink}'; + return 'TimelineEntry{id: $id, isReshare: $isReshare, isFavorited: $isFavorited, parentId: $parentId, creationTimestamp: $creationTimestamp, modificationTimestamp: $modificationTimestamp, backdatedTimeStamp: $backdatedTimestamp, post: $body, title: $title, author: $author, parentAuthor: $parentAuthor externalLink:$externalLink}'; + } + + String toShortString() { + return 'TimelineEntry{id: $id, isReshare: $isReshare, isFavorited: $isFavorited, parentId: $parentId}'; } @override @@ -147,8 +151,47 @@ class TimelineEntry { identical(this, other) || other is TimelineEntry && runtimeType == other.runtimeType && - id == other.id; + id == other.id && + parentId == other.parentId && + parentAuthor == other.parentAuthor && + parentAuthorId == other.parentAuthorId && + creationTimestamp == other.creationTimestamp && + backdatedTimestamp == other.backdatedTimestamp && + modificationTimestamp == other.modificationTimestamp && + body == other.body && + title == other.title && + isReshare == other.isReshare && + author == other.author && + authorId == other.authorId && + externalLink == other.externalLink && + locationData == other.locationData && + isFavorited == other.isFavorited && + links == other.links && + likes == other.likes && + dislikes == other.dislikes && + mediaAttachments == other.mediaAttachments && + engagementSummary == other.engagementSummary; @override - int get hashCode => id.hashCode; + int get hashCode => + id.hashCode ^ + parentId.hashCode ^ + parentAuthor.hashCode ^ + parentAuthorId.hashCode ^ + creationTimestamp.hashCode ^ + backdatedTimestamp.hashCode ^ + modificationTimestamp.hashCode ^ + body.hashCode ^ + title.hashCode ^ + isReshare.hashCode ^ + author.hashCode ^ + authorId.hashCode ^ + externalLink.hashCode ^ + locationData.hashCode ^ + isFavorited.hashCode ^ + links.hashCode ^ + likes.hashCode ^ + dislikes.hashCode ^ + mediaAttachments.hashCode ^ + engagementSummary.hashCode; } diff --git a/lib/screens/home.dart b/lib/screens/home.dart index ac33b56..ef58e2a 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -76,15 +76,24 @@ class _HomeScreenState extends State { print('items count = ${items.length}'); return RefreshIndicator( onRefresh: () async { - await manager.refreshTimeline(TimelineIdentifiers.home()); + await manager.refreshTimeline(TimelineIdentifiers.home(), true); }, child: ListView.separated( itemBuilder: (context, index) { - print('Building item: $index'); - return StatusControl(originalItem: items[index]); + if (index == 0) { + return ElevatedButton( + onPressed: () async => await manager.refreshTimeline( + TimelineIdentifiers.home(), false), + child: Text('Load newer posts')); + } + final itemIndex = index - 1; + final item = items[itemIndex]; + _logger.finest( + 'Building item: $itemIndex: ${item.entry.toShortString()}'); + return StatusControl(originalItem: item); }, separatorBuilder: (context, index) => Divider(), - itemCount: items.length, + itemCount: items.length + 1, ), ); } diff --git a/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart b/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart index 2566cdd..19cab62 100644 --- a/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart +++ b/lib/serializers/mastodon/timeline_entry_mastodon_extensions.dart @@ -24,7 +24,7 @@ extension TimelineEntryMastodonExtensions on TimelineEntry { }) : 0; final id = json['id'] ?? ''; - final isReshare = json.containsKey('reblogged'); + final isReshare = json['reblogged'] ?? false; final parentId = json['in_reply_to_id'] ?? ''; final parentAuthor = json['in_reply_to_account_id'] ?? ''; final parentAuthorId = json['in_reply_to_account_id'] ?? ''; diff --git a/lib/services/entry_manager_service.dart b/lib/services/entry_manager_service.dart index 58346f7..7ed7521 100644 --- a/lib/services/entry_manager_service.dart +++ b/lib/services/entry_manager_service.dart @@ -12,15 +12,13 @@ import 'auth_service.dart'; class EntryManagerService extends ChangeNotifier { static final _logger = Logger('$EntryManagerService'); - final Map _posts = {}; - final List _orphanedComments = []; - final Map _allComments = {}; + final _entries = {}; + final _parentPostIds = {}; + final _postNodes = {}; void clear() { - _posts.clear(); - _orphanedComments.clear(); - _allComments.clear(); - notifyListeners(); + _entries.clear(); + _parentPostIds.clear(); } FutureResult, ExecError> updateTimeline( @@ -45,10 +43,15 @@ class EntryManagerService extends ChangeNotifier { itemsResult.value.sort((t1, t2) => t1.id.compareTo(t2.id)); final updatedPosts = await processNewItems(itemsResult.value, myHandle, client); - _orphanedComments.removeWhere((element) => !element.isOrphaned); - _logger.finest(() => - 'End of update # posts: ${_posts.length}, #comments: ${_allComments.length}, #orphans: ${_orphanedComments.length}'); - notifyListeners(); + _logger.finest(() { + final postCount = _entries.values.where((e) => e.parentId.isEmpty).length; + final commentCount = _entries.length - postCount; + final orphanCount = _entries.values + .where( + (e) => e.parentId.isNotEmpty && !_entries.containsKey(e.parentId)) + .length; + return 'End of update # posts: $postCount, #comments: $commentCount, #orphans: $orphanCount'; + }); return Result.ok(updatedPosts); } @@ -57,74 +60,67 @@ class EntryManagerService extends ChangeNotifier { String myHandle, FriendicaClient? client, ) async { - final updatedPosts = []; + final allSeenItems = [...items]; for (final item in items) { - late EntryTreeItem entry; - final isMine = item.author == myHandle; - if (item.parentAuthor.isEmpty) { - entry = _posts.putIfAbsent(item.id, - () => EntryTreeItem(item, isMine: isMine, isOrphaned: false)); - updatedPosts.add(entry); + _entries[item.id] = item; + } + + final orphans = []; + for (final item in items) { + final parent = _entries[item.parentId]; + if (parent == null) { + orphans.add(item); } else { - final parentPost = _posts[item.parentId]; - bool newEntry = false; - entry = _allComments.putIfAbsent(item.id, () { - newEntry = true; - return EntryTreeItem( - item, - isMine: isMine, - isOrphaned: parentPost == null, - ); - }); - - if (parentPost != null) { - parentPost.addChild(entry); - updatedPosts.add(parentPost); - } - - if (newEntry && entry.isOrphaned) { - _orphanedComments.add(entry); + if (parent.parentId.isEmpty) { + _parentPostIds[item.id] = parent.id; } } } - final orphansToProcess = []; - for (final c in _orphanedComments) { - final post = _posts[c.entry.parentId]; - if (post != null) { - c.isOrphaned = false; - post.addChild(c); - updatedPosts.add(post); - continue; - } - - final comment = _allComments[c.entry.parentId]; - if (comment != null) { - c.isOrphaned = false; - comment.addChild(c); - continue; - } - - if (client != null) { - orphansToProcess.add(c); - } - } - - for (final o in orphansToProcess) { + for (final o in orphans) { await client ?.getPostOrComment(o.id, fullContext: true) .andThenSuccessAsync((items) async { - final moreUpdatedPosts = await processNewItems(items, myHandle, null); - updatedPosts.addAll(moreUpdatedPosts); + final parentPostId = items.firstWhere((e) => e.parentId.isEmpty).id; + _parentPostIds[o.id] = parentPostId; + allSeenItems.addAll(items); + for (final item in items) { + _entries[item.id] = item; + _parentPostIds[item.id] = parentPostId; + } + ; }); } + + allSeenItems.sort((i1, i2) => int.parse(i1.id).compareTo(int.parse(i2.id))); + final postNodesToReturn = <_Node>{}; + for (final item in allSeenItems) { + if (item.parentId.isEmpty) { + final postNode = _postNodes.putIfAbsent(item.id, () => _Node(item.id)); + postNodesToReturn.add(postNode); + } else { + final parentPostNode = _postNodes[_parentPostIds[item.id]]!; + postNodesToReturn.add(parentPostNode); + if (parentPostNode.getChildById(item.id) == null) { + final newNode = _Node(item.id); + final injectionNode = parentPostNode.id == item.parentId + ? parentPostNode + : parentPostNode.getChildById(item.parentId)!; + injectionNode.addChild(newNode); + } + } + } + + final updatedPosts = + postNodesToReturn.map((node) => _nodeToTreeItem(node)).toList(); + _logger.finest( 'Completed processing new items ${client == null ? 'sub level' : 'top level'}'); return updatedPosts; } - FutureResult refreshPost(EntryTreeItem item) async { - _logger.finest('Refreshing post: ${item.id}'); + FutureResult refreshPost(String id) async { + _logger.finest('Refreshing post: $id'); final auth = getIt(); final clientResult = auth.currentClient; if (clientResult.isFailure) { @@ -134,18 +130,22 @@ class EntryManagerService extends ChangeNotifier { final client = clientResult.value; final result = await client - .getPostOrComment(item.id, fullContext: true) + .getPostOrComment(id, fullContext: false) .andThenSuccessAsync((items) async { - await processNewItems(items, client.credentials.username, null); - }); + 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); + }); return result.mapValue((_) { - _logger.finest('${item.id} post updated'); - notifyListeners(); - return _posts[item.id]!; + _logger.finest('$id post updated'); + return _nodeToTreeItem(_postNodes[id]!); }).mapError( (error) { - _logger.finest('${item.id} error updating: $error'); + _logger.finest('$id error updating: $error'); return ExecError( type: ErrorType.localError, message: error.toString(), @@ -155,8 +155,7 @@ class EntryManagerService extends ChangeNotifier { } FutureResult toggleFavorited( - String id, bool newStatus, - {bool notify = false}) async { + String id, bool newStatus) async { final auth = getIt(); final clientResult = auth.currentClient; if (clientResult.isFailure) { @@ -170,35 +169,60 @@ class EntryManagerService extends ChangeNotifier { } final update = result.value; - late EntryTreeItem rval; - if (_posts.containsKey(update.id)) { - rval = _posts[update.id]!.copy(entry: update); - _posts[update.id] = rval; - _updateChildrenEntities(rval, update); - } + _entries[update.id] = update; + final node = update.parentId.isEmpty + ? _postNodes[update.id]! + : _postNodes[update.parentId]!.getChildById(update.id)!; - if (_allComments.containsKey(update.id)) { - rval = _allComments[update.id]!.copy(entry: update); - _allComments[update.id] = rval; - _updateChildrenEntities(rval, update); - } - - if (notify) { - notifyListeners(); - } - return Result.ok(rval); + notifyListeners(); + return Result.ok(_nodeToTreeItem(node)); } - void _updateChildrenEntities(EntryTreeItem item, TimelineEntry entry) { - final updates = item.children.where((element) => element.id == entry.id); - for (final u in updates) { - final newItem = u.copy(entry: entry); - item.children.remove(u); - item.children.add(newItem); - } - - for (final c in item.children) { - _updateChildrenEntities(c, entry); + EntryTreeItem _nodeToTreeItem(_Node node) { + final childenEntries = {}; + for (final c in node.children) { + childenEntries[c.id] = _nodeToTreeItem(c); } + return EntryTreeItem(_entries[node.id]!, initialChildren: childenEntries); } } + +class _Node { + final String id; + final _children = {}; + + List<_Node> get children => _children.values.toList(); + + _Node(this.id, {Map? initialChildren}) { + if (initialChildren != null) { + _children.addAll(initialChildren); + } + } + + void addChild(_Node node) { + _children[node.id] = node; + } + + _Node? getChildById(String id) { + if (_children.containsKey(id)) { + return _children[id]!; + } + + for (final c in _children.values) { + final result = c.getChildById(id); + if (result != null) { + return result; + } + } + + return null; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _Node && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/lib/services/timeline_manager.dart b/lib/services/timeline_manager.dart index 28655a4..eb9306d 100644 --- a/lib/services/timeline_manager.dart +++ b/lib/services/timeline_manager.dart @@ -22,20 +22,32 @@ class TimelineManager extends ChangeNotifier { // refresh timeline gets statuses newer than the newest in that timeline Result, ExecError> getTimeline(TimelineIdentifiers type) { + _logger.finest('Getting timeline $type'); final posts = cachedTimelines[type]?.posts; if (posts != null) { return Result.ok(posts); } - refreshTimeline(type); + refreshTimeline(type, true); return Result.ok([]); } - Future refreshTimeline(TimelineIdentifiers type) async { + FutureResult refreshPost(String id) async { + final result = await getIt().refreshPost(id); + if (result.isSuccess) { + for (final t in cachedTimelines.values) { + t.addOrUpdate([result.value]); + } + notifyListeners(); + } + return result; + } + + Future refreshTimeline(TimelineIdentifiers type, bool reload) async { final timeline = cachedTimelines.putIfAbsent(type, () => Timeline(type)); (await getIt() - .updateTimeline(type, timeline.highestStatusId)) + .updateTimeline(type, reload ? 0 : timeline.highestStatusId)) .match(onSuccess: (posts) { _logger.finest('Posts returned for adding to $type: ${posts.length}'); timeline.addOrUpdate(posts); @@ -43,19 +55,38 @@ class TimelineManager extends ChangeNotifier { }, onError: (error) { _logger.severe('Error getting timeline: $type}'); }); + } + + FutureResult toggleFavorited( + String id, bool newStatus) async { + _logger.finest('Attempting toggling favorite $id to $newStatus'); + final result = + await getIt().toggleFavorited(id, newStatus); + if (result.isFailure) { + _logger.finest('Error toggling favorite $id: ${result.error}'); + return result; + } + + final update = result.value; + for (final timeline in cachedTimelines.values) { + update.entry.parentId.isEmpty + ? timeline.addOrUpdate([update]) + : timeline.tryUpdateComment(update); + } notifyListeners(); + return result; } - // All statuses get dumped into the entity mangager and get full assembled posts out of it - // Timeline keeps track of posts level only so can query timeline manager for those - // Should put backing store on timelines and entity manager so can recover from restart faster - // Have a purge caches button to start that over from scratch - // Should have a contacts manager with backing store as well - // Timeline view is new control that knows how to load timeline, scrolling around with refresh and get more - // Timeline Item view displays itself and children - // Has "Add Comment" value - // Has like/dislke - // Has reshare/quote reshare (if can get that working somehow) - // If our own has delete +// All statuses get dumped into the entity mangager and get full assembled posts out of it +// Timeline keeps track of posts level only so can query timeline manager for those +// Should put backing store on timelines and entity manager so can recover from restart faster +// Have a purge caches button to start that over from scratch +// Should have a contacts manager with backing store as well +// Timeline view is new control that knows how to load timeline, scrolling around with refresh and get more +// Timeline Item view displays itself and children +// Has "Add Comment" value +// Has like/dislke +// Has reshare/quote reshare (if can get that working somehow) +// If our own has delete }