kopia lustrzana https://gitlab.com/mysocialportal/relatica
548 wiersze
17 KiB
Dart
548 wiersze
17 KiB
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 '../friendica_client/friendica_client.dart';
|
||
|
import '../friendica_client/paging_data.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/media_upload_attachment.dart';
|
||
|
import '../models/media_attachment_uploads/new_entry_media_items.dart';
|
||
|
import '../models/timeline_entry.dart';
|
||
|
import '../models/timeline_identifiers.dart';
|
||
|
import '../models/visibility.dart';
|
||
|
import '../utils/media_upload_attachment_helper.dart';
|
||
|
import 'timeline_entry_services.dart';
|
||
|
|
||
|
part 'entry_tree_item_services.g.dart';
|
||
|
|
||
|
@Riverpod(keepAlive: true)
|
||
|
Map<String, String> _parentPostIds(_ParentPostIdsRef ref, Profile profile) {
|
||
|
return {};
|
||
|
}
|
||
|
|
||
|
@Riverpod(keepAlive: true)
|
||
|
Map<String, _Node> _postNodes(_PostNodesRef ref, Profile profile) {
|
||
|
return {};
|
||
|
}
|
||
|
|
||
|
@Riverpod(keepAlive: true)
|
||
|
Map<String, EntryTreeItem> _entryTreeItems(
|
||
|
_EntryTreeItemsRef ref, Profile profile) =>
|
||
|
{};
|
||
|
|
||
|
final _pteLogger = Logger('PostTreeEntryByIdProvider');
|
||
|
|
||
|
@riverpod
|
||
|
Result<EntryTreeItem, ExecError> postTreeEntryById(
|
||
|
PostTreeEntryByIdRef 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 {
|
||
|
late Profile userProfile;
|
||
|
var entryId = '';
|
||
|
|
||
|
@override
|
||
|
Result<EntryTreeItem, ExecError> build(Profile profile, String id) {
|
||
|
_etmLogger.finest('Building for $id for $profile');
|
||
|
// ref.cacheFor(const Duration(hours: 1));
|
||
|
entryId = id;
|
||
|
userProfile = profile;
|
||
|
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<EntryTreeItem, ExecError> 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(userProfile))[entryId] = entry;
|
||
|
}
|
||
|
|
||
|
if (state.isFailure || entry != state.value) {
|
||
|
state = Result.ok(entry);
|
||
|
ref.invalidateSelf(); //TODO Confirm need to invalidate (I don't think I do any longer)
|
||
|
}
|
||
|
|
||
|
return state;
|
||
|
}
|
||
|
|
||
|
void remove() {
|
||
|
_etmLogger.finest('Removing for $entryId for $profile');
|
||
|
ref.read(_entryTreeItemsProvider(userProfile)).remove(entryId);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
final _tluLogger = Logger('TimelineUpdater');
|
||
|
|
||
|
@riverpod
|
||
|
class TimelineUpdater extends _$TimelineUpdater {
|
||
|
@override
|
||
|
bool build(Profile userProfile) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
FutureResult<List<EntryTreeItem>, ExecError> updateTimeline(
|
||
|
TimelineIdentifiers type, int maxId, int sinceId) async {
|
||
|
_tluLogger.fine(() => 'Updating timeline');
|
||
|
final client = TimelineClient(userProfile);
|
||
|
final itemsResult = await client.getTimeline(
|
||
|
type: type,
|
||
|
page: PagingData(
|
||
|
maxId: maxId > 0 ? maxId : null,
|
||
|
sinceId: sinceId > 0 ? sinceId : null,
|
||
|
),
|
||
|
);
|
||
|
if (itemsResult.isFailure) {
|
||
|
_tluLogger.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,
|
||
|
userProfile.userId,
|
||
|
);
|
||
|
return Result.ok(updatedPosts);
|
||
|
}
|
||
|
|
||
|
FutureResult<EntryTreeItem, ExecError> refreshStatusChain(String id) async {
|
||
|
_tluLogger.fine('Refreshing post: $id');
|
||
|
final client = StatusesClient(userProfile);
|
||
|
final postResult = client.getPostOrComment(id, fullContext: false);
|
||
|
final contextResult = client.getPostOrComment(id, fullContext: true);
|
||
|
|
||
|
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);
|
||
|
//ID may be wrong, have to think about if this always needs to be a post ID. Maybe this field is superfluous
|
||
|
await _processNewItems(entries, client.profile.userId);
|
||
|
}
|
||
|
|
||
|
if (hadError) {
|
||
|
return Result.error(results.firstWhere((r) => r.isFailure).error);
|
||
|
} else {
|
||
|
final resultFromProvider =
|
||
|
ref.read(postTreeEntryByIdProvider(userProfile, id));
|
||
|
return resultFromProvider;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
FutureResult<bool, ExecError> deleteEntryById(String id) async {
|
||
|
// TODO confirm that the entry no longer appears in the UI even if the timeline or chain has the ID
|
||
|
_tluLogger.finest('Delete entry: $id');
|
||
|
final result = await StatusesClient(userProfile)
|
||
|
.deleteEntryById(id)
|
||
|
.withResult((_) => _cleanupEntriesForId(id));
|
||
|
|
||
|
return result.execErrorCast();
|
||
|
}
|
||
|
|
||
|
void _cleanupEntriesForId(String id) {
|
||
|
final parentPostIds = ref.read(_parentPostIdsProvider(userProfile));
|
||
|
final postNodes = ref.read(_postNodesProvider(userProfile));
|
||
|
if (parentPostIds.containsKey(id)) {
|
||
|
final parentPostId = parentPostIds.remove(id);
|
||
|
final parentPostNode = postNodes[parentPostId];
|
||
|
parentPostNode?.removeChildById(id);
|
||
|
}
|
||
|
|
||
|
ref.read(entryTreeManagerProvider(userProfile, id).notifier).remove();
|
||
|
|
||
|
if (postNodes.containsKey(id)) {
|
||
|
postNodes.remove(id);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
Future<List<EntryTreeItem>> _processNewItems(
|
||
|
List<TimelineEntry> 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(userProfile, item.id).notifier)
|
||
|
.upsert(item);
|
||
|
}
|
||
|
|
||
|
final orphans = <TimelineEntry>[];
|
||
|
for (final item in items) {
|
||
|
if (item.parentId.isEmpty) {
|
||
|
continue;
|
||
|
}
|
||
|
ref.read(timelineEntryManagerProvider(userProfile, item.parentId)).match(
|
||
|
onSuccess: (parent) {
|
||
|
if (parent.parentId.isEmpty) {
|
||
|
ref.read(_parentPostIdsProvider(userProfile))[item.id] = parent.id;
|
||
|
}
|
||
|
}, onError: (_) {
|
||
|
orphans.add(item);
|
||
|
});
|
||
|
}
|
||
|
for (final o in orphans) {
|
||
|
await StatusesClient(userProfile)
|
||
|
.getPostOrComment(o.id, fullContext: true)
|
||
|
.andThenSuccessAsync((items) async {
|
||
|
final parentPostId = items.firstWhere((e) => e.parentId.isEmpty).id;
|
||
|
ref.read(_parentPostIdsProvider(userProfile))[o.id] = parentPostId;
|
||
|
allSeenItems.addAll(items);
|
||
|
for (final item in items) {
|
||
|
ref
|
||
|
.read(timelineEntryManagerProvider(userProfile, item.id).notifier)
|
||
|
.upsert(item);
|
||
|
ref.read(_parentPostIdsProvider(userProfile))[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(userProfile));
|
||
|
final parentPostIds = ref.read(_parentPostIdsProvider(userProfile));
|
||
|
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}');
|
||
|
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');
|
||
|
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 = <String>[];
|
||
|
for (final c in node.children) {
|
||
|
childenEntries.add(c.id);
|
||
|
_nodeToTreeItem(c, currentId);
|
||
|
}
|
||
|
final entryId = node.id;
|
||
|
final isMine = ref
|
||
|
.read(timelineEntryManagerProvider(userProfile, entryId))
|
||
|
.fold(onSuccess: (t) => t.authorId, onError: (_) => '') ==
|
||
|
currentId;
|
||
|
final rval = EntryTreeItem(
|
||
|
entryId,
|
||
|
isMine: isMine,
|
||
|
initialChildren: childenEntries,
|
||
|
);
|
||
|
|
||
|
ref
|
||
|
.read(entryTreeManagerProvider(userProfile, node.id).notifier)
|
||
|
.upsert(rval);
|
||
|
return rval;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
final _swLogger = Logger('StatusWriter');
|
||
|
|
||
|
@riverpod
|
||
|
class StatusWriter extends _$StatusWriter {
|
||
|
@override
|
||
|
bool build(Profile userProfile) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
// TODO move to RP timeline entry services
|
||
|
FutureResult<TimelineEntry, ExecError> createNewStatus(
|
||
|
String text, {
|
||
|
String spoilerText = '',
|
||
|
String inReplyToId = '',
|
||
|
required NewEntryMediaItems mediaItems,
|
||
|
required List<ImageEntry> 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(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 StatusesClient(userProfile)
|
||
|
.createNewStatus(
|
||
|
text: text,
|
||
|
spoilerText: spoilerText,
|
||
|
inReplyToId: inReplyToId,
|
||
|
mediaIds: mediaIds,
|
||
|
visibility: visibility)
|
||
|
.withResult((item) async {
|
||
|
ref
|
||
|
.read(timelineEntryManagerProvider(userProfile, item.id).notifier)
|
||
|
.upsert(item);
|
||
|
})
|
||
|
.withResult((item) async {
|
||
|
if (inReplyToId.isNotEmpty) {
|
||
|
late final String rootPostId;
|
||
|
if (ref
|
||
|
.read(_postNodesProvider(userProfile))
|
||
|
.containsKey(inReplyToId)) {
|
||
|
rootPostId = inReplyToId;
|
||
|
} else {
|
||
|
rootPostId =
|
||
|
ref.read(_parentPostIdsProvider(userProfile))[inReplyToId]!;
|
||
|
}
|
||
|
await ref
|
||
|
.read(timelineUpdaterProvider(userProfile).notifier)
|
||
|
.refreshStatusChain(rootPostId);
|
||
|
}
|
||
|
})
|
||
|
.withResult((status) => _swLogger.finest('${status.id} status created'))
|
||
|
.withError((error) => _swLogger.finest('Error creating post: $error'));
|
||
|
|
||
|
return result.execErrorCast();
|
||
|
}
|
||
|
|
||
|
FutureResult<TimelineEntry, ExecError> editStatus(
|
||
|
String statusId,
|
||
|
String text, {
|
||
|
String spoilerText = '',
|
||
|
required NewEntryMediaItems mediaItems,
|
||
|
required List<ImageEntry> 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(
|
||
|
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 StatusesClient(userProfile)
|
||
|
.editStatus(
|
||
|
id: statusId,
|
||
|
text: text,
|
||
|
spoilerText: spoilerText,
|
||
|
mediaIds: mediaIds)
|
||
|
.withResult((item) async {
|
||
|
ref
|
||
|
.read(timelineEntryManagerProvider(userProfile, item.id).notifier)
|
||
|
.upsert(item);
|
||
|
})
|
||
|
.withResult((item) async {
|
||
|
final inReplyToId = item.parentId;
|
||
|
if (inReplyToId.isNotEmpty) {
|
||
|
late final String rootPostId;
|
||
|
if (ref
|
||
|
.read(_postNodesProvider(userProfile))
|
||
|
.containsKey(inReplyToId)) {
|
||
|
rootPostId = inReplyToId;
|
||
|
} else {
|
||
|
rootPostId =
|
||
|
ref.read(_parentPostIdsProvider(userProfile))[inReplyToId]!;
|
||
|
}
|
||
|
await ref
|
||
|
.read(timelineUpdaterProvider(userProfile).notifier)
|
||
|
.refreshStatusChain(rootPostId);
|
||
|
}
|
||
|
})
|
||
|
.withResult((status) => _swLogger.finest('${status.id} status created'))
|
||
|
.withError((error) => _swLogger.finest('Error creating post: $error'));
|
||
|
|
||
|
return result.execErrorCast();
|
||
|
}
|
||
|
|
||
|
FutureResult<String, ExecError> _uploadMediaItems(
|
||
|
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 RemoteFileClient(userProfile).uploadFileAsAttachment(
|
||
|
bytes: imageBytes,
|
||
|
album: albumName,
|
||
|
description: item.description,
|
||
|
fileName: filename,
|
||
|
visibility: visibility,
|
||
|
),
|
||
|
)
|
||
|
.transform((v) => v.scales.first.id);
|
||
|
|
||
|
return uploadResult.execErrorCast();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class _Node {
|
||
|
final String id;
|
||
|
final _children = <String, _Node>{};
|
||
|
|
||
|
List<_Node> get children => _children.values.toList();
|
||
|
|
||
|
_Node(this.id, {Map<String, _Node>? 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;
|
||
|
}
|