diff --git a/lib/controls/timeline/interactions_bar_control.dart b/lib/controls/timeline/interactions_bar_control.dart index 726a5fd..16cd318 100644 --- a/lib/controls/timeline/interactions_bar_control.dart +++ b/lib/controls/timeline/interactions_bar_control.dart @@ -134,11 +134,13 @@ class _InteractionsBarControlState extends State { } Future openAction(BuildContext context) async { + const editStatus = 'Edit Post'; const goToPost = 'Go to Post'; const copyUrl = 'Copy URL'; const openExternal = 'Open In Browser'; final options = [ if (widget.showOpenControl && !widget.openRemote) goToPost, + if (widget.isMine) editStatus, openExternal, copyUrl, 'Cancel' @@ -154,6 +156,13 @@ class _InteractionsBarControlState extends State { case goToPost: context.push('/post/view/${widget.entry.id}/${widget.entry.id}'); break; + case editStatus: + if (widget.entry.parentId.isEmpty) { + context.push('/post/edit/${widget.entry.id}'); + } else { + context.push('/comment/edit/${widget.entry.id}'); + } + break; case openExternal: await openUrlStringInSystembrowser( context, diff --git a/lib/friendica_client/friendica_client.dart b/lib/friendica_client/friendica_client.dart index 2c1e0b4..bd6f2f8 100644 --- a/lib/friendica_client/friendica_client.dart +++ b/lib/friendica_client/friendica_client.dart @@ -604,6 +604,9 @@ class StatusesClient extends FriendicaClient { if (spoilerText.isNotEmpty) 'spoiler_text': spoilerText, if (inReplyToId.isNotEmpty) 'in_reply_to_id': inReplyToId, if (mediaIds.isNotEmpty) 'media_ids': mediaIds, + 'friendica': { + 'title': '', + }, }; final result = await _postUrl(url, body); if (result.isFailure) { @@ -620,6 +623,37 @@ class StatusesClient extends FriendicaClient { }); } + FutureResult editStatus({ + required String id, + required String text, + String spoilerText = '', + List mediaIds = const [], + }) async { + _logger.finest(() => 'Updating status $id'); + final url = Uri.parse('https://$serverName/api/v1/statuses/$id'); + final body = { + 'status': text, + if (spoilerText.isNotEmpty) 'spoiler_text': spoilerText, + if (mediaIds.isNotEmpty) 'media_ids': mediaIds, + 'friendica': { + 'title': '', + }, + }; + final result = await _putUrl(url, body); + if (result.isFailure) { + return result.errorCast(); + } + + final responseText = result.value; + + return runCatching(() { + final json = jsonDecode(responseText); + return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); + }).mapError((error) { + return ExecError(type: ErrorType.parsingError, message: error.toString()); + }); + } + FutureResult resharePost(String id) async { _logger.finest(() => 'Reshare post $id'); final url = Uri.parse('https://$serverName/api/v1/statuses/$id/reblog'); @@ -806,6 +840,31 @@ abstract class FriendicaClient { } } + FutureResult _putUrl( + Uri url, Map body) async { + _logger.finer('PUT: $url \n Body: $body'); + try { + final response = await http.put( + url, + headers: { + 'Authorization': _profile.credentials.authHeaderValue, + 'Content-Type': 'application/json; charset=UTF-8' + }, + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + return Result.error(ExecError( + type: ErrorType.authentication, + message: '${response.statusCode}: ${response.reasonPhrase}')); + } + return Result.ok(utf8.decode(response.bodyBytes)); + } catch (e) { + return Result.error( + ExecError(type: ErrorType.localError, message: e.toString())); + } + } + FutureResult _deleteUrl( Uri url, Map body) async { _logger.finer('DELETE: $url'); diff --git a/lib/models/media_attachment.dart b/lib/models/media_attachment.dart index 9b49c6e..f249a45 100644 --- a/lib/models/media_attachment.dart +++ b/lib/models/media_attachment.dart @@ -1,4 +1,5 @@ import 'package:path/path.dart' as p; +import 'package:relatica/models/image_entry.dart'; import '../globals.dart'; import 'attachment_media_type_enum.dart'; @@ -7,6 +8,8 @@ class MediaAttachment { static final _graphicsExtensions = ['jpg', 'png', 'gif', 'tif']; static final _movieExtensions = ['avi', 'mp4', 'mpg', 'wmv']; + final String id; + final Uri uri; final int creationTimestamp; @@ -24,7 +27,8 @@ class MediaAttachment { final String description; MediaAttachment( - {required this.uri, + {required this.id, + required this.uri, required this.creationTimestamp, required this.metadata, required this.thumbnailUri, @@ -34,7 +38,8 @@ class MediaAttachment { required this.description}); MediaAttachment.randomBuilt() - : uri = Uri.parse('http://localhost/${randomId()}'), + : id = randomId(), + uri = Uri.parse('http://localhost/${randomId()}'), creationTimestamp = DateTime.now().millisecondsSinceEpoch, fullFileUri = Uri.parse(''), title = 'Random title ${randomId()}', @@ -44,7 +49,8 @@ class MediaAttachment { metadata = {'value1': randomId(), 'value2': randomId()}; MediaAttachment.blank() - : uri = Uri(), + : id = '', + uri = Uri(), creationTimestamp = 0, thumbnailUri = Uri(), explicitType = AttachmentMediaType.unknown, @@ -55,6 +61,7 @@ class MediaAttachment { factory MediaAttachment.fromMastodonJson(Map json) => MediaAttachment( + id: json['id'] ?? '', uri: Uri.parse(json['url'] ?? 'http://localhost'), creationTimestamp: 0, metadata: {}, @@ -66,7 +73,20 @@ class MediaAttachment { @override String toString() { - return 'FriendicaMediaAttachment{uri: $uri, creationTimestamp: $creationTimestamp, type: $explicitType, metadata: $metadata, title: $title, description: $description}'; + return 'FriendicaMediaAttachment{id: $id, uri: $uri, creationTimestamp: $creationTimestamp, type: $explicitType, metadata: $metadata, title: $title, description: $description}'; + } + + ImageEntry toImageEntry() { + return ImageEntry( + id: id, + album: '', + filename: '', + description: description, + thumbnailUrl: thumbnailUri.toString(), + created: DateTime.fromMillisecondsSinceEpoch(creationTimestamp), + height: 0, + width: 0, + scales: []); } Map toJson() => { diff --git a/lib/routes.dart b/lib/routes.dart index 6692f5a..c905abd 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -164,12 +164,16 @@ final appRouter = GoRouter( routes: [ GoRoute( path: 'new', - builder: (context, state) => EditorScreen(), + builder: (context, state) => EditorScreen( + forEditing: false, + ), ), GoRoute( path: 'edit/:id', - builder: (context, state) => - EditorScreen(id: state.params['id'] ?? 'Not Found'), + builder: (context, state) => EditorScreen( + id: state.params['id'] ?? 'Not Found', + forEditing: true, + ), ), GoRoute( path: 'view/:id/:goto_id', @@ -193,12 +197,15 @@ final appRouter = GoRouter( path: 'new', builder: (context, state) => EditorScreen( parentId: state.queryParams['parent_id'] ?? '', + forEditing: false, ), ), GoRoute( path: 'edit/:id', - builder: (context, state) => - EditorScreen(id: state.params['id'] ?? 'Not Found'), + builder: (context, state) => EditorScreen( + id: state.params['id'] ?? 'Not Found', + forEditing: true, + ), ), ]), GoRoute( diff --git a/lib/screens/editor.dart b/lib/screens/editor.dart index 9ab80eb..60de863 100644 --- a/lib/screens/editor.dart +++ b/lib/screens/editor.dart @@ -13,6 +13,7 @@ import '../controls/entry_media_attachments/media_uploads_control.dart'; import '../controls/padding.dart'; import '../controls/standard_appbar.dart'; import '../controls/timeline/status_header_control.dart'; +import '../globals.dart'; import '../models/image_entry.dart'; import '../models/media_attachment_uploads/new_entry_media_items.dart'; import '../models/timeline_entry.dart'; @@ -23,8 +24,10 @@ import '../utils/snackbar_builder.dart'; class EditorScreen extends StatefulWidget { final String id; final String parentId; + final bool forEditing; - const EditorScreen({super.key, this.id = '', this.parentId = ''}); + const EditorScreen( + {super.key, this.id = '', this.parentId = '', required this.forEditing}); @override State createState() => _EditorScreenState(); @@ -49,21 +52,53 @@ class _EditorScreenState extends State { String get localEntryId => widget.id.isNotEmpty ? widget.id : localEntryTemporaryId; + bool loaded = false; + @override void initState() { - if (!isComment) { - return; + if (isComment) { + final manager = context + .read>() + .activeEntry + .value; + manager.getEntryById(widget.parentId).match(onSuccess: (entry) { + spoilerController.text = entry.spoilerText; + parentEntry = entry; + }, onError: (error) { + _logger.finest('Error trying to get parent entry: $error'); + }); } - final manager = context - .read>() + if (widget.forEditing) { + restoreStatusData(); + } else { + loaded = true; + } + } + + void restoreStatusData() async { + _logger.fine('Attempting to load status for editing'); + loaded = false; + final result = await getIt>() .activeEntry - .value; - manager.getEntryById(widget.parentId).match(onSuccess: (entry) { + .andThenAsync((manager) async => await manager.getEntryById(widget.id)); + result.match(onSuccess: (entry) { + _logger.fine('Loading status ${widget.id} information into fields'); + contentController.text = entry.body; spoilerController.text = entry.spoilerText; - parentEntry = entry; + existingMediaItems + .addAll(entry.mediaAttachments.map((e) => e.toImageEntry())); + setState(() { + loaded = true; + }); }, onError: (error) { - _logger.finest('Error trying to get parent entry: $error'); + if (context.mounted) { + buildSnackbar( + context, + 'Error getting post for editing: $error', + ); + } + _logger.severe('Error getting post for editing: $error'); }); } @@ -99,6 +134,38 @@ class _EditorScreenState extends State { } } + Future editStatus(BuildContext context, TimelineManager manager) async { + if (contentController.text.isEmpty) { + buildSnackbar(context, "Can't submit an empty post/comment"); + return; + } + + setState(() { + isSubmitting = true; + }); + + final result = await manager.editStatus( + widget.id, + contentController.text, + spoilerText: spoilerController.text, + inReplyToId: widget.parentId, + newMediaItems: newMediaItems, + existingMediaItems: existingMediaItems, + ); + setState(() { + isSubmitting = false; + }); + + if (result.isFailure) { + buildSnackbar(context, 'Error Updating $statusType: ${result.error}'); + return; + } + + if (mounted && context.canPop()) { + context.pop(); + } + } + @override Widget build(BuildContext context) { _logger.finest('Build editor $isComment $parentEntry'); @@ -146,34 +213,20 @@ class _EditorScreenState extends State { ), ); - final submittingBody = Stack( - children: [ - mainBody, - Card( - color: Theme.of(context).canvasColor.withOpacity(0.8), - child: SizedBox( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const VerticalPadding(), - Text('Submitting New $statusType'), - ], - ), - ), - ), - ), - ], - ); + final Widget body; + if (widget.forEditing && !loaded) { + body = buildBusyBody(context, mainBody, 'Loading status'); + } else if (isSubmitting) { + body = buildBusyBody(context, mainBody, 'Submitting $statusType'); + } else { + body = mainBody; + } return Scaffold( appBar: StandardAppBar.build( context, widget.id.isEmpty ? 'New $statusType' : 'Edit $statusType', withDrawer: true), - body: isSubmitting ? submittingBody : mainBody, + body: body, ); } @@ -270,10 +323,17 @@ class _EditorScreenState extends State { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton( - onPressed: isSubmitting ? null : () => createStatus(context, manager), - child: const Text('Submit'), - ), + if (!widget.forEditing) + ElevatedButton( + onPressed: + isSubmitting ? null : () => createStatus(context, manager), + child: const Text('Submit'), + ), + if (widget.forEditing) + ElevatedButton( + onPressed: isSubmitting ? null : () => editStatus(context, manager), + child: const Text('Edit'), + ), const HorizontalPadding(), ElevatedButton( onPressed: isSubmitting @@ -286,4 +346,29 @@ class _EditorScreenState extends State { ], ); } + + Widget buildBusyBody(BuildContext context, Widget mainBody, String status) { + return Stack( + children: [ + mainBody, + Card( + color: Theme.of(context).canvasColor.withOpacity(0.8), + child: SizedBox( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const VerticalPadding(), + Text(status), + ], + ), + ), + ), + ), + ], + ); + } } diff --git a/lib/serializers/friendica/image_entry_friendica_extensions.dart b/lib/serializers/friendica/image_entry_friendica_extensions.dart index b981e07..97864ba 100644 --- a/lib/serializers/friendica/image_entry_friendica_extensions.dart +++ b/lib/serializers/friendica/image_entry_friendica_extensions.dart @@ -30,6 +30,7 @@ extension ImageEntryFriendicaExtension on ImageEntry { final thumbUri = Uri.parse(thumbnailUrl); final fullFileUri = scales.first.link; return MediaAttachment( + id: id, uri: fullFileUri, fullFileUri: fullFileUri, creationTimestamp: created.millisecondsSinceEpoch, diff --git a/lib/serializers/friendica/media_attachment_friendica_extensions.dart b/lib/serializers/friendica/media_attachment_friendica_extensions.dart index fb5c34e..c213877 100644 --- a/lib/serializers/friendica/media_attachment_friendica_extensions.dart +++ b/lib/serializers/friendica/media_attachment_friendica_extensions.dart @@ -3,6 +3,7 @@ import '../../models/media_attachment.dart'; extension MediaAttachmentFriendicaExtensions on MediaAttachment { static MediaAttachment fromJson(Map json) { + final id = json['id']; final uri = Uri.parse(json['url']); const creationTimestamp = 0; final metadata = (json['metadata'] as Map? ?? {}) @@ -17,6 +18,7 @@ extension MediaAttachmentFriendicaExtensions on MediaAttachment { const description = ''; return MediaAttachment( + id: id, uri: uri, fullFileUri: uri, creationTimestamp: creationTimestamp, diff --git a/lib/services/entry_manager_service.dart b/lib/services/entry_manager_service.dart index ab49ef6..a65902f 100644 --- a/lib/services/entry_manager_service.dart +++ b/lib/services/entry_manager_service.dart @@ -166,6 +166,93 @@ class EntryManagerService extends ChangeNotifier { ); } + FutureResult editStatus( + String id, + String text, { + String spoilerText = '', + required NewEntryMediaItems mediaItems, + required List existingMediaItems, + }) async { + _logger.finest('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 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(getIt().currentProfile) + .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 StatusesClient(getIt().currentProfile) + .editStatus( + id: id, text: text, spoilerText: spoilerText, mediaIds: mediaIds) + .andThenSuccessAsync((item) async { + await processNewItems( + [item], getIt().currentProfile.username, null); + return item; + }).andThenSuccessAsync((item) async { + final inReplyToId = item.parentId; + 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'); diff --git a/lib/services/timeline_manager.dart b/lib/services/timeline_manager.dart index 4bf008a..5e2dc74 100644 --- a/lib/services/timeline_manager.dart +++ b/lib/services/timeline_manager.dart @@ -84,6 +84,28 @@ class TimelineManager extends ChangeNotifier { return result; } + FutureResult editStatus( + String id, + String text, { + String spoilerText = '', + String inReplyToId = '', + required NewEntryMediaItems newMediaItems, + required List existingMediaItems, + }) async { + final result = await entryManagerService.editStatus( + id, + text, + spoilerText: spoilerText, + mediaItems: newMediaItems, + existingMediaItems: existingMediaItems, + ); + if (result.isSuccess) { + _logger.finest('Notifying listeners of updated status'); + notifyListeners(); + } + return result; + } + Result getEntryById(String id) { _logger.finest('Getting entry for $id'); return entryManagerService.getEntryById(id);