import 'package:flutter/material.dart' hide Visibility; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; import 'package:result_monad/result_monad.dart'; import '../friendica_client/friendica_client.dart'; import '../friendica_client/paging_data.dart'; import '../models/TimelineIdentifiers.dart'; import '../models/auth/profile.dart'; import '../models/entry_tree_item.dart'; import '../models/exec_error.dart'; import '../models/image_entry.dart'; import '../models/media_attachment_uploads/new_entry_media_items.dart'; import '../models/timeline_entry.dart'; import '../models/visibility.dart'; import 'media_upload_attachment_helper.dart'; class EntryManagerService extends ChangeNotifier { static final _logger = Logger('$EntryManagerService'); final _entries = {}; final _parentPostIds = {}; final _postNodes = {}; final Profile profile; EntryManagerService(this.profile); void clear() { _entries.clear(); _parentPostIds.clear(); _postNodes.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 idForCall = id; final postNode = _getPostRootNode(idForCall); if (postNode == null) { return Result.error(ExecError( type: ErrorType.notFound, message: 'Unknown post id: $id', )); } return Result.ok(_nodeToTreeItem(postNode, profile.userId)); } Result getEntryById(String id) { if (_entries.containsKey(id)) { return Result.ok(_entries[id]!); } return Result.error(ExecError( type: ErrorType.notFound, message: 'Timeline entry not found: $id', )); } FutureResult deleteEntryById(String id) async { _logger.finest('Delete entry: $id'); final result = await StatusesClient(profile).deleteEntryById(id); if (result.isFailure) { return result.errorCast(); } _cleanupEntriesForId(id); notifyListeners(); return Result.ok(true); } FutureResult createNewStatus( String text, { String spoilerText = '', String inReplyToId = '', required NewEntryMediaItems mediaItems, required List existingMediaItems, required Visibility visibility, }) async { _logger.finest('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 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 RemoteFileClient(profile).uploadFileAsAttachment( bytes: imageBytes, album: mediaItems.albumName, description: item.description, fileName: filename, visibility: visibility, ), ); if (uploadResult.isSuccess) { mediaIds.add(uploadResult.value.scales.first.id); } else { return Result.error(ExecError( type: ErrorType.localError, message: 'Error uploading image: ${uploadResult.error}')); } } final result = await StatusesClient(profile) .createNewStatus( text: text, spoilerText: spoilerText, inReplyToId: inReplyToId, mediaIds: mediaIds, visibility: visibility) .andThenSuccessAsync((item) async { await processNewItems([item], profile.username, null); return item; }).andThenSuccessAsync((item) async { if (inReplyToId.isNotEmpty) { late final String rootPostId; if (_postNodes.containsKey(inReplyToId)) { rootPostId = inReplyToId; } else { rootPostId = _parentPostIds[inReplyToId]!; } await refreshStatusChain(rootPostId); } return item; }); return result.mapValue((status) { _logger.finest('${status.id} status created'); return true; }).mapError( (error) { _logger.finest('Error creating post: $error'); return ExecError( type: ErrorType.localError, message: error.toString(), ); }, ); } FutureResult editStatus( String id, String text, { String spoilerText = '', required NewEntryMediaItems mediaItems, required List existingMediaItems, required Visibility newMediaItemVisibility, }) async { _logger.finest('Editing post: $text'); final idForCall = id; 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 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 RemoteFileClient(profile) .uploadFileAsAttachment( bytes: imageBytes, album: mediaItems.albumName, description: item.description, fileName: filename, visibility: newMediaItemVisibility), ); if (uploadResult.isSuccess) { mediaIds.add(uploadResult.value.scales.first.id); } else { return Result.error(ExecError( type: ErrorType.localError, message: 'Error uploading image: ${uploadResult.error}')); } } final result = await StatusesClient(profile) .editStatus( id: idForCall, text: text, spoilerText: spoilerText, mediaIds: mediaIds) .andThenSuccessAsync((item) async { await processNewItems([item], profile.username, null); return item; }).andThenSuccessAsync((item) async { final inReplyToId = item.parentId; if (inReplyToId.isNotEmpty) { late final String rootPostId; if (_postNodes.containsKey(inReplyToId)) { rootPostId = inReplyToId; } else { rootPostId = _parentPostIds[inReplyToId]!; } await refreshStatusChain(rootPostId); } return item; }); return result.mapValue((status) { _logger.finest('${status.id} status created'); return true; }).mapError( (error) { _logger.finest('Error creating post: $error'); return ExecError( type: ErrorType.localError, message: error.toString(), ); }, ); } FutureResult, ExecError> updateTimeline( TimelineIdentifiers type, int maxId, int sinceId) async { _logger.fine(() => 'Updating timeline'); final client = TimelineClient(profile); final itemsResult = await client.getTimeline( type: type, page: PagingData( maxId: maxId > 0 ? maxId : null, sinceId: sinceId > 0 ? sinceId : null, ), ); if (itemsResult.isFailure) { _logger.severe('Error getting timeline: ${itemsResult.error}'); return itemsResult.errorCast(); } itemsResult.value.sort((t1, t2) => t1.id.compareTo(t2.id)); final updatedPosts = await processNewItems(itemsResult.value, profile.userId, client); _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); } Future> processNewItems( List items, 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; } final orphans = []; for (final item in items) { if (item.parentId.isEmpty) { continue; } final parent = _entries[item.parentId]; if (parent == null) { orphans.add(item); } else { if (parent.parentId.isEmpty) { _parentPostIds[item.id] = parent.id; } } } for (final o in orphans) { await StatusesClient(profile) .getPostOrComment(o.id, fullContext: true) .andThenSuccessAsync((items) async { 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) { 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; 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) { _logger.severe( 'Error finding parent ${item.parentId} for entry ${item.id}'); 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) { _logger.severe( 'Had ${allSeenItems.length} items left over after all iterations'); break; } lastCount = allSeenItems.length; } final updatedPosts = postNodesToReturn .map((node) => _nodeToTreeItem(node, currentId)) .toList(); _logger.finest( 'Completed processing new items ${client == null ? 'sub level' : 'top level'}'); return updatedPosts; } FutureResult refreshStatusChain(String id) async { _logger.finest('Refreshing post: $id'); final client = StatusesClient(profile); final result = await client .getPostOrComment(id, fullContext: false) .andThenAsync((rootItems) async => await client .getPostOrComment(id, fullContext: true) .andThenSuccessAsync( (contextItems) async => [...rootItems, ...contextItems])) .withResult((items) async { _cleanupEntriesForId(id); await processNewItems(items, client.profile.username, null); }); return result.mapValue((_) { _logger.finest('$id post updated'); return _nodeToTreeItem(_getPostRootNode(id)!, client.profile.userId); }).mapError( (error) { _logger.finest('$id error updating: $error'); return ExecError( type: ErrorType.localError, message: error.toString(), ); }, ); } FutureResult resharePost(String id) async { _logger.finest('Resharing post: $id'); final client = StatusesClient(profile); final idForCall = id; final result = await client.resharePost(idForCall).andThenSuccessAsync((item) async { await processNewItems([item], client.profile.username, null); }); return result.mapValue((_) { _logger.finest('$id post updated after reshare'); return _nodeToTreeItem(_postNodes[id]!, client.profile.userId); }).mapError( (error) { _logger.finest('$id error updating: $error'); return ExecError( type: ErrorType.localError, message: error.toString(), ); }, ); } FutureResult unResharePost(String id) async { _logger.finest('Unresharing post: $id'); final client = StatusesClient(profile); final idForCall = id; final result = await client.unResharePost(idForCall).andThenSuccessAsync((item) async { await processNewItems([item], client.profile.username, null); }); if (result.isFailure) { return Result.error(result.error); } _cleanupEntriesForId(id); notifyListeners(); return Result.ok(true); } FutureResult toggleFavorited( String id, bool newStatus) async { final interactionClient = InteractionsClient(profile); final postsClient = StatusesClient(profile); final idForCall = id; final result = await interactionClient.changeFavoriteStatus(idForCall, newStatus); if (result.isFailure) { return result.errorCast(); } final updateResult = await postsClient.getPostOrComment(id, fullContext: false); if (updateResult.isFailure) { return updateResult.errorCast(); } final update = updateResult.value.first; _entries[update.id] = update; final node = update.parentId.isEmpty ? _postNodes[update.id]! : _postNodes[_parentPostIds[update.id]]!; notifyListeners(); return Result.ok(_nodeToTreeItem(node, interactionClient.profile.userId)); } EntryTreeItem _nodeToTreeItem(_Node node, String currentId) { final childenEntries = {}; for (final c in node.children) { childenEntries[c.id] = _nodeToTreeItem(c, currentId); } final entry = _entries[node.id]!; final isMine = entry.authorId == currentId; return EntryTreeItem( _entries[node.id]!, isMine: isMine, initialChildren: childenEntries, ); } void _cleanupEntriesForId(String id) { if (_parentPostIds.containsKey(id)) { final parentPostId = _parentPostIds.remove(id); final parentPostNode = _postNodes[parentPostId]; parentPostNode?.removeChildById(id); } if (_entries.containsKey(id)) { _entries.remove(id); } if (_postNodes.containsKey(id)) { _postNodes.remove(id); } } } 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; }