import 'package:flutter/material.dart'; 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 '../globals.dart'; import '../models/TimelineIdentifiers.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 'auth_service.dart'; import 'media_upload_attachment_helper.dart'; class EntryManagerService extends ChangeNotifier { static final _logger = Logger('$EntryManagerService'); final _entries = {}; final _parentPostIds = {}; final _postNodes = {}; 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 auth = getIt(); final postNode = _getPostRootNode(id); if (postNode == null) { return Result.error(ExecError( type: ErrorType.notFound, message: 'Unknown post id: $id', )); } return Result.ok(_nodeToTreeItem(postNode, auth.currentId)); } 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 auth = getIt(); final clientResult = auth.currentClient; if (clientResult.isFailure) { _logger.severe('Error getting Friendica client: ${clientResult.error}'); return clientResult.errorCast(); } final client = clientResult.value; final result = await client.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, }) async { _logger.finest('Creating new post: $text'); final auth = getIt(); final clientResult = auth.currentClient; if (clientResult.isFailure) { _logger.severe('Error getting Friendica client: ${clientResult.error}'); return clientResult.errorCast(); } final client = clientResult.value; 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 client.uploadFileAsAttachment( bytes: imageBytes, album: mediaItems.albumName, description: item.description, fileName: filename, ), ); 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 client .createNewStatus( text: text, spoilerText: spoilerText, inReplyToId: inReplyToId, mediaIds: mediaIds) .andThenSuccessAsync((item) async { await processNewItems([item], client.credentials.username, null); return item; }).andThenSuccessAsync((item) async { if (inReplyToId.isNotEmpty) { late final 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 auth = getIt(); final clientResult = auth.currentClient; if (clientResult.isFailure) { _logger.severe('Error getting Friendica client: ${clientResult.error}'); return clientResult.errorCast(); } final client = clientResult.value; 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, auth.currentId, 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 client ?.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]; 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 auth = getIt(); final clientResult = auth.currentClient; if (clientResult.isFailure) { _logger.severe('Error getting Friendica client: ${clientResult.error}'); return clientResult.errorCast(); } 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); }); return result.mapValue((_) { _logger.finest('$id post updated'); return _nodeToTreeItem(_getPostRootNode(id)!, auth.currentId); }).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 auth = getIt(); final clientResult = auth.currentClient; if (clientResult.isFailure) { _logger.severe('Error getting Friendica client: ${clientResult.error}'); return clientResult.errorCast(); } final client = clientResult.value; final result = await client.resharePost(id).andThenSuccessAsync((item) async { await processNewItems([item], client.credentials.username, null); }); return result.mapValue((_) { _logger.finest('$id post updated after reshare'); return _nodeToTreeItem(_postNodes[id]!, auth.currentId); }).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 auth = getIt(); final clientResult = auth.currentClient; if (clientResult.isFailure) { _logger.severe('Error getting Friendica client: ${clientResult.error}'); return clientResult.errorCast(); } final client = clientResult.value; final result = await client.unResharePost(id).andThenSuccessAsync((item) async { await processNewItems([item], client.credentials.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 auth = getIt(); final clientResult = auth.currentClient; if (clientResult.isFailure) { _logger.severe('Error getting Friendica client: ${clientResult.error}'); return clientResult.errorCast(); } final client = clientResult.value; final result = await client.changeFavoriteStatus(id, newStatus); if (result.isFailure) { return result.errorCast(); } final update = result.value; _entries[update.id] = update; final node = update.parentId.isEmpty ? _postNodes[update.id]! : _postNodes[update.parentId]!.getChildById(update.id)!; notifyListeners(); return Result.ok(_nodeToTreeItem(node, auth.currentId)); } 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 || entry.reshareAuthorId == currentId; return EntryTreeItem( _entries[node.id]!, isMine: isMine, initialChildren: childenEntries, ); } void _cleanupEntriesForId(String id) { if (_parentPostIds.containsKey(id)) { _parentPostIds.remove(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; } _Node? 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; }