1 import { Component, linkEvent } from 'inferno';
2 import { Link } from 'inferno-router';
4 CommentNode as CommentNodeI,
6 CommentForm as CommentFormI,
13 AddModToCommunityForm,
15 TransferCommunityForm,
20 } from '../interfaces';
21 import { WebSocketService, UserService } from '../services';
27 pictshareAvatarThumbnail,
32 import moment from 'moment';
33 import { MomentTime } from './moment-time';
34 import { CommentForm } from './comment-form';
35 import { CommentNodes } from './comment-nodes';
36 import { i18n } from '../i18next';
38 interface CommentNodeState {
41 showRemoveDialog: boolean;
43 showBanDialog: boolean;
47 showConfirmTransferSite: boolean;
48 showConfirmTransferCommunity: boolean;
49 showConfirmAppointAsMod: boolean;
50 showConfirmAppointAsAdmin: boolean;
53 showAdvanced: boolean;
61 interface CommentNodeProps {
67 moderators: Array<CommunityUser>;
68 admins: Array<UserView>;
69 // TODO is this necessary, can't I get it from the node itself?
70 postCreatorId?: number;
71 showCommunity?: boolean;
72 sort?: CommentSortType;
76 export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
77 private emptyState: CommentNodeState = {
80 showRemoveDialog: false,
85 banType: BanType.Community,
89 showConfirmTransferSite: false,
90 showConfirmTransferCommunity: false,
91 showConfirmAppointAsMod: false,
92 showConfirmAppointAsAdmin: false,
93 my_vote: this.props.node.comment.my_vote,
94 score: this.props.node.comment.score,
95 upvotes: this.props.node.comment.upvotes,
96 downvotes: this.props.node.comment.downvotes,
97 borderColor: this.props.node.comment.depth
98 ? colorList[this.props.node.comment.depth % colorList.length]
102 constructor(props: any, context: any) {
103 super(props, context);
105 this.state = this.emptyState;
106 this.handleReplyCancel = this.handleReplyCancel.bind(this);
107 this.handleCommentUpvote = this.handleCommentUpvote.bind(this);
108 this.handleCommentDownvote = this.handleCommentDownvote.bind(this);
111 componentWillReceiveProps(nextProps: CommentNodeProps) {
112 this.state.my_vote = nextProps.node.comment.my_vote;
113 this.state.upvotes = nextProps.node.comment.upvotes;
114 this.state.downvotes = nextProps.node.comment.downvotes;
115 this.state.score = nextProps.node.comment.score;
116 this.setState(this.state);
120 let node = this.props.node;
123 className={`comment ${
124 node.comment.parent_id && !this.props.noIndent ? 'ml-1' : ''
128 id={`comment-${node.comment.id}`}
129 className={`details comment-node border-top border-light ${
130 this.isCommentNew ? 'mark' : ''
133 !this.props.noIndent &&
134 this.props.node.comment.parent_id &&
135 `border-left: 2px ${this.state.borderColor} solid !important`
139 class={`${!this.props.noIndent &&
140 this.props.node.comment.parent_id &&
143 <ul class="list-inline mb-1 text-muted small">
144 <li className="mt-1 list-inline-item">
146 className="text-body font-weight-bold"
147 to={`/u/${node.comment.creator_name}`}
149 {node.comment.creator_avatar && showAvatars() && (
153 src={pictshareAvatarThumbnail(
154 node.comment.creator_avatar
156 class="rounded-circle mr-1"
159 <span>{node.comment.creator_name}</span>
163 <li className="list-inline-item badge badge-light">
168 <li className="list-inline-item badge badge-light">
172 {this.isPostCreator && (
173 <li className="list-inline-item badge badge-light">
177 {(node.comment.banned_from_community || node.comment.banned) && (
178 <li className="list-inline-item badge badge-danger">
182 <li className="list-inline-item">
184 className="unselectable pointer text-monospace"
185 onClick={linkEvent(this, this.handleCommentCollapse)}
187 {this.state.collapsed ? (
188 <svg class="icon icon-inline">
189 <use xlinkHref="#icon-plus-square"></use>
192 <svg class="icon icon-inline">
193 <use xlinkHref="#icon-minus-square"></use>
198 {this.props.showCommunity && (
199 <li className="list-inline-item">
200 <span> {i18n.t('to')} </span>
201 <Link to={`/c/${node.comment.community_name}`}>
202 {node.comment.community_name}
206 <li className="list-inline-item">•</li>
207 <li className="list-inline-item">
209 className={`unselectable pointer ${this.scoreColor}`}
210 onClick={linkEvent(node, this.handleCommentUpvote)}
211 data-tippy-content={this.pointsTippy}
213 <svg class="icon icon-inline mr-1">
214 <use xlinkHref="#icon-zap"></use>
219 <li className="list-inline-item">•</li>
220 <li className="list-inline-item">
222 <MomentTime data={node.comment} />
226 {this.state.showEdit && (
230 onReplyCancel={this.handleReplyCancel}
231 disabled={this.props.locked}
234 {!this.state.showEdit && !this.state.collapsed && (
236 {this.state.viewSource ? (
237 <pre>{this.commentUnlessRemoved}</pre>
241 dangerouslySetInnerHTML={mdToHtml(
242 this.commentUnlessRemoved
246 <ul class="list-inline mb-0 text-muted font-weight-bold small">
247 {this.props.markable && (
248 <li className="list-inline-item">
250 class="btn btn-link btn-sm btn-animate text-muted"
251 onClick={linkEvent(this, this.handleMarkRead)}
254 ? i18n.t('mark_as_unread')
255 : i18n.t('mark_as_read')
259 class={`icon icon-inline ${node.comment.read &&
262 <use xlinkHref="#icon-check"></use>
267 {UserService.Instance.user && !this.props.viewOnly && (
269 <li className="list-inline-item">
271 className={`btn btn-link btn-sm btn-animate ${
272 this.state.my_vote == 1 ? 'text-info' : 'text-muted'
274 onClick={linkEvent(node, this.handleCommentUpvote)}
275 data-tippy-content={i18n.t('upvote')}
277 <svg class="icon icon-inline">
278 <use xlinkHref="#icon-arrow-up"></use>
280 {this.state.upvotes !== this.state.score && (
281 <span class="ml-1">{this.state.upvotes}</span>
285 {WebSocketService.Instance.site.enable_downvotes && (
286 <li className="list-inline-item">
288 className={`btn btn-link btn-sm btn-animate ${
289 this.state.my_vote == -1
295 this.handleCommentDownvote
297 data-tippy-content={i18n.t('downvote')}
299 <svg class="icon icon-inline">
300 <use xlinkHref="#icon-arrow-down"></use>
302 {this.state.upvotes !== this.state.score && (
303 <span class="ml-1">{this.state.downvotes}</span>
308 <li className="list-inline-item">
310 class="btn btn-link btn-sm btn-animate text-muted"
311 onClick={linkEvent(this, this.handleReplyClick)}
312 data-tippy-content={i18n.t('reply')}
314 <svg class="icon icon-inline">
315 <use xlinkHref="#icon-reply1"></use>
319 <li className="list-inline-item">
321 className="btn btn-link btn-sm btn-animate text-muted"
322 to={`/post/${node.comment.post_id}/comment/${node.comment.id}`}
323 title={i18n.t('link')}
325 <svg class="icon icon-inline">
326 <use xlinkHref="#icon-link"></use>
330 {!this.state.showAdvanced ? (
331 <li className="list-inline-item">
333 className="btn btn-link btn-sm btn-animate text-muted"
334 onClick={linkEvent(this, this.handleShowAdvanced)}
335 data-tippy-content={i18n.t('more')}
337 <svg class="icon icon-inline">
338 <use xlinkHref="#icon-more-vertical"></use>
344 {!this.myComment && (
345 <li className="list-inline-item">
347 class="btn btn-link btn-sm btn-animate text-muted"
348 to={`/create_private_message?recipient_id=${node.comment.creator_id}`}
349 title={i18n.t('message').toLowerCase()}
352 <use xlinkHref="#icon-mail"></use>
357 <li className="list-inline-item">
359 class="btn btn-link btn-sm btn-animate text-muted"
362 this.handleSaveCommentClick
371 class={`icon icon-inline ${node.comment.saved &&
374 <use xlinkHref="#icon-star"></use>
378 <li className="list-inline-item">
380 className="btn btn-link btn-sm btn-animate text-muted"
381 onClick={linkEvent(this, this.handleViewSource)}
382 data-tippy-content={i18n.t('view_source')}
385 class={`icon icon-inline ${this.state
386 .viewSource && 'text-success'}`}
388 <use xlinkHref="#icon-file-text"></use>
394 <li className="list-inline-item">•</li>
395 <li className="list-inline-item">
397 class="btn btn-link btn-sm btn-animate text-muted"
402 data-tippy-content={i18n.t('edit')}
404 <svg class="icon icon-inline">
405 <use xlinkHref="#icon-edit"></use>
409 <li className="list-inline-item">
411 class="btn btn-link btn-sm btn-animate text-muted"
414 this.handleDeleteClick
417 !node.comment.deleted
423 class={`icon icon-inline ${node.comment
424 .deleted && 'text-danger'}`}
426 <use xlinkHref="#icon-trash"></use>
432 {/* Admins and mods can remove comments */}
433 {(this.canMod || this.canAdmin) && (
435 <li className="list-inline-item">
436 {!node.comment.removed ? (
441 this.handleModRemoveShow
451 this.handleModRemoveSubmit
460 {/* Mods can ban from community, and appoint as mods to community */}
464 <li className="list-inline-item">
465 {!node.comment.banned_from_community ? (
470 this.handleModBanFromCommunityShow
480 this.handleModBanFromCommunitySubmit
488 {!node.comment.banned_from_community && (
489 <li className="list-inline-item">
490 {!this.state.showConfirmAppointAsMod ? (
495 this.handleShowConfirmAppointAsMod
499 ? i18n.t('remove_as_mod')
500 : i18n.t('appoint_as_mod')}
504 <span class="d-inline-block mr-1">
505 {i18n.t('are_you_sure')}
508 class="pointer d-inline-block mr-1"
511 this.handleAddModToCommunity
517 class="pointer d-inline-block"
520 this.handleCancelConfirmAppointAsMod
531 {/* Community creators and admins can transfer community to another mod */}
532 {(this.amCommunityCreator || this.canAdmin) &&
534 <li className="list-inline-item">
535 {!this.state.showConfirmTransferCommunity ? (
540 this.handleShowConfirmTransferCommunity
543 {i18n.t('transfer_community')}
547 <span class="d-inline-block mr-1">
548 {i18n.t('are_you_sure')}
551 class="pointer d-inline-block mr-1"
554 this.handleTransferCommunity
560 class="pointer d-inline-block"
564 .handleCancelShowConfirmTransferCommunity
573 {/* Admins can ban from all, and appoint other admins */}
577 <li className="list-inline-item">
578 {!node.comment.banned ? (
583 this.handleModBanShow
586 {i18n.t('ban_from_site')}
593 this.handleModBanSubmit
596 {i18n.t('unban_from_site')}
601 {!node.comment.banned && (
602 <li className="list-inline-item">
603 {!this.state.showConfirmAppointAsAdmin ? (
608 this.handleShowConfirmAppointAsAdmin
612 ? i18n.t('remove_as_admin')
613 : i18n.t('appoint_as_admin')}
617 <span class="d-inline-block mr-1">
618 {i18n.t('are_you_sure')}
621 class="pointer d-inline-block mr-1"
630 class="pointer d-inline-block"
633 this.handleCancelConfirmAppointAsAdmin
644 {/* Site Creator can transfer to another admin */}
645 {this.amSiteCreator && this.isAdmin && (
646 <li className="list-inline-item">
647 {!this.state.showConfirmTransferSite ? (
652 this.handleShowConfirmTransferSite
655 {i18n.t('transfer_site')}
659 <span class="d-inline-block mr-1">
660 {i18n.t('are_you_sure')}
663 class="pointer d-inline-block mr-1"
666 this.handleTransferSite
672 class="pointer d-inline-block"
675 this.handleCancelShowConfirmTransferSite
693 {/* end of details */}
694 {this.state.showRemoveDialog && (
697 onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
701 class="form-control mr-2"
702 placeholder={i18n.t('reason')}
703 value={this.state.removeReason}
704 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
706 <button type="submit" class="btn btn-secondary">
707 {i18n.t('remove_comment')}
711 {this.state.showBanDialog && (
712 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
713 <div class="form-group row">
714 <label class="col-form-label">{i18n.t('reason')}</label>
717 class="form-control mr-2"
718 placeholder={i18n.t('reason')}
719 value={this.state.banReason}
720 onInput={linkEvent(this, this.handleModBanReasonChange)}
723 {/* TODO hold off on expires until later */}
724 {/* <div class="form-group row"> */}
725 {/* <label class="col-form-label">Expires</label> */}
726 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
728 <div class="form-group row">
729 <button type="submit" class="btn btn-secondary">
730 {i18n.t('ban')} {node.comment.creator_name}
735 {this.state.showReply && (
738 onReplyCancel={this.handleReplyCancel}
739 disabled={this.props.locked}
742 {node.children && !this.state.collapsed && (
744 nodes={node.children}
745 locked={this.props.locked}
746 moderators={this.props.moderators}
747 admins={this.props.admins}
748 postCreatorId={this.props.postCreatorId}
749 sort={this.props.sort}
750 sortType={this.props.sortType}
753 {/* A collapsed clearfix */}
754 {this.state.collapsed && <div class="row col-12"></div>}
759 get myComment(): boolean {
761 UserService.Instance.user &&
762 this.props.node.comment.creator_id == UserService.Instance.user.id
766 get isMod(): boolean {
768 this.props.moderators &&
770 this.props.moderators.map(m => m.user_id),
771 this.props.node.comment.creator_id
776 get isAdmin(): boolean {
780 this.props.admins.map(a => a.id),
781 this.props.node.comment.creator_id
786 get isPostCreator(): boolean {
787 return this.props.node.comment.creator_id == this.props.postCreatorId;
790 get canMod(): boolean {
791 if (this.props.admins && this.props.moderators) {
792 let adminsThenMods = this.props.admins
794 .concat(this.props.moderators.map(m => m.user_id));
797 UserService.Instance.user,
799 this.props.node.comment.creator_id
806 get canAdmin(): boolean {
810 UserService.Instance.user,
811 this.props.admins.map(a => a.id),
812 this.props.node.comment.creator_id
817 get amCommunityCreator(): boolean {
819 this.props.moderators &&
820 UserService.Instance.user &&
821 this.props.node.comment.creator_id != UserService.Instance.user.id &&
822 UserService.Instance.user.id == this.props.moderators[0].user_id
826 get amSiteCreator(): boolean {
829 UserService.Instance.user &&
830 this.props.node.comment.creator_id != UserService.Instance.user.id &&
831 UserService.Instance.user.id == this.props.admins[0].id
835 get commentUnlessRemoved(): string {
836 let node = this.props.node;
837 return node.comment.removed
838 ? `*${i18n.t('removed')}*`
839 : node.comment.deleted
840 ? `*${i18n.t('deleted')}*`
841 : node.comment.content;
844 handleReplyClick(i: CommentNode) {
845 i.state.showReply = true;
849 handleEditClick(i: CommentNode) {
850 i.state.showEdit = true;
854 handleDeleteClick(i: CommentNode) {
855 let deleteForm: CommentFormI = {
856 content: i.props.node.comment.content,
857 edit_id: i.props.node.comment.id,
858 creator_id: i.props.node.comment.creator_id,
859 post_id: i.props.node.comment.post_id,
860 parent_id: i.props.node.comment.parent_id,
861 deleted: !i.props.node.comment.deleted,
864 WebSocketService.Instance.editComment(deleteForm);
867 handleSaveCommentClick(i: CommentNode) {
869 i.props.node.comment.saved == undefined
871 : !i.props.node.comment.saved;
872 let form: SaveCommentForm = {
873 comment_id: i.props.node.comment.id,
877 WebSocketService.Instance.saveComment(form);
880 handleReplyCancel() {
881 this.state.showReply = false;
882 this.state.showEdit = false;
883 this.setState(this.state);
886 handleCommentUpvote(i: CommentNodeI) {
887 let new_vote = this.state.my_vote == 1 ? 0 : 1;
889 if (this.state.my_vote == 1) {
891 this.state.upvotes--;
892 } else if (this.state.my_vote == -1) {
893 this.state.downvotes--;
894 this.state.upvotes++;
895 this.state.score += 2;
897 this.state.upvotes++;
901 this.state.my_vote = new_vote;
903 let form: CommentLikeForm = {
904 comment_id: i.comment.id,
905 post_id: i.comment.post_id,
906 score: this.state.my_vote,
909 WebSocketService.Instance.likeComment(form);
910 this.setState(this.state);
914 handleCommentDownvote(i: CommentNodeI) {
915 let new_vote = this.state.my_vote == -1 ? 0 : -1;
917 if (this.state.my_vote == 1) {
918 this.state.score -= 2;
919 this.state.upvotes--;
920 this.state.downvotes++;
921 } else if (this.state.my_vote == -1) {
922 this.state.downvotes--;
925 this.state.downvotes++;
929 this.state.my_vote = new_vote;
931 let form: CommentLikeForm = {
932 comment_id: i.comment.id,
933 post_id: i.comment.post_id,
934 score: this.state.my_vote,
937 WebSocketService.Instance.likeComment(form);
938 this.setState(this.state);
942 handleModRemoveShow(i: CommentNode) {
943 i.state.showRemoveDialog = true;
947 handleModRemoveReasonChange(i: CommentNode, event: any) {
948 i.state.removeReason = event.target.value;
952 handleModRemoveSubmit(i: CommentNode) {
953 event.preventDefault();
954 let form: CommentFormI = {
955 content: i.props.node.comment.content,
956 edit_id: i.props.node.comment.id,
957 creator_id: i.props.node.comment.creator_id,
958 post_id: i.props.node.comment.post_id,
959 parent_id: i.props.node.comment.parent_id,
960 removed: !i.props.node.comment.removed,
961 reason: i.state.removeReason,
964 WebSocketService.Instance.editComment(form);
966 i.state.showRemoveDialog = false;
970 handleMarkRead(i: CommentNode) {
971 // if it has a user_mention_id field, then its a mention
972 if (i.props.node.comment.user_mention_id) {
973 let form: EditUserMentionForm = {
974 user_mention_id: i.props.node.comment.user_mention_id,
975 read: !i.props.node.comment.read,
977 WebSocketService.Instance.editUserMention(form);
979 let form: CommentFormI = {
980 content: i.props.node.comment.content,
981 edit_id: i.props.node.comment.id,
982 creator_id: i.props.node.comment.creator_id,
983 post_id: i.props.node.comment.post_id,
984 parent_id: i.props.node.comment.parent_id,
985 read: !i.props.node.comment.read,
988 WebSocketService.Instance.editComment(form);
992 handleModBanFromCommunityShow(i: CommentNode) {
993 i.state.showBanDialog = !i.state.showBanDialog;
994 i.state.banType = BanType.Community;
998 handleModBanShow(i: CommentNode) {
999 i.state.showBanDialog = !i.state.showBanDialog;
1000 i.state.banType = BanType.Site;
1001 i.setState(i.state);
1004 handleModBanReasonChange(i: CommentNode, event: any) {
1005 i.state.banReason = event.target.value;
1006 i.setState(i.state);
1009 handleModBanExpiresChange(i: CommentNode, event: any) {
1010 i.state.banExpires = event.target.value;
1011 i.setState(i.state);
1014 handleModBanFromCommunitySubmit(i: CommentNode) {
1015 i.state.banType = BanType.Community;
1016 i.setState(i.state);
1017 i.handleModBanBothSubmit(i);
1020 handleModBanSubmit(i: CommentNode) {
1021 i.state.banType = BanType.Site;
1022 i.setState(i.state);
1023 i.handleModBanBothSubmit(i);
1026 handleModBanBothSubmit(i: CommentNode) {
1027 event.preventDefault();
1029 if (i.state.banType == BanType.Community) {
1030 let form: BanFromCommunityForm = {
1031 user_id: i.props.node.comment.creator_id,
1032 community_id: i.props.node.comment.community_id,
1033 ban: !i.props.node.comment.banned_from_community,
1034 reason: i.state.banReason,
1035 expires: getUnixTime(i.state.banExpires),
1037 WebSocketService.Instance.banFromCommunity(form);
1039 let form: BanUserForm = {
1040 user_id: i.props.node.comment.creator_id,
1041 ban: !i.props.node.comment.banned,
1042 reason: i.state.banReason,
1043 expires: getUnixTime(i.state.banExpires),
1045 WebSocketService.Instance.banUser(form);
1048 i.state.showBanDialog = false;
1049 i.setState(i.state);
1052 handleShowConfirmAppointAsMod(i: CommentNode) {
1053 i.state.showConfirmAppointAsMod = true;
1054 i.setState(i.state);
1057 handleCancelConfirmAppointAsMod(i: CommentNode) {
1058 i.state.showConfirmAppointAsMod = false;
1059 i.setState(i.state);
1062 handleAddModToCommunity(i: CommentNode) {
1063 let form: AddModToCommunityForm = {
1064 user_id: i.props.node.comment.creator_id,
1065 community_id: i.props.node.comment.community_id,
1068 WebSocketService.Instance.addModToCommunity(form);
1069 i.state.showConfirmAppointAsMod = false;
1070 i.setState(i.state);
1073 handleShowConfirmAppointAsAdmin(i: CommentNode) {
1074 i.state.showConfirmAppointAsAdmin = true;
1075 i.setState(i.state);
1078 handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1079 i.state.showConfirmAppointAsAdmin = false;
1080 i.setState(i.state);
1083 handleAddAdmin(i: CommentNode) {
1084 let form: AddAdminForm = {
1085 user_id: i.props.node.comment.creator_id,
1088 WebSocketService.Instance.addAdmin(form);
1089 i.state.showConfirmAppointAsAdmin = false;
1090 i.setState(i.state);
1093 handleShowConfirmTransferCommunity(i: CommentNode) {
1094 i.state.showConfirmTransferCommunity = true;
1095 i.setState(i.state);
1098 handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1099 i.state.showConfirmTransferCommunity = false;
1100 i.setState(i.state);
1103 handleTransferCommunity(i: CommentNode) {
1104 let form: TransferCommunityForm = {
1105 community_id: i.props.node.comment.community_id,
1106 user_id: i.props.node.comment.creator_id,
1108 WebSocketService.Instance.transferCommunity(form);
1109 i.state.showConfirmTransferCommunity = false;
1110 i.setState(i.state);
1113 handleShowConfirmTransferSite(i: CommentNode) {
1114 i.state.showConfirmTransferSite = true;
1115 i.setState(i.state);
1118 handleCancelShowConfirmTransferSite(i: CommentNode) {
1119 i.state.showConfirmTransferSite = false;
1120 i.setState(i.state);
1123 handleTransferSite(i: CommentNode) {
1124 let form: TransferSiteForm = {
1125 user_id: i.props.node.comment.creator_id,
1127 WebSocketService.Instance.transferSite(form);
1128 i.state.showConfirmTransferSite = false;
1129 i.setState(i.state);
1132 get isCommentNew(): boolean {
1133 let now = moment.utc().subtract(10, 'minutes');
1134 let then = moment.utc(this.props.node.comment.published);
1135 return now.isBefore(then);
1138 handleCommentCollapse(i: CommentNode) {
1139 i.state.collapsed = !i.state.collapsed;
1140 i.setState(i.state);
1143 handleViewSource(i: CommentNode) {
1144 i.state.viewSource = !i.state.viewSource;
1145 i.setState(i.state);
1148 handleShowAdvanced(i: CommentNode) {
1149 i.state.showAdvanced = !i.state.showAdvanced;
1150 i.setState(i.state);
1155 if (this.state.my_vote == 1) {
1157 } else if (this.state.my_vote == -1) {
1158 return 'text-danger';
1160 return 'text-muted';
1164 get pointsTippy(): string {
1165 let points = i18n.t('number_of_points', {
1166 count: this.state.score,
1169 let upvotes = i18n.t('number_of_upvotes', {
1170 count: this.state.upvotes,
1173 let downvotes = i18n.t('number_of_downvotes', {
1174 count: this.state.downvotes,
1177 return `${points} • ${upvotes} • ${downvotes}`;