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: randomHsl(),
100 constructor(props: any, context: any) {
101 super(props, context);
103 this.state = this.emptyState;
104 this.handleReplyCancel = this.handleReplyCancel.bind(this);
105 this.handleCommentUpvote = this.handleCommentUpvote.bind(this);
106 this.handleCommentDownvote = this.handleCommentDownvote.bind(this);
109 componentWillReceiveProps(nextProps: CommentNodeProps) {
110 this.state.my_vote = nextProps.node.comment.my_vote;
111 this.state.upvotes = nextProps.node.comment.upvotes;
112 this.state.downvotes = nextProps.node.comment.downvotes;
113 this.state.score = nextProps.node.comment.score;
114 this.setState(this.state);
118 let node = this.props.node;
121 className={`comment ${
122 node.comment.parent_id && !this.props.noIndent ? 'ml-2' : ''
125 {!node.comment.parent_id && !this.props.noIndent && (
126 <hr class="d-sm-none my-2" />
129 id={`comment-${node.comment.id}`}
130 className={`details comment-node mb-1 ${
131 this.isCommentNew ? 'mark' : ''
134 !this.props.noIndent &&
135 this.props.node.comment.parent_id &&
136 `border-left: 1px ${this.state.borderColor} solid !important`
140 class={`${!this.props.noIndent &&
141 this.props.node.comment.parent_id &&
144 <ul class="list-inline mb-1 text-muted small">
145 <li className="list-inline-item">
147 className="text-body font-weight-bold"
148 to={`/u/${node.comment.creator_name}`}
150 {node.comment.creator_avatar && showAvatars() && (
154 src={pictshareAvatarThumbnail(
155 node.comment.creator_avatar
157 class="rounded-circle mr-1"
160 <span>{node.comment.creator_name}</span>
164 <li className="list-inline-item badge badge-light">
169 <li className="list-inline-item badge badge-light">
173 {this.isPostCreator && (
174 <li className="list-inline-item badge badge-light">
178 {(node.comment.banned_from_community || node.comment.banned) && (
179 <li className="list-inline-item badge badge-danger">
183 <li className="list-inline-item">•</li>
185 class="unselectable pointer mr-2"
186 data-tippy-content={i18n.t('number_of_points', {
187 count: this.state.score,
190 <li className="list-inline-item">
191 <span className={this.scoreColor}>
192 <svg className="small icon icon-inline mr-1">
193 <use xlinkHref="#icon-zap"></use>
198 <li className="list-inline-item">
199 <svg class="small icon icon-inline mr-1">
200 <use xlinkHref="#icon-arrow-up"></use>
204 <li className="list-inline-item">
205 <svg class="small icon icon-inline mr-1">
206 <use xlinkHref="#icon-arrow-down"></use>
208 {this.state.downvotes}
211 {this.props.showCommunity && (
212 <li className="list-inline-item">
213 <span> {i18n.t('to')} </span>
214 <Link to={`/c/${node.comment.community_name}`}>
215 {node.comment.community_name}
219 <li className="list-inline-item">•</li>
220 <li className="list-inline-item">
222 <MomentTime data={node.comment} />
225 <li className="list-inline-item">
227 className="unselectable pointer text-monospace"
228 onClick={linkEvent(this, this.handleCommentCollapse)}
230 {this.state.collapsed ? (
232 <use xlinkHref="#icon-plus-square"></use>
236 <use xlinkHref="#icon-minus-square"></use>
242 {this.state.showEdit && (
246 onReplyCancel={this.handleReplyCancel}
247 disabled={this.props.locked}
250 {!this.state.showEdit && !this.state.collapsed && (
252 {this.state.viewSource ? (
253 <pre>{this.commentUnlessRemoved}</pre>
257 dangerouslySetInnerHTML={mdToHtml(
258 this.commentUnlessRemoved
262 <ul class="list-inline mb-0 text-muted font-weight-bold h5">
263 {this.props.markable && (
264 <li className="list-inline-item-action">
267 onClick={linkEvent(this, this.handleMarkRead)}
270 ? i18n.t('mark_as_unread')
271 : i18n.t('mark_as_read')
275 class={`icon icon-inline ${node.comment.read &&
278 <use xlinkHref="#icon-check"></use>
283 {UserService.Instance.user && !this.props.viewOnly && (
285 <li className="list-inline-item-action">
287 className={`vote-animate btn btn-link p-0 mb-1 ${
288 this.state.my_vote == 1 ? 'text-info' : 'text-muted'
290 onClick={linkEvent(node, this.handleCommentUpvote)}
291 data-tippy-content={i18n.t('upvote')}
294 <use xlinkHref="#icon-arrow-up"></use>
298 {WebSocketService.Instance.site.enable_downvotes && (
299 <li className="list-inline-item-action">
301 className={`vote-animate btn btn-link p-0 mb-1 ${
302 this.state.my_vote == -1
308 this.handleCommentDownvote
310 data-tippy-content={i18n.t('downvote')}
313 <use xlinkHref="#icon-arrow-down"></use>
318 <li className="list-inline-item-action">
321 onClick={linkEvent(this, this.handleReplyClick)}
322 data-tippy-content={i18n.t('reply')}
324 <svg class="icon icon-inline">
325 <use xlinkHref="#icon-reply1"></use>
329 {!this.myComment && (
330 <li className="list-inline-item-action">
333 to={`/create_private_message?recipient_id=${node.comment.creator_id}`}
334 title={i18n.t('message').toLowerCase()}
337 <use xlinkHref="#icon-mail"></use>
342 <li className="list-inline-item-action">
344 className="text-muted"
345 to={`/post/${node.comment.post_id}/comment/${node.comment.id}`}
346 title={i18n.t('link')}
348 <svg class="icon icon-inline">
349 <use xlinkHref="#icon-link"></use>
353 {!this.state.showAdvanced ? (
354 <li className="list-inline-item-action">
356 className="unselectable pointer"
357 onClick={linkEvent(this, this.handleShowAdvanced)}
358 data-tippy-content={i18n.t('more')}
360 <svg class="icon icon-inline">
361 <use xlinkHref="#icon-more-vertical"></use>
367 <li className="list-inline-item-action">
372 this.handleSaveCommentClick
381 class={`icon icon-inline ${node.comment.saved &&
384 <use xlinkHref="#icon-star"></use>
388 <li className="list-inline-item-action">
391 onClick={linkEvent(this, this.handleViewSource)}
392 data-tippy-content={i18n.t('view_source')}
395 class={`icon icon-inline ${this.state
396 .viewSource && 'text-success'}`}
398 <use xlinkHref="#icon-file-text"></use>
404 <li className="list-inline-item-action">•</li>
405 <li className="list-inline-item-action">
412 data-tippy-content={i18n.t('edit')}
414 <svg class="icon icon-inline">
415 <use xlinkHref="#icon-edit"></use>
419 <li className="list-inline-item-action">
424 this.handleDeleteClick
427 !node.comment.deleted
433 class={`icon icon-inline ${node.comment
434 .deleted && 'text-danger'}`}
436 <use xlinkHref="#icon-trash"></use>
442 {/* Admins and mods can remove comments */}
443 {(this.canMod || this.canAdmin) && (
445 <li className="list-inline-item-action">
446 {!node.comment.removed ? (
451 this.handleModRemoveShow
461 this.handleModRemoveSubmit
470 {/* Mods can ban from community, and appoint as mods to community */}
474 <li className="list-inline-item-action">
475 {!node.comment.banned_from_community ? (
480 this.handleModBanFromCommunityShow
490 this.handleModBanFromCommunitySubmit
498 {!node.comment.banned_from_community && (
499 <li className="list-inline-item-action">
500 {!this.state.showConfirmAppointAsMod ? (
505 this.handleShowConfirmAppointAsMod
509 ? i18n.t('remove_as_mod')
510 : i18n.t('appoint_as_mod')}
514 <span class="d-inline-block mr-1">
515 {i18n.t('are_you_sure')}
518 class="pointer d-inline-block mr-1"
521 this.handleAddModToCommunity
527 class="pointer d-inline-block"
530 this.handleCancelConfirmAppointAsMod
541 {/* Community creators and admins can transfer community to another mod */}
542 {(this.amCommunityCreator || this.canAdmin) &&
544 <li className="list-inline-item-action">
545 {!this.state.showConfirmTransferCommunity ? (
550 this.handleShowConfirmTransferCommunity
553 {i18n.t('transfer_community')}
557 <span class="d-inline-block mr-1">
558 {i18n.t('are_you_sure')}
561 class="pointer d-inline-block mr-1"
564 this.handleTransferCommunity
570 class="pointer d-inline-block"
574 .handleCancelShowConfirmTransferCommunity
583 {/* Admins can ban from all, and appoint other admins */}
587 <li className="list-inline-item-action">
588 {!node.comment.banned ? (
593 this.handleModBanShow
596 {i18n.t('ban_from_site')}
603 this.handleModBanSubmit
606 {i18n.t('unban_from_site')}
611 {!node.comment.banned && (
612 <li className="list-inline-item-action">
613 {!this.state.showConfirmAppointAsAdmin ? (
618 this.handleShowConfirmAppointAsAdmin
622 ? i18n.t('remove_as_admin')
623 : i18n.t('appoint_as_admin')}
627 <span class="d-inline-block mr-1">
628 {i18n.t('are_you_sure')}
631 class="pointer d-inline-block mr-1"
640 class="pointer d-inline-block"
643 this.handleCancelConfirmAppointAsAdmin
654 {/* Site Creator can transfer to another admin */}
655 {this.amSiteCreator && this.isAdmin && (
656 <li className="list-inline-item-action">
657 {!this.state.showConfirmTransferSite ? (
662 this.handleShowConfirmTransferSite
665 {i18n.t('transfer_site')}
669 <span class="d-inline-block mr-1">
670 {i18n.t('are_you_sure')}
673 class="pointer d-inline-block mr-1"
676 this.handleTransferSite
682 class="pointer d-inline-block"
685 this.handleCancelShowConfirmTransferSite
703 {/* end of details */}
704 {this.state.showRemoveDialog && (
707 onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
711 class="form-control mr-2"
712 placeholder={i18n.t('reason')}
713 value={this.state.removeReason}
714 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
716 <button type="submit" class="btn btn-secondary">
717 {i18n.t('remove_comment')}
721 {this.state.showBanDialog && (
722 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
723 <div class="form-group row">
724 <label class="col-form-label">{i18n.t('reason')}</label>
727 class="form-control mr-2"
728 placeholder={i18n.t('reason')}
729 value={this.state.banReason}
730 onInput={linkEvent(this, this.handleModBanReasonChange)}
733 {/* TODO hold off on expires until later */}
734 {/* <div class="form-group row"> */}
735 {/* <label class="col-form-label">Expires</label> */}
736 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
738 <div class="form-group row">
739 <button type="submit" class="btn btn-secondary">
740 {i18n.t('ban')} {node.comment.creator_name}
745 {this.state.showReply && (
748 onReplyCancel={this.handleReplyCancel}
749 disabled={this.props.locked}
752 {node.children && !this.state.collapsed && (
754 nodes={node.children}
755 locked={this.props.locked}
756 moderators={this.props.moderators}
757 admins={this.props.admins}
758 postCreatorId={this.props.postCreatorId}
759 sort={this.props.sort}
760 sortType={this.props.sortType}
763 {/* A collapsed clearfix */}
764 {this.state.collapsed && <div class="row col-12"></div>}
769 get myComment(): boolean {
771 UserService.Instance.user &&
772 this.props.node.comment.creator_id == UserService.Instance.user.id
776 get isMod(): boolean {
778 this.props.moderators &&
780 this.props.moderators.map(m => m.user_id),
781 this.props.node.comment.creator_id
786 get isAdmin(): boolean {
790 this.props.admins.map(a => a.id),
791 this.props.node.comment.creator_id
796 get isPostCreator(): boolean {
797 return this.props.node.comment.creator_id == this.props.postCreatorId;
800 get canMod(): boolean {
801 if (this.props.admins && this.props.moderators) {
802 let adminsThenMods = this.props.admins
804 .concat(this.props.moderators.map(m => m.user_id));
807 UserService.Instance.user,
809 this.props.node.comment.creator_id
816 get canAdmin(): boolean {
820 UserService.Instance.user,
821 this.props.admins.map(a => a.id),
822 this.props.node.comment.creator_id
827 get amCommunityCreator(): boolean {
829 this.props.moderators &&
830 UserService.Instance.user &&
831 this.props.node.comment.creator_id != UserService.Instance.user.id &&
832 UserService.Instance.user.id == this.props.moderators[0].user_id
836 get amSiteCreator(): boolean {
839 UserService.Instance.user &&
840 this.props.node.comment.creator_id != UserService.Instance.user.id &&
841 UserService.Instance.user.id == this.props.admins[0].id
845 get commentUnlessRemoved(): string {
846 let node = this.props.node;
847 return node.comment.removed
848 ? `*${i18n.t('removed')}*`
849 : node.comment.deleted
850 ? `*${i18n.t('deleted')}*`
851 : node.comment.content;
854 handleReplyClick(i: CommentNode) {
855 i.state.showReply = true;
859 handleEditClick(i: CommentNode) {
860 i.state.showEdit = true;
864 handleDeleteClick(i: CommentNode) {
865 let deleteForm: CommentFormI = {
866 content: i.props.node.comment.content,
867 edit_id: i.props.node.comment.id,
868 creator_id: i.props.node.comment.creator_id,
869 post_id: i.props.node.comment.post_id,
870 parent_id: i.props.node.comment.parent_id,
871 deleted: !i.props.node.comment.deleted,
874 WebSocketService.Instance.editComment(deleteForm);
877 handleSaveCommentClick(i: CommentNode) {
879 i.props.node.comment.saved == undefined
881 : !i.props.node.comment.saved;
882 let form: SaveCommentForm = {
883 comment_id: i.props.node.comment.id,
887 WebSocketService.Instance.saveComment(form);
890 handleReplyCancel() {
891 this.state.showReply = false;
892 this.state.showEdit = false;
893 this.setState(this.state);
896 handleCommentUpvote(i: CommentNodeI) {
897 let new_vote = this.state.my_vote == 1 ? 0 : 1;
899 if (this.state.my_vote == 1) {
901 this.state.upvotes--;
902 } else if (this.state.my_vote == -1) {
903 this.state.downvotes--;
904 this.state.upvotes++;
905 this.state.score += 2;
907 this.state.upvotes++;
911 this.state.my_vote = new_vote;
913 let form: CommentLikeForm = {
914 comment_id: i.comment.id,
915 post_id: i.comment.post_id,
916 score: this.state.my_vote,
919 WebSocketService.Instance.likeComment(form);
920 this.setState(this.state);
923 handleCommentDownvote(i: CommentNodeI) {
924 let new_vote = this.state.my_vote == -1 ? 0 : -1;
926 if (this.state.my_vote == 1) {
927 this.state.score -= 2;
928 this.state.upvotes--;
929 this.state.downvotes++;
930 } else if (this.state.my_vote == -1) {
931 this.state.downvotes--;
934 this.state.downvotes++;
938 this.state.my_vote = new_vote;
940 let form: CommentLikeForm = {
941 comment_id: i.comment.id,
942 post_id: i.comment.post_id,
943 score: this.state.my_vote,
946 WebSocketService.Instance.likeComment(form);
947 this.setState(this.state);
950 handleModRemoveShow(i: CommentNode) {
951 i.state.showRemoveDialog = true;
955 handleModRemoveReasonChange(i: CommentNode, event: any) {
956 i.state.removeReason = event.target.value;
960 handleModRemoveSubmit(i: CommentNode) {
961 event.preventDefault();
962 let form: CommentFormI = {
963 content: i.props.node.comment.content,
964 edit_id: i.props.node.comment.id,
965 creator_id: i.props.node.comment.creator_id,
966 post_id: i.props.node.comment.post_id,
967 parent_id: i.props.node.comment.parent_id,
968 removed: !i.props.node.comment.removed,
969 reason: i.state.removeReason,
972 WebSocketService.Instance.editComment(form);
974 i.state.showRemoveDialog = false;
978 handleMarkRead(i: CommentNode) {
979 // if it has a user_mention_id field, then its a mention
980 if (i.props.node.comment.user_mention_id) {
981 let form: EditUserMentionForm = {
982 user_mention_id: i.props.node.comment.user_mention_id,
983 read: !i.props.node.comment.read,
985 WebSocketService.Instance.editUserMention(form);
987 let form: CommentFormI = {
988 content: i.props.node.comment.content,
989 edit_id: i.props.node.comment.id,
990 creator_id: i.props.node.comment.creator_id,
991 post_id: i.props.node.comment.post_id,
992 parent_id: i.props.node.comment.parent_id,
993 read: !i.props.node.comment.read,
996 WebSocketService.Instance.editComment(form);
1000 handleModBanFromCommunityShow(i: CommentNode) {
1001 i.state.showBanDialog = !i.state.showBanDialog;
1002 i.state.banType = BanType.Community;
1003 i.setState(i.state);
1006 handleModBanShow(i: CommentNode) {
1007 i.state.showBanDialog = !i.state.showBanDialog;
1008 i.state.banType = BanType.Site;
1009 i.setState(i.state);
1012 handleModBanReasonChange(i: CommentNode, event: any) {
1013 i.state.banReason = event.target.value;
1014 i.setState(i.state);
1017 handleModBanExpiresChange(i: CommentNode, event: any) {
1018 i.state.banExpires = event.target.value;
1019 i.setState(i.state);
1022 handleModBanFromCommunitySubmit(i: CommentNode) {
1023 i.state.banType = BanType.Community;
1024 i.setState(i.state);
1025 i.handleModBanBothSubmit(i);
1028 handleModBanSubmit(i: CommentNode) {
1029 i.state.banType = BanType.Site;
1030 i.setState(i.state);
1031 i.handleModBanBothSubmit(i);
1034 handleModBanBothSubmit(i: CommentNode) {
1035 event.preventDefault();
1037 if (i.state.banType == BanType.Community) {
1038 let form: BanFromCommunityForm = {
1039 user_id: i.props.node.comment.creator_id,
1040 community_id: i.props.node.comment.community_id,
1041 ban: !i.props.node.comment.banned_from_community,
1042 reason: i.state.banReason,
1043 expires: getUnixTime(i.state.banExpires),
1045 WebSocketService.Instance.banFromCommunity(form);
1047 let form: BanUserForm = {
1048 user_id: i.props.node.comment.creator_id,
1049 ban: !i.props.node.comment.banned,
1050 reason: i.state.banReason,
1051 expires: getUnixTime(i.state.banExpires),
1053 WebSocketService.Instance.banUser(form);
1056 i.state.showBanDialog = false;
1057 i.setState(i.state);
1060 handleShowConfirmAppointAsMod(i: CommentNode) {
1061 i.state.showConfirmAppointAsMod = true;
1062 i.setState(i.state);
1065 handleCancelConfirmAppointAsMod(i: CommentNode) {
1066 i.state.showConfirmAppointAsMod = false;
1067 i.setState(i.state);
1070 handleAddModToCommunity(i: CommentNode) {
1071 let form: AddModToCommunityForm = {
1072 user_id: i.props.node.comment.creator_id,
1073 community_id: i.props.node.comment.community_id,
1076 WebSocketService.Instance.addModToCommunity(form);
1077 i.state.showConfirmAppointAsMod = false;
1078 i.setState(i.state);
1081 handleShowConfirmAppointAsAdmin(i: CommentNode) {
1082 i.state.showConfirmAppointAsAdmin = true;
1083 i.setState(i.state);
1086 handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1087 i.state.showConfirmAppointAsAdmin = false;
1088 i.setState(i.state);
1091 handleAddAdmin(i: CommentNode) {
1092 let form: AddAdminForm = {
1093 user_id: i.props.node.comment.creator_id,
1096 WebSocketService.Instance.addAdmin(form);
1097 i.state.showConfirmAppointAsAdmin = false;
1098 i.setState(i.state);
1101 handleShowConfirmTransferCommunity(i: CommentNode) {
1102 i.state.showConfirmTransferCommunity = true;
1103 i.setState(i.state);
1106 handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1107 i.state.showConfirmTransferCommunity = false;
1108 i.setState(i.state);
1111 handleTransferCommunity(i: CommentNode) {
1112 let form: TransferCommunityForm = {
1113 community_id: i.props.node.comment.community_id,
1114 user_id: i.props.node.comment.creator_id,
1116 WebSocketService.Instance.transferCommunity(form);
1117 i.state.showConfirmTransferCommunity = false;
1118 i.setState(i.state);
1121 handleShowConfirmTransferSite(i: CommentNode) {
1122 i.state.showConfirmTransferSite = true;
1123 i.setState(i.state);
1126 handleCancelShowConfirmTransferSite(i: CommentNode) {
1127 i.state.showConfirmTransferSite = false;
1128 i.setState(i.state);
1131 handleTransferSite(i: CommentNode) {
1132 let form: TransferSiteForm = {
1133 user_id: i.props.node.comment.creator_id,
1135 WebSocketService.Instance.transferSite(form);
1136 i.state.showConfirmTransferSite = false;
1137 i.setState(i.state);
1140 get isCommentNew(): boolean {
1141 let now = moment.utc().subtract(10, 'minutes');
1142 let then = moment.utc(this.props.node.comment.published);
1143 return now.isBefore(then);
1146 handleCommentCollapse(i: CommentNode) {
1147 i.state.collapsed = !i.state.collapsed;
1148 i.setState(i.state);
1151 handleViewSource(i: CommentNode) {
1152 i.state.viewSource = !i.state.viewSource;
1153 i.setState(i.state);
1156 handleShowAdvanced(i: CommentNode) {
1157 i.state.showAdvanced = !i.state.showAdvanced;
1158 i.setState(i.state);
1163 if (this.state.my_vote == 1) {
1165 } else if (this.state.my_vote == -1) {
1166 return 'text-danger';
1168 return 'text-muted';