import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; import 'package:result_monad/result_monad.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:stack_trace/stack_trace.dart'; import '../models/auth/profile.dart'; import '../models/entry_tree_item.dart'; import '../models/exec_error.dart'; import '../models/flattened_tree_item.dart'; import '../models/image_entry.dart'; import '../models/media_attachment_uploads/media_upload_attachment.dart'; import '../models/media_attachment_uploads/new_entry_media_items.dart'; import '../models/networking/paging_data.dart'; import '../models/timeline_entry.dart'; import '../models/timeline_identifiers.dart'; import '../models/visibility.dart'; import '../riverpod_controllers/networking/friendica_remote_file_client_services.dart'; import '../riverpod_controllers/networking/friendica_timelines_client_services.dart'; import '../utils/entry_tree_item_flattening.dart'; import '../utils/media_upload_attachment_helper.dart'; import 'networking/friendica_statuses_client_services.dart'; import 'timeline_entry_services.dart'; import 'timeline_services.dart'; part 'entry_tree_item_services.g.dart'; @Riverpod(keepAlive: true) Map _parentPostIds(Ref ref, Profile profile) { return {}; } @Riverpod(keepAlive: true) Map _postNodes(Ref ref, Profile profile) { return {}; } @Riverpod(keepAlive: true) Map _entryTreeItems(Ref ref, Profile profile) => {}; final _pteLogger = Logger('PostTreeEntryByIdProvider'); @riverpod Result postTreeEntryById( Ref ref, Profile profile, String id) { _pteLogger.finest('Building for $id for $profile'); final isPostNode = ref.watch(_postNodesProvider(profile)).containsKey(id); _pteLogger.finest('$id ${isPostNode ? "is a post" : "is a comment"}'); final postId = isPostNode ? id : ref.watch(_parentPostIdsProvider(profile))[id]; if (postId == null) { _pteLogger.finest('No post entry found for $id for $profile'); return buildErrorResult( type: ErrorType.notFound, message: 'No post entry found for id: $id'); } final entry = ref.watch(entryTreeManagerProvider(profile, postId)); _pteLogger.finest('Result from ETM: $entry'); return entry; } final _etmLogger = Logger('EntryTreeManagerProvider'); @Riverpod(keepAlive: true) class EntryTreeManager extends _$EntryTreeManager { var entryId = ''; @override Result build(Profile profile, String id) { _etmLogger.finest('Building for $id for $profile'); entryId = id; final entries = ref.watch(_entryTreeItemsProvider(profile)); final entry = entries[id]; if (entry == null) { _etmLogger.finest('EntryTreeItem for $id for $profile not found'); return buildErrorResult( type: ErrorType.notFound, message: '$id not found', ); } _etmLogger.finest('Return entry for $id for $profile'); return Result.ok(entry); } Result upsert(EntryTreeItem entry) { _etmLogger.finest('Upserting entry for $id for $profile'); if (entry.id != entryId) { return buildErrorResult( type: ErrorType.argumentError, message: 'Trying to add an entry to a provider that does not match the id: $id', ); } else { ref.read(_entryTreeItemsProvider(profile))[entryId] = entry; } if (state.isFailure || entry != state.value) { state = Result.ok(entry); } return state; } void remove() { _etmLogger.finest('Removing for $entryId for $profile'); ref.read(_entryTreeItemsProvider(profile)).remove(entryId); ref.invalidateSelf(); } Result, ExecError> flattened({ int level = FlatteningExtensions.baseLevel, bool topLevelOnly = false, }) { if (state.isFailure) { return state.errorCast(); } return Result.ok( state.value.flatten( level: level, topLevelOnly: topLevelOnly, profile: profile, ref: ref, ), ); } } final _tluLogger = Logger('TimelineUpdater'); @riverpod class TimelineUpdater extends _$TimelineUpdater { @override bool build(Profile profile) { return true; } FutureResult, ExecError> updateTimeline( TimelineIdentifiers type, int maxId, int sinceId) async { _tluLogger.fine(() => 'Updating timeline'); final itemsResult = await ref.read(timelineProvider(profile, type: type, page: PagingData( maxId: maxId > 0 ? maxId : null, sinceId: sinceId > 0 ? sinceId : null, )).future); if (itemsResult.isFailure) { _tluLogger.severe( 'Error getting timeline: ${itemsResult.error}', Trace.current(), ); return itemsResult.errorCast(); } itemsResult.value.sort((t1, t2) => t1.id.compareTo(t2.id)); final updatedPosts = await _processNewItems( itemsResult.value, profile.userId, ); return Result.ok(updatedPosts); } FutureResult refreshStatusChain(String id) async { _tluLogger.fine('Refreshing post: $id'); final postResult = ref.read(postOrCommentProvider(profile, id, fullContext: false).future); final contextResult = ref.read(postOrCommentProvider(profile, id, fullContext: true).future); final results = await Future.wait([postResult, contextResult]); final hadError = results.map((r) => r.isFailure).reduce((e, s) => e && s); final entries = results .map((r) => r.getValueOrElse(() => [])) .expand((i) => i) .toList(); if (entries.isNotEmpty) { _cleanupEntriesForId(id); await _processNewItems(entries, profile.userId); } if (hadError) { return Result.error(results.firstWhere((r) => r.isFailure).error); } else { final resultFromProvider = ref.read(postTreeEntryByIdProvider(profile, id)); return resultFromProvider; } } FutureResult deleteEntryById(String id) async { _tluLogger.finest('Delete entry: $id'); final result = await ref .read(deleteStatusEntryByIdProvider(profile, id).future) .withResult((_) { ref.read(entryTreeManagerProvider(profile, id).notifier).remove(); ref.read(timelineEntryManagerProvider(profile, id).notifier).remove(); _cleanupEntriesForId(id); }); return result.execErrorCast(); } void _cleanupEntriesForId(String id) { final parentPostIds = ref.read(_parentPostIdsProvider(profile)); final postNodes = ref.read(_postNodesProvider(profile)); if (parentPostIds.containsKey(id)) { final parentPostId = parentPostIds.remove(id); final parentPostNode = postNodes[parentPostId]; ref.invalidate(timelineEntryManagerProvider(profile, parentPostId!)); parentPostNode?.removeChildById(id); } if (postNodes.containsKey(id)) { postNodes.remove(id); } } Future> _processNewItems( List items, String currentId, ) async { _tluLogger.fine('Processing new items: ${items.map((e) => e.id).toList()}'); items.sort((i1, i2) => int.parse(i1.id).compareTo(int.parse(i2.id))); final allSeenItems = [...items]; for (final item in items) { _tluLogger.finest('Upserting entry for ${item.id}'); ref .read(timelineEntryManagerProvider(profile, item.id).notifier) .upsert(item); } final orphans = []; for (final item in items) { if (item.parentId.isEmpty) { continue; } ref.read(timelineEntryManagerProvider(profile, item.parentId)).match( onSuccess: (parent) { if (parent.parentId.isEmpty) { ref.read(_parentPostIdsProvider(profile))[item.id] = parent.id; } }, onError: (_) { orphans.add(item); }); } for (final o in orphans) { await ref .read(postOrCommentProvider(profile, o.id, fullContext: true).future) .andThenSuccessAsync((items) async { final parentPostId = items.firstWhere((e) => e.parentId.isEmpty).id; ref.read(_parentPostIdsProvider(profile))[o.id] = parentPostId; allSeenItems.addAll(items); for (final item in items) { ref .read(timelineEntryManagerProvider(profile, item.id).notifier) .upsert(item); ref.read(_parentPostIdsProvider(profile))[item.id] = parentPostId; } }); } allSeenItems.sort((i1, i2) { if (i1.parentId.isEmpty && i2.parentId.isNotEmpty) { return -1; } if (i2.parentId.isEmpty && i1.parentId.isNotEmpty) { return 1; } return int.parse(i1.id).compareTo(int.parse(i2.id)); }); final postNodesToReturn = <_Node>{}; var lastCount = 0; final postNodes = ref.read(_postNodesProvider(profile)); final parentPostIds = ref.read(_parentPostIdsProvider(profile)); while (allSeenItems.isNotEmpty) { final seenItemsCopy = [...allSeenItems]; for (final item in seenItemsCopy) { if (item.parentId.isEmpty) { final postNode = postNodes.putIfAbsent(item.id, () => _Node(item.id)); postNodesToReturn.add(postNode); allSeenItems.remove(item); } else { final parentParentPostId = postNodes.containsKey(item.parentId) ? item.parentId : parentPostIds[item.parentId]; if (postNodes[parentParentPostId] == null) { _tluLogger.severe( 'Error finding parent ${item.parentId} for entry ${item.id}', Trace.current(), ); continue; } final parentPostNode = postNodes[parentParentPostId]!; postNodesToReturn.add(parentPostNode); parentPostIds[item.id] = parentPostNode.id; if (parentPostNode.getChildById(item.id) == null) { final newNode = _Node(item.id); final injectionNode = parentPostNode.id == item.parentId ? parentPostNode : parentPostNode.getChildById(item.parentId); if (injectionNode == null) { continue; } else { injectionNode.addChild(newNode); } } allSeenItems.remove(item); } } if (allSeenItems.isNotEmpty && allSeenItems.length == lastCount) { _tluLogger.severe( 'Had ${allSeenItems.length} items left over after all iterations', Trace.current(), ); break; } lastCount = allSeenItems.length; } final updatedPosts = postNodesToReturn .map((node) => _nodeToTreeItem(node, currentId)) .toList(); _tluLogger.finest('Completed processing new items'); return updatedPosts; } EntryTreeItem _nodeToTreeItem(_Node node, String currentId) { final childenEntries = []; for (final c in node.children) { childenEntries.add(c.id); _nodeToTreeItem(c, currentId); } final entryId = node.id; final isMine = ref .read(timelineEntryManagerProvider(profile, entryId)) .fold(onSuccess: (t) => t.authorId, onError: (_) => '') == currentId; final rval = EntryTreeItem( entryId, isMine: isMine, initialChildren: childenEntries, ); ref.read(entryTreeManagerProvider(profile, node.id).notifier).upsert(rval); return rval; } } final _swLogger = Logger('StatusWriter'); @riverpod class StatusWriter extends _$StatusWriter { @override bool build(Profile profile) { return true; } FutureResult createNewStatus( Profile profile, String text, { String spoilerText = '', String inReplyToId = '', required NewEntryMediaItems mediaItems, required List existingMediaItems, required Visibility visibility, }) async { _swLogger.info('Creating new post: $text'); final mediaIds = existingMediaItems.map((m) => m.scales.first.id).toList(); for (final item in mediaItems.attachments) { if (item.isExistingServerItem) { continue; } final result = await _uploadMediaItems( profile, item, mediaItems.albumName, visibility) .withResult((newId) => mediaIds.add(newId)); if (result.isFailure) { return Result.error(ExecError( type: ErrorType.localError, message: 'Error uploading image: ${result.error}')); } } final result = await ref .read(createNewStatusProvider(profile, text: text, spoilerText: spoilerText, inReplyToId: inReplyToId, mediaIds: mediaIds, visibility: visibility) .future) .withResultAsync((item) async { ref .read(timelineEntryManagerProvider(profile, item.id).notifier) .upsert(item); }) .withResultAsync((item) async { if (inReplyToId.isNotEmpty) { late final String rootPostId; if (ref .read(_postNodesProvider(profile)) .containsKey(inReplyToId)) { rootPostId = inReplyToId; } else { rootPostId = ref.read(_parentPostIdsProvider(profile))[inReplyToId]!; } await ref .read(timelineUpdaterProvider(profile).notifier) .refreshStatusChain(rootPostId); } }) .withResultAsync((item) async { await ref .read(timelineMaintainerProvider(profile).notifier) .loadNewerForPersonalTimelines(); }) .withResult((status) => _swLogger.finest('${status.id} status created')) .withError((error) => _swLogger.finest('Error creating post: $error')); return result.execErrorCast(); } FutureResult editStatus( Profile profile, String statusId, String text, { String spoilerText = '', required NewEntryMediaItems mediaItems, required List existingMediaItems, required Visibility newMediaItemVisibility, }) async { _swLogger.info('Editing post: $text'); final mediaIds = existingMediaItems .map((m) => m.scales.isEmpty ? m.id : m.scales.first.id) .toList(); for (final item in mediaItems.attachments) { if (item.isExistingServerItem) { continue; } final result = await _uploadMediaItems( profile, item, mediaItems.albumName, newMediaItemVisibility) .withResult((newId) => mediaIds.add(newId)); if (result.isFailure) { return Result.error(ExecError( type: ErrorType.localError, message: 'Error uploading image: ${result.error}')); } } final result = await ref .read(editStatusProvider(profile, id: statusId, text: text, spoilerText: spoilerText, mediaIds: mediaIds) .future) .withResult((item) async { ref .read(timelineEntryManagerProvider(profile, item.id).notifier) .upsert(item); }) .withResult((item) async { final inReplyToId = item.parentId; if (inReplyToId.isNotEmpty) { late final String rootPostId; if (ref .read(_postNodesProvider(profile)) .containsKey(inReplyToId)) { rootPostId = inReplyToId; } else { rootPostId = ref.read(_parentPostIdsProvider(profile))[inReplyToId]!; } await ref .read(timelineUpdaterProvider(profile).notifier) .refreshStatusChain(rootPostId); } }) .withResult((status) => _swLogger.finest('${status.id} status created')) .withError((error) => _swLogger.finest('Error creating post: $error')); return result.execErrorCast(); } FutureResult _uploadMediaItems( Profile profile, MediaUploadAttachment item, String albumName, Visibility visibility, ) async { final String extension = p.extension(item.localFilePath); late final String filename; if (item.remoteFilename.isEmpty) { filename = p.basename(item.localFilePath); } else { if (item.remoteFilename.toLowerCase().endsWith(extension.toLowerCase())) { filename = item.remoteFilename; } else { filename = "${item.remoteFilename}$extension"; } } final uploadResult = await MediaUploadAttachmentHelper.getUploadableImageBytes( item.localFilePath, ) .andThenAsync((imageBytes) async => await ref.read(uploadFileAsAttachmentProvider( profile, bytes: imageBytes, album: albumName, description: item.description, fileName: filename, visibility: visibility, ).future)) .transform((v) => v.scales.first.id); return uploadResult.execErrorCast(); } } 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; } void removeChildById(String id) { if (_children.containsKey(id)) { _children.remove(id); } for (final c in _children.values) { c.removeChildById(id); } } _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; }