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