Further Microblog/Entry Support (#4)

* Added posts sub feeds for magazines and users.

* Added ability to edit posts.

* Added ability to edit post comments.

* Added ability to delete posts and post comments.

* Added ability to edit/delete entries and entry comments.

* Edit/Delete buttons are now greyed out on feed page.
Editing populates the text field with the current body of the entry/post.

* Edit/Delete buttons are greyed out if logged in user is not op.
This commit is contained in:
olorin99 2024-01-03 13:52:10 +10:00 committed by GitHub
parent ee657d55c4
commit ffe80277db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 543 additions and 123 deletions

View File

@ -175,3 +175,39 @@ Future<Comment> postComment(
return Comment.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<Comment> editComment(
http.Client client,
String instanceHost,
int commentId,
String body,
String lang,
bool? isAdult
) async {
final response = await client.put(Uri.https(
instanceHost,
'/api/comments/$commentId'
),
body: jsonEncode({
'body': body,
'lang': lang,
'isAdult': isAdult ?? false
}));
httpErrorHandler(response, message: "Failed to edit comment");
return Comment.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<void> deleteComment(
http.Client client,
String instanceHost,
int commentId,
) async {
final response = await client.delete(Uri.https(
instanceHost,
'/api/comments/$commentId'
));
httpErrorHandler(response, message: "Failed to delete comment");
}

View File

@ -154,3 +154,44 @@ Future<EntryItem> putFavorite(
return EntryItem.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<EntryItem> editEntry(
http.Client client,
String instanceHost,
int entryID,
String title,
bool isOc,
String body,
String lang,
bool isAdult
) async {
final response = await client.put(Uri.https(
instanceHost,
'/api/entry/$entryID'
),
body: jsonEncode({
'title': title,
'tags': [],
'isOc': isOc,
'body': body,
'lang': lang,
'isAdult': isAdult
}));
httpErrorHandler(response, message: "Failed to edit entry");
return EntryItem.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<void> deletePost(
http.Client client,
String instanceHost,
int postID,
) async {
final response = await client.delete(Uri.https(
instanceHost,
'/api/entry/$postID'
));
httpErrorHandler(response, message: "Failed to delete entry");
}

View File

@ -175,3 +175,39 @@ Future<Comment> postComment(
return Comment.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<Comment> editComment(
http.Client client,
String instanceHost,
int commentId,
String body,
String lang,
bool? isAdult
) async {
final response = await client.put(Uri.https(
instanceHost,
'/api/post-comments/$commentId'
),
body: jsonEncode({
'body': body,
'lang': lang,
'isAdult': isAdult ?? false
}));
httpErrorHandler(response, message: "Failed to edit comment");
return Comment.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<void> deleteComment(
http.Client client,
String instanceHost,
int commentId,
) async {
final response = await client.delete(Uri.https(
instanceHost,
'/api/post-comments/$commentId'
));
httpErrorHandler(response, message: "Failed to delete comment");
}

View File

@ -144,3 +144,39 @@ Future<PostItem> putFavorite(
return PostItem.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<PostItem> editPost(
http.Client client,
String instanceHost,
int postID,
String body,
String lang,
bool isAdult
) async {
final response = await client.put(Uri.https(
instanceHost,
'/api/post/$postID'
),
body: jsonEncode({
'body': body,
'lang': lang,
'isAdult': isAdult
}));
httpErrorHandler(response, message: "Failed to edit post");
return PostItem.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
Future<void> deletePost(
http.Client client,
String instanceHost,
int postID,
) async {
final response = await client.delete(Uri.https(
instanceHost,
'/api/post/$postID'
));
httpErrorHandler(response, message: "Failed to delete post");
}

View File

@ -117,6 +117,29 @@ class _EntryCommentState extends State<EntryComment> {
newComment.children!.insert(0, newSubComment);
widget.onUpdate(newComment);
},
onEdit: whenLoggedIn(context, (body) async {
var newComment = await api_comments.editComment(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().instanceHost,
widget.comment.commentId,
body,
widget.comment.lang,
widget.comment.isAdult
);
setState(() {
widget.comment.body = newComment.body;
});
}),
onDelete: whenLoggedIn(context, () async {
await api_comments.deleteComment(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().instanceHost,
widget.comment.commentId,
);
setState(() {
widget.comment.body = "deleted";
});
}),
),
),
const SizedBox(height: 4),

View File

@ -19,11 +19,15 @@ class EntryItem extends StatelessWidget {
super.key,
this.isPreview = false,
this.onReply,
this.onEdit,
this.onDelete,
});
final api_entries.EntryItem item;
final void Function(api_entries.EntryItem) onUpdate;
final Future<void> Function(String)? onReply;
final Future<void> Function(String)? onEdit;
final Future<void> Function()? onDelete;
final bool isPreview;
_onImageClick(BuildContext context) {
@ -208,6 +212,11 @@ class EntryItem extends StatelessWidget {
));
}),
onReply: onReply,
onEdit: isLoggedInUser(context, item.user.username, onEdit),
onDelete: isLoggedInUser(context, item.user.username, onDelete),
initEdit: () {
return item.body;
},
leadingWidgets: [
const Icon(Icons.comment),
const SizedBox(width: 4),

View File

@ -5,6 +5,7 @@ import 'package:interstellar/src/api/entries.dart' as api_entries;
import 'package:interstellar/src/screens/entries/entry_comment.dart';
import 'package:interstellar/src/screens/entries/entry_item.dart';
import 'package:interstellar/src/screens/settings/settings_controller.dart';
import 'package:interstellar/src/utils/utils.dart';
import 'package:provider/provider.dart';
class EntryPage extends StatefulWidget {
@ -89,6 +90,31 @@ class _EntryPageState extends State<EntryPage> {
_pagingController.itemList = newList;
});
},
onEdit: whenLoggedIn(context, (body) async {
final newEntry = await api_entries.editEntry(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().instanceHost,
widget.item.entryId,
widget.item.title,
widget.item.isOc,
body,
widget.item.lang,
widget.item.isAdult
);
setState(() {
widget.item.body = newEntry.body;
});
}),
onDelete: whenLoggedIn(context, () async {
await api_entries.deletePost(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().instanceHost,
widget.item.entryId,
);
setState(() {
widget.item.body = "deleted";
});
}),
),
),
SliverToBoxAdapter(

View File

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:interstellar/src/api/content_sources.dart';
import 'package:interstellar/src/api/magazines.dart' as api_magazines;
import 'package:interstellar/src/screens/entries/entries_list.dart';
import 'package:interstellar/src/screens/feed_screen.dart';
import 'package:interstellar/src/screens/posts/posts_list.dart';
import 'package:interstellar/src/screens/settings/settings_controller.dart';
import 'package:interstellar/src/utils/utils.dart';
import 'package:interstellar/src/widgets/avatar.dart';
@ -21,6 +23,7 @@ class MagazineScreen extends StatefulWidget {
class _MagazineScreenState extends State<MagazineScreen> {
api_magazines.DetailedMagazine? _data;
FeedMode _feedMode = FeedMode.entries;
@override
void initState() {
@ -41,14 +44,8 @@ class _MagazineScreenState extends State<MagazineScreen> {
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(_data?.name ?? '')),
body: EntriesListView(
contentSource: ContentMagazine(widget.magazineId),
details: _data != null
? Padding(
Widget _magazineDetails() {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -103,9 +100,53 @@ class _MagazineScreenState extends State<MagazineScreen> {
)
],
),
)
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_data?.name ?? ''),
actions: [
SegmentedButton(
segments: const [
ButtonSegment(
value: FeedMode.entries,
label: Text("Threads"),
),
ButtonSegment(
value: FeedMode.posts,
label: Text("Posts"),
),
],
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity(horizontal: -3, vertical: -3),
),
selected: <FeedMode>{_feedMode},
onSelectionChanged: (Set<FeedMode> newSelection) {
setState(() {
_feedMode = newSelection.first;
});
},
),
],
),
body: switch (_feedMode) {
FeedMode.entries => EntriesListView(
contentSource: ContentMagazine(widget.magazineId),
details: _data != null
? _magazineDetails()
: null,
),
FeedMode.posts => PostsListView(
contentSource: ContentMagazine(widget.magazineId),
details: _data != null
? _magazineDetails()
: null,
),
}
);
}
}

View File

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:interstellar/src/api/content_sources.dart';
import 'package:interstellar/src/api/users.dart' as api_users;
import 'package:interstellar/src/screens/entries/entries_list.dart';
import 'package:interstellar/src/screens/feed_screen.dart';
import 'package:interstellar/src/screens/posts/posts_list.dart';
import 'package:interstellar/src/screens/settings/settings_controller.dart';
import 'package:interstellar/src/utils/utils.dart';
import 'package:interstellar/src/widgets/avatar.dart';
@ -21,6 +23,7 @@ class UserScreen extends StatefulWidget {
class _UserScreenState extends State<UserScreen> {
api_users.DetailedUser? _data;
FeedMode _feedMode = FeedMode.entries;
@override
void initState() {
@ -41,14 +44,8 @@ class _UserScreenState extends State<UserScreen> {
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(_data?.username ?? '')),
body: EntriesListView(
contentSource: ContentUser(widget.userId),
details: _data != null
? Padding(
Widget _userDetails() {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -103,9 +100,53 @@ class _UserScreenState extends State<UserScreen> {
)
],
),
)
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_data?.username ?? ''),
actions: [
SegmentedButton(
segments: const [
ButtonSegment(
value: FeedMode.entries,
label: Text("Threads"),
),
ButtonSegment(
value: FeedMode.posts,
label: Text("Posts"),
),
],
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity(horizontal: -3, vertical: -3),
),
selected: <FeedMode>{_feedMode},
onSelectionChanged: (Set<FeedMode> newSelection) {
setState(() {
_feedMode = newSelection.first;
});
},
),
],
),
body: switch (_feedMode) {
FeedMode.entries => EntriesListView(
contentSource: ContentUser(widget.userId),
details: _data != null
? _userDetails()
: null,
),
FeedMode.posts => PostsListView(
contentSource: ContentUser(widget.userId),
details: _data != null
? _userDetails()
: null,
),
}
);
}
}

View File

@ -117,6 +117,29 @@ class _EntryCommentState extends State<PostComment> {
newComment.children!.insert(0, newSubComment);
widget.onUpdate(newComment);
},
onEdit: whenLoggedIn(context, (body) async {
var newComment = await api_comments.editComment(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().instanceHost,
widget.comment.commentId,
body,
widget.comment.lang,
widget.comment.isAdult
);
setState(() {
widget.comment.body = newComment.body;
});
}),
onDelete: whenLoggedIn(context, () async {
await api_comments.deleteComment(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().instanceHost,
widget.comment.commentId,
);
setState(() {
widget.comment.body = "deleted";
});
}),
),
),
const SizedBox(height: 4),

View File

@ -16,11 +16,15 @@ class PostItem extends StatelessWidget {
super.key,
this.isPreview = false,
this.onReply,
this.onEdit,
this.onDelete,
});
final api_posts.PostItem item;
final void Function(api_posts.PostItem) onUpdate;
final Future<void> Function(String)? onReply;
final Future<void> Function(String)? onEdit;
final Future<void> Function()? onDelete;
final bool isPreview;
_onImageClick(BuildContext context) {
@ -159,6 +163,11 @@ class PostItem extends StatelessWidget {
));
}),
onReply: onReply,
onEdit: isLoggedInUser(context, item.user.username, onEdit),
onDelete: isLoggedInUser(context, item.user.username, onDelete),
initEdit: () {
return item.body;
},
leadingWidgets: [
const Icon(Icons.comment),
const SizedBox(width: 4),

View File

@ -5,6 +5,7 @@ import 'package:interstellar/src/api/posts.dart' as api_posts;
import 'package:interstellar/src/screens/posts/post_comment.dart';
import 'package:interstellar/src/screens/posts/post_item.dart';
import 'package:interstellar/src/screens/settings/settings_controller.dart';
import 'package:interstellar/src/utils/utils.dart';
import 'package:provider/provider.dart';
class PostPage extends StatefulWidget {
@ -89,6 +90,29 @@ class _PostPageState extends State<PostPage> {
_pagingController.itemList = newList;
});
},
onEdit: whenLoggedIn(context, (body) async {
final newPost = await api_posts.editPost(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().instanceHost,
widget.item.postId,
body,
widget.item.lang,
widget.item.isAdult
);
setState(() {
widget.item.body = newPost.body;
});
}),
onDelete: whenLoggedIn(context, () async {
await api_posts.deletePost(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().instanceHost,
widget.item.postId,
);
setState(() {
widget.item.body = "deleted";
});
}),
),
),
SliverToBoxAdapter(

View File

@ -5,6 +5,7 @@ import 'package:interstellar/src/api/posts.dart' as api_posts;
import 'package:interstellar/src/screens/posts/post_item.dart';
import 'package:interstellar/src/screens/posts/post_page.dart';
import 'package:interstellar/src/screens/settings/settings_controller.dart';
import 'package:interstellar/src/utils/utils.dart';
import 'package:provider/provider.dart';
class PostsListView extends StatefulWidget {

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:http/http.dart' as http;
import 'package:interstellar/src/api/users.dart';
import 'package:interstellar/src/screens/settings/settings_controller.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
@ -74,3 +75,6 @@ Map<String, dynamic> removeNulls(Map<String, dynamic> map) {
});
return map;
}
T? isLoggedInUser<T>(BuildContext context, String username, T? value, {T? otherwise}) =>
context.read<SettingsController>().selectedAccount.split("@").first == username ? value : otherwise;

View File

@ -17,6 +17,9 @@ class ActionBar extends StatefulWidget {
final void Function()? onDownVote;
final void Function()? onCollapse;
final Future<void> Function(String)? onReply;
final Future<void> Function(String)? onEdit;
final void Function()? onDelete;
final String? Function()? initEdit;
final List<Widget>? leadingWidgets;
@ -34,6 +37,9 @@ class ActionBar extends StatefulWidget {
this.onDownVote,
this.onReply,
this.onCollapse,
this.onEdit,
this.onDelete,
this.initEdit,
this.leadingWidgets,
});
@ -43,6 +49,8 @@ class ActionBar extends StatefulWidget {
class _ActionBarState extends State<ActionBar> {
TextEditingController? _replyTextController;
TextEditingController? _editTextController;
final MenuController _menuController = MenuController();
@override
Widget build(BuildContext context) {
@ -71,9 +79,38 @@ class _ActionBarState extends State<ActionBar> {
const Spacer(),
Padding(
padding: const EdgeInsets.only(left: 12),
child: IconButton(
child: MenuAnchor(
builder: (BuildContext context, MenuController controller, Widget? child) {
return IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {},
onPressed: () {
if (_menuController.isOpen) {
_menuController.close();
} else {
_menuController.open();
}
},
);
},
controller: _menuController,
menuChildren: [
MenuItemButton(
onPressed: widget.onEdit != null ? () => setState(() {
_editTextController = TextEditingController();
}) : null,
child: const Padding(
padding: EdgeInsets.all(12),
child: Text("Edit")
),
),
MenuItemButton(
onPressed: widget.onDelete,
child: const Padding(
padding: EdgeInsets.all(12),
child: Text("Delete")
),
),
]
),
),
if (widget.boosts != null)
@ -146,6 +183,39 @@ class _ActionBarState extends State<ActionBar> {
)
],
),
),
if (widget.onEdit != null && _editTextController != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
MarkdownEditor(_editTextController!..text = (widget.initEdit != null ? widget.initEdit!() : "")!),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: () => setState(() {
_editTextController!.dispose();
_editTextController = null;
}),
child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton(
onPressed: () async {
// Wait in case of errors before closing
await widget.onEdit!(_editTextController!.text);
setState(() {
_editTextController!.dispose();
_editTextController = null;
});
},
child: const Text('Submit'))
],
)
],
),
)
],
);