lemmur-app-android/lib/widgets/comment.dart

529 lines
18 KiB
Dart
Raw Normal View History

2020-09-09 23:21:21 +02:00
import 'package:esys_flutter_share/esys_flutter_share.dart';
2020-09-21 20:19:14 +02:00
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
2020-09-16 20:09:09 +02:00
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
2021-01-24 21:50:49 +01:00
import 'package:lemmy_api_client/v2.dart';
2020-09-09 23:21:21 +02:00
import 'package:url_launcher/url_launcher.dart' as ul;
2020-08-31 20:10:05 +02:00
import '../comment_tree.dart';
2020-09-19 02:04:00 +02:00
import '../hooks/delayed_loading.dart';
import '../hooks/logged_in_action.dart';
2021-01-24 21:50:49 +01:00
import '../hooks/stores.dart';
2021-02-24 20:52:18 +01:00
import '../util/delayed_action.dart';
import '../util/extensions/api.dart';
import '../util/extensions/datetime.dart';
2020-09-09 23:21:21 +02:00
import '../util/goto.dart';
2020-09-19 12:28:46 +02:00
import '../util/intl.dart';
import '../util/text_color.dart';
2021-02-18 09:19:00 +01:00
import 'avatar.dart';
2020-09-09 23:21:21 +02:00
import 'bottom_modal.dart';
2020-10-26 18:47:49 +01:00
import 'info_table_popup.dart';
2021-02-24 20:52:18 +01:00
import 'markdown_mode_icon.dart';
2020-08-31 20:10:05 +02:00
import 'markdown_text.dart';
2021-02-24 20:52:18 +01:00
import 'tile_action.dart';
2020-09-21 13:20:25 +02:00
import 'write_comment.dart';
2020-09-30 19:05:00 +02:00
/// A single comment that renders its replies
2021-01-24 21:50:49 +01:00
class CommentWidget extends HookWidget {
2021-02-24 20:52:18 +01:00
final int depth;
final CommentTree commentTree;
final bool detached;
final bool wasVoted;
2021-02-24 20:52:18 +01:00
final bool canBeMarkedAsRead;
final bool hideOnRead;
2021-02-24 22:48:19 +01:00
final int userMentionId;
static const colors = [
Colors.pink,
Colors.green,
Colors.amber,
Colors.cyan,
Colors.indigo,
];
2021-01-24 21:50:49 +01:00
CommentWidget(
this.commentTree, {
2021-02-24 20:52:18 +01:00
this.depth = 0,
this.detached = false,
2021-02-24 20:52:18 +01:00
this.canBeMarkedAsRead = false,
this.hideOnRead = false,
2021-02-24 22:48:19 +01:00
this.userMentionId,
2021-02-24 22:33:21 +01:00
}) : wasVoted =
(commentTree.comment.myVote ?? VoteType.none) != VoteType.none;
2021-02-24 20:52:18 +01:00
CommentWidget.fromCommentView(
CommentView cv, {
bool canBeMarkedAsRead = false,
bool hideOnRead = false,
}) : this(
CommentTree(cv),
detached: true,
canBeMarkedAsRead: canBeMarkedAsRead,
hideOnRead: hideOnRead,
);
CommentWidget.fromUserMentionView(
2021-02-24 22:33:21 +01:00
UserMentionView userMentionView, {
bool hideOnRead = false,
2021-02-24 22:48:19 +01:00
}) : this(
CommentTree(CommentView.fromJson(userMentionView.toJson())),
2021-02-24 22:33:21 +01:00
hideOnRead: hideOnRead,
canBeMarkedAsRead: true,
2021-02-24 22:48:19 +01:00
detached: true,
userMentionId: userMentionView.userMention.id,
2021-02-24 22:33:21 +01:00
);
2020-09-09 23:21:21 +02:00
_showCommentInfo(BuildContext context) {
final com = commentTree.comment;
2021-02-24 20:52:18 +01:00
showInfoTablePopup(context: context, table: {
...com.toJson(),
2021-01-24 21:50:49 +01:00
'% of upvotes':
2021-02-09 15:12:13 +01:00
'${100 * (com.counts.upvotes / (com.counts.upvotes + com.counts.downvotes))}%',
2020-10-26 18:47:49 +01:00
});
2020-09-09 23:21:21 +02:00
}
2021-01-24 21:50:49 +01:00
bool get isOP =>
commentTree.comment.comment.creatorId ==
commentTree.comment.post.creatorId;
@override
Widget build(BuildContext context) {
2020-09-19 12:28:46 +02:00
final theme = Theme.of(context);
2021-01-24 21:50:49 +01:00
final accStore = useAccountsStore();
final isMine = commentTree.comment.comment.creatorId ==
accStore.defaultTokenFor(commentTree.comment.instanceHost)?.payload?.id;
2020-09-16 20:09:09 +02:00
final selectable = useState(false);
final showRaw = useState(false);
2020-09-19 12:01:12 +02:00
final collapsed = useState(false);
2020-09-19 12:28:46 +02:00
final myVote = useState(commentTree.comment.myVote ?? VoteType.none);
2021-01-24 21:50:49 +01:00
final isDeleted = useState(commentTree.comment.comment.deleted);
2021-02-24 20:52:18 +01:00
final isRead = useState(commentTree.comment.comment.read);
2020-09-19 12:28:46 +02:00
final delayedVoting = useDelayedLoading();
2020-10-05 14:37:43 +02:00
final delayedDeletion = useDelayedLoading();
final loggedInAction = useLoggedInAction(commentTree.comment.instanceHost);
2021-02-24 20:52:18 +01:00
2020-09-21 13:20:25 +02:00
final newReplies = useState(const <CommentTree>[]);
2020-08-31 13:39:27 +02:00
2020-09-16 20:09:09 +02:00
final comment = commentTree.comment;
2020-09-03 00:01:04 +02:00
2021-02-24 20:52:18 +01:00
if (hideOnRead && isRead.value) {
return const SizedBox.shrink();
}
2020-10-05 14:37:43 +02:00
2021-02-24 20:52:18 +01:00
handleDelete(Jwt token) {
2020-10-05 14:37:43 +02:00
Navigator.of(context).pop();
2021-02-24 20:52:18 +01:00
delayedAction<FullCommentView>(
context: context,
delayedLoading: delayedDeletion,
instanceHost: token.payload.iss,
query: DeleteComment(
2021-01-24 21:50:49 +01:00
commentId: comment.comment.id,
deleted: !isDeleted.value,
auth: token.raw,
2021-02-24 20:52:18 +01:00
),
onSuccess: (res) => isDeleted.value = res.commentView.comment.deleted,
);
2020-10-05 14:37:43 +02:00
}
2020-09-16 20:09:09 +02:00
void _openMoreMenu(BuildContext context) {
2020-09-16 21:21:47 +02:00
pop() => Navigator.of(context).pop();
2020-09-16 20:09:09 +02:00
final com = commentTree.comment;
2021-02-09 15:12:13 +01:00
showBottomModal(
2020-09-16 20:09:09 +02:00
context: context,
2021-02-09 15:12:13 +01:00
builder: (context) => Column(
children: [
ListTile(
leading: const Icon(Icons.open_in_browser),
title: const Text('Open in browser'),
onTap: () async => await ul.canLaunch(com.comment.link)
? ul.launch(com.comment.link)
: Scaffold.of(context).showSnackBar(
const SnackBar(content: Text("can't open in browser"))),
),
ListTile(
leading: const Icon(Icons.share),
title: const Text('Share url'),
onTap: () => Share.text(
'Share comment url', com.comment.link, 'text/plain'),
),
ListTile(
leading: const Icon(Icons.share),
title: const Text('Share text'),
onTap: () => Share.text(
'Share comment text', com.comment.content, 'text/plain'),
),
ListTile(
leading:
Icon(selectable.value ? Icons.assignment : Icons.content_cut),
title:
Text('Make text ${selectable.value ? 'un' : ''}selectable'),
onTap: () {
selectable.value = !selectable.value;
pop();
},
),
ListTile(
2021-02-24 20:52:18 +01:00
leading: markdownModeIcon(fancy: !showRaw.value),
2021-02-09 15:12:13 +01:00
title: Text('Show ${showRaw.value ? 'fancy' : 'raw'} text'),
onTap: () {
showRaw.value = !showRaw.value;
pop();
},
),
if (isMine)
2020-09-16 20:09:09 +02:00
ListTile(
2021-02-09 15:12:13 +01:00
leading: Icon(isDeleted.value ? Icons.restore : Icons.delete),
title: Text(isDeleted.value ? 'Restore' : 'Delete'),
onTap: loggedInAction(handleDelete),
2020-09-16 20:09:09 +02:00
),
2021-02-09 15:12:13 +01:00
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Nerd stuff'),
onTap: () => _showCommentInfo(context),
),
],
2020-09-16 20:09:09 +02:00
),
);
}
2020-09-21 13:20:25 +02:00
reply() async {
2020-09-21 20:18:57 +02:00
final newComment = await showCupertinoModalPopup<CommentView>(
2020-09-21 13:20:25 +02:00
context: context,
2021-02-24 20:52:18 +01:00
builder: (_) => WriteComment.toComment(
comment: comment.comment, post: comment.post),
2020-09-21 13:20:25 +02:00
);
if (newComment != null) {
newReplies.value = [...newReplies.value, CommentTree(newComment)];
}
2020-09-16 20:09:09 +02:00
}
2021-02-24 20:52:18 +01:00
vote(VoteType vote, Jwt token) => delayedAction<FullCommentView>(
context: context,
delayedLoading: delayedVoting,
instanceHost: token.payload.iss,
query: CreateCommentLike(
commentId: comment.comment.id,
score: vote,
auth: token.raw,
),
onSuccess: (res) =>
myVote.value = res.commentView.myVote ?? VoteType.none,
);
2020-09-16 20:09:09 +02:00
2020-09-01 14:17:56 +02:00
final body = () {
2020-10-05 14:37:43 +02:00
if (isDeleted.value) {
2021-01-03 19:43:39 +01:00
return const Flexible(
2020-09-19 12:28:46 +02:00
child: Text(
'comment deleted by creator',
style: TextStyle(fontStyle: FontStyle.italic),
),
);
2021-01-24 21:50:49 +01:00
} else if (comment.comment.removed) {
2021-01-03 19:43:39 +01:00
return const Flexible(
2020-09-19 12:01:12 +02:00
child: Text(
'comment deleted by moderator',
style: TextStyle(fontStyle: FontStyle.italic),
),
);
} else if (collapsed.value) {
return Flexible(
child: Opacity(
opacity: 0.3,
2020-09-01 14:17:56 +02:00
child: Text(
2021-01-24 21:50:49 +01:00
commentTree.comment.comment.content,
2020-09-19 12:01:12 +02:00
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
);
2020-09-01 14:17:56 +02:00
} else {
2020-09-19 14:05:58 +02:00
// TODO: bug, the text is selectable even when disabled after following
// these steps:
// make selectable > show raw > show fancy > make unselectable
return Flexible(
2020-09-16 20:09:09 +02:00
child: showRaw.value
? selectable.value
2021-01-24 21:50:49 +01:00
? SelectableText(commentTree.comment.comment.content)
: Text(commentTree.comment.comment.content)
2020-09-16 20:09:09 +02:00
: MarkdownText(
2021-01-24 21:50:49 +01:00
commentTree.comment.comment.content,
instanceHost: commentTree.comment.instanceHost,
2020-09-16 20:09:09 +02:00
selectable: selectable.value,
));
2020-09-01 14:17:56 +02:00
}
}();
2020-09-19 12:01:12 +02:00
final actions = collapsed.value
2021-01-03 19:43:39 +01:00
? const SizedBox.shrink()
2020-09-19 12:01:12 +02:00
: Row(children: [
2021-01-24 21:50:49 +01:00
if (selectable.value &&
!isDeleted.value &&
!comment.comment.removed)
2021-02-24 20:52:18 +01:00
TileAction(
2020-09-19 12:01:12 +02:00
icon: Icons.content_copy,
tooltip: 'copy',
onPressed: () {
2021-01-24 21:50:49 +01:00
Clipboard.setData(ClipboardData(
text: commentTree.comment.comment.content))
2021-01-03 19:43:39 +01:00
.then((_) => Scaffold.of(context).showSnackBar(
const SnackBar(
content: Text('comment copied to clipboard'))));
2020-09-19 12:01:12 +02:00
}),
2021-01-03 19:43:39 +01:00
const Spacer(),
2021-02-24 20:52:18 +01:00
if (canBeMarkedAsRead)
_MarkAsRead(
commentTree.comment,
onChanged: (val) => isRead.value = val,
2021-02-24 22:48:19 +01:00
userMentionId: userMentionId,
2021-02-24 20:52:18 +01:00
),
if (detached)
2021-02-24 20:52:18 +01:00
TileAction(
2021-01-16 15:20:49 +01:00
icon: Icons.link,
onPressed: () =>
2021-01-24 21:50:49 +01:00
goToPost(context, comment.instanceHost, comment.post.id),
2021-01-16 15:20:49 +01:00
tooltip: 'go to post',
),
2021-02-24 20:52:18 +01:00
TileAction(
2020-09-19 12:01:12 +02:00
icon: Icons.more_horiz,
onPressed: () => _openMoreMenu(context),
2021-02-24 20:52:18 +01:00
delayedLoading: delayedDeletion,
2020-09-19 12:01:12 +02:00
tooltip: 'more',
),
_SaveComment(commentTree.comment),
2021-01-24 21:50:49 +01:00
if (!isDeleted.value && !comment.comment.removed)
2021-02-24 20:52:18 +01:00
TileAction(
icon: Icons.reply,
onPressed: loggedInAction((_) => reply()),
tooltip: 'reply',
),
2021-02-24 20:52:18 +01:00
TileAction(
2020-09-19 12:01:12 +02:00
icon: Icons.arrow_upward,
2020-09-19 12:28:46 +02:00
iconColor: myVote.value == VoteType.up ? theme.accentColor : null,
onPressed: loggedInAction((token) => vote(
myVote.value == VoteType.up ? VoteType.none : VoteType.up,
token,
)),
2020-09-19 12:01:12 +02:00
tooltip: 'upvote',
),
2021-02-24 20:52:18 +01:00
TileAction(
2020-09-19 12:01:12 +02:00
icon: Icons.arrow_downward,
2020-09-19 12:28:46 +02:00
iconColor: myVote.value == VoteType.down ? Colors.red : null,
onPressed: loggedInAction(
(token) => vote(
myVote.value == VoteType.down ? VoteType.none : VoteType.down,
token,
),
),
2020-09-19 12:01:12 +02:00
tooltip: 'downvote',
),
]);
2020-09-16 20:09:09 +02:00
2021-02-24 20:52:18 +01:00
return InkWell(
onLongPress:
selectable.value ? null : () => collapsed.value = !collapsed.value,
2020-09-19 12:01:12 +02:00
child: Column(
children: [
Container(
2021-01-03 19:43:39 +01:00
padding: const EdgeInsets.all(10),
2021-02-24 20:52:18 +01:00
margin: EdgeInsets.only(left: depth > 1 ? (depth - 1) * 5.0 : 0),
2021-01-03 18:13:25 +01:00
decoration: BoxDecoration(
border: Border(
2021-02-24 20:52:18 +01:00
left: depth > 0
2021-01-03 18:13:25 +01:00
? BorderSide(
2021-02-24 20:52:18 +01:00
color: colors[depth % colors.length], width: 5)
2021-01-03 18:13:25 +01:00
: BorderSide.none,
2021-01-03 19:43:39 +01:00
top: const BorderSide(width: 0.2))),
2020-09-19 12:01:12 +02:00
child: Column(
children: [
Row(children: [
2021-01-24 21:50:49 +01:00
if (comment.creator.avatar != null)
2020-09-19 12:01:12 +02:00
Padding(
padding: const EdgeInsets.only(right: 5),
child: InkWell(
2021-02-24 20:52:18 +01:00
onTap: () =>
goToUser.fromUserSafe(context, comment.creator),
2021-02-18 09:19:00 +01:00
child: Avatar(
url: comment.creator.avatar,
radius: 10,
noBlank: true,
2020-08-31 15:29:02 +02:00
),
),
),
2020-09-19 12:01:12 +02:00
InkWell(
2021-02-24 20:52:18 +01:00
onTap: () =>
goToUser.fromUserSafe(context, comment.creator),
2021-01-27 21:11:36 +01:00
child: Text(comment.creator.originDisplayName,
2020-09-19 12:01:12 +02:00
style: TextStyle(
2021-02-24 20:52:18 +01:00
color: theme.accentColor,
2020-09-19 12:01:12 +02:00
)),
2020-08-31 15:29:02 +02:00
),
2021-02-24 20:52:18 +01:00
if (isOP) _CommentTag('OP', theme.accentColor),
if (comment.creator.admin)
_CommentTag('ADMIN', theme.accentColor),
2021-01-24 21:50:49 +01:00
if (comment.creator.banned)
const _CommentTag('BANNED', Colors.red),
if (comment.creatorBannedFromCommunity)
2021-01-03 19:43:39 +01:00
const _CommentTag('BANNED FROM COMMUNITY', Colors.red),
const Spacer(),
2021-01-26 23:51:02 +01:00
if (collapsed.value && commentTree.children.isNotEmpty) ...[
_CommentTag('+${commentTree.children.length}',
Theme.of(context).accentColor),
2021-01-03 19:43:39 +01:00
const SizedBox(width: 7),
],
2020-09-19 12:01:12 +02:00
InkWell(
onTap: () => _showCommentInfo(context),
child: Row(
children: [
2020-09-19 21:51:22 +02:00
if (delayedVoting.loading)
SizedBox.fromSize(
2021-01-03 19:43:39 +01:00
size: const Size.square(16),
child: const CircularProgressIndicator())
2020-09-19 21:51:22 +02:00
else
2021-01-24 21:50:49 +01:00
Text(compactNumber(comment.counts.score +
(wasVoted ? 0 : myVote.value.value))),
2021-01-03 19:43:39 +01:00
const Text(' · '),
Text(comment.comment.published.fancy),
2020-09-19 12:01:12 +02:00
],
),
)
]),
2021-01-03 19:43:39 +01:00
const SizedBox(height: 10),
2020-09-19 12:01:12 +02:00
Row(children: [body]),
2021-01-03 19:43:39 +01:00
const SizedBox(height: 5),
2020-09-19 12:01:12 +02:00
actions,
],
),
2020-08-31 15:29:02 +02:00
),
2020-09-19 12:01:12 +02:00
if (!collapsed.value)
2020-09-21 13:20:25 +02:00
for (final c in newReplies.value.followedBy(commentTree.children))
2021-02-24 20:52:18 +01:00
CommentWidget(c, depth: depth + 1),
2020-09-19 12:01:12 +02:00
],
),
);
}
}
2020-08-31 15:29:02 +02:00
2021-02-24 20:52:18 +01:00
class _MarkAsRead extends HookWidget {
final CommentView commentView;
final ValueChanged<bool> onChanged;
2021-02-24 22:48:19 +01:00
final int userMentionId;
2021-02-24 20:52:18 +01:00
2021-02-24 22:48:19 +01:00
const _MarkAsRead(
this.commentView, {
@required this.onChanged,
@required this.userMentionId,
}) : assert(commentView != null);
2021-02-24 20:52:18 +01:00
@override
Widget build(BuildContext context) {
final accStore = useAccountsStore();
final comment = commentView.comment;
final instanceHost = commentView.instanceHost;
final isRead = useState(comment.read);
final delayedRead = useDelayedLoading();
Future<void> handleMarkAsSeen() => delayedAction<FullCommentView>(
context: context,
delayedLoading: delayedRead,
instanceHost: instanceHost,
query: MarkCommentAsRead(
commentId: comment.id,
read: !isRead.value,
2021-02-24 22:33:21 +01:00
auth: accStore.defaultTokenFor(instanceHost)?.raw,
2021-02-24 20:52:18 +01:00
),
onSuccess: (val) {
isRead.value = val.commentView.comment.read;
onChanged?.call(isRead.value);
},
);
2021-02-24 22:48:19 +01:00
Future<void> handleMarkMentionAsSeen() => delayedAction<UserMentionView>(
context: context,
delayedLoading: delayedRead,
instanceHost: instanceHost,
query: MarkUserMentionAsRead(
userMentionId: userMentionId,
read: !isRead.value,
auth: accStore.defaultTokenFor(instanceHost)?.raw,
),
onSuccess: (val) {
isRead.value = val.userMention.read;
onChanged?.call(isRead.value);
},
);
2021-02-24 20:52:18 +01:00
return TileAction(
icon: Icons.check,
delayedLoading: delayedRead,
2021-02-24 22:48:19 +01:00
onPressed:
userMentionId != null ? handleMarkMentionAsSeen : handleMarkAsSeen,
2021-02-24 20:52:18 +01:00
iconColor: isRead.value ? Theme.of(context).accentColor : null,
tooltip: 'mark as ${isRead.value ? 'un' : ''}read',
);
}
}
2020-09-19 02:04:00 +02:00
class _SaveComment extends HookWidget {
final CommentView comment;
2021-01-03 18:21:56 +01:00
const _SaveComment(this.comment);
2020-09-19 02:04:00 +02:00
@override
Widget build(BuildContext context) {
final loggedInAction = useLoggedInAction(comment.instanceHost);
2020-09-19 02:04:00 +02:00
final isSaved = useState(comment.saved ?? false);
2021-01-03 18:03:59 +01:00
final delayed = useDelayedLoading();
2020-09-19 02:04:00 +02:00
2021-02-24 20:52:18 +01:00
handleSave(Jwt token) => delayedAction<FullCommentView>(
context: context,
delayedLoading: delayed,
instanceHost: comment.instanceHost,
query: SaveComment(
commentId: comment.comment.id,
save: !isSaved.value,
auth: token.raw,
),
onSuccess: (res) => isSaved.value = res.commentView.saved,
);
2020-09-19 02:04:00 +02:00
2021-02-24 20:52:18 +01:00
return TileAction(
delayedLoading: delayed,
2020-09-19 02:04:00 +02:00
icon: isSaved.value ? Icons.bookmark : Icons.bookmark_border,
onPressed: loggedInAction(delayed.pending ? (_) {} : handleSave),
tooltip: '${isSaved.value ? 'unsave' : 'save'} comment',
);
}
}
2020-09-03 00:10:36 +02:00
class _CommentTag extends StatelessWidget {
2020-08-31 15:29:02 +02:00
final String text;
final Color bgColor;
2020-09-03 00:10:36 +02:00
const _CommentTag(this.text, this.bgColor);
2020-08-31 15:29:02 +02:00
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(left: 5),
child: Container(
decoration: BoxDecoration(
2021-01-03 19:43:39 +01:00
borderRadius: const BorderRadius.all(Radius.circular(5)),
2020-08-31 15:29:02 +02:00
color: bgColor,
),
2021-01-03 19:43:39 +01:00
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
2020-08-31 15:29:02 +02:00
child: Text(text,
style: TextStyle(
color: textColorBasedOnBackground(bgColor),
2020-08-31 15:29:02 +02:00
fontSize: Theme.of(context).textTheme.bodyText1.fontSize - 5,
fontWeight: FontWeight.w800,
)),
),
);
}