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,
31 import moment from 'moment';
32 import { MomentTime } from './moment-time';
33 import { CommentForm } from './comment-form';
34 import { CommentNodes } from './comment-nodes';
35 import { i18n } from '../i18next';
37 interface CommentNodeState {
40 showRemoveDialog: boolean;
42 showBanDialog: boolean;
46 showConfirmTransferSite: boolean;
47 showConfirmTransferCommunity: boolean;
48 showConfirmAppointAsMod: boolean;
49 showConfirmAppointAsAdmin: boolean;
52 showAdvanced: boolean;
59 interface CommentNodeProps {
65 moderators: Array<CommunityUser>;
66 admins: Array<UserView>;
67 // TODO is this necessary, can't I get it from the node itself?
68 postCreatorId?: number;
69 showCommunity?: boolean;
70 sort?: CommentSortType;
74 export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
75 private emptyState: CommentNodeState = {
78 showRemoveDialog: false,
83 banType: BanType.Community,
87 showConfirmTransferSite: false,
88 showConfirmTransferCommunity: false,
89 showConfirmAppointAsMod: false,
90 showConfirmAppointAsAdmin: false,
91 my_vote: this.props.node.comment.my_vote,
92 score: this.props.node.comment.score,
93 upvotes: this.props.node.comment.upvotes,
94 downvotes: this.props.node.comment.downvotes,
97 constructor(props: any, context: any) {
98 super(props, context);
100 this.state = this.emptyState;
101 this.handleReplyCancel = this.handleReplyCancel.bind(this);
102 this.handleCommentUpvote = this.handleCommentUpvote.bind(this);
103 this.handleCommentDownvote = this.handleCommentDownvote.bind(this);
106 componentWillReceiveProps(nextProps: CommentNodeProps) {
107 this.state.my_vote = nextProps.node.comment.my_vote;
108 this.state.upvotes = nextProps.node.comment.upvotes;
109 this.state.downvotes = nextProps.node.comment.downvotes;
110 this.state.score = nextProps.node.comment.score;
111 this.setState(this.state);
115 let node = this.props.node;
118 className={`comment ${
119 node.comment.parent_id && !this.props.noIndent ? 'ml-4' : ''
123 id={`comment-${node.comment.id}`}
124 className={`details comment-node mb-1 ${
125 this.isCommentNew ? 'mark' : ''
128 <ul class="list-inline mb-1 text-muted small">
129 <li className="list-inline-item">
131 className="text-info"
132 to={`/u/${node.comment.creator_name}`}
134 {node.comment.creator_avatar && showAvatars() && (
138 src={pictshareAvatarThumbnail(node.comment.creator_avatar)}
139 class="rounded-circle mr-1"
142 <span>{node.comment.creator_name}</span>
146 <li className="list-inline-item badge badge-light">
151 <li className="list-inline-item badge badge-light">
155 {this.isPostCreator && (
156 <li className="list-inline-item badge badge-light">
160 {(node.comment.banned_from_community || node.comment.banned) && (
161 <li className="list-inline-item badge badge-danger">
165 <li className="list-inline-item">•</li>
167 class="unselectable pointer mr-2"
168 data-tippy-content={i18n.t('number_of_points', {
169 count: this.state.score,
172 <li className="list-inline-item">
173 <span className={this.scoreColor}>
174 <svg className="small icon icon-inline mr-1">
175 <use xlinkHref="#icon-zap"></use>
180 <li className="list-inline-item">
181 <svg class="small icon icon-inline mr-1">
182 <use xlinkHref="#icon-arrow-up"></use>
186 <li className="list-inline-item">
187 <svg class="small icon icon-inline mr-1">
188 <use xlinkHref="#icon-arrow-down"></use>
190 {this.state.downvotes}
193 {this.props.showCommunity && (
194 <li className="list-inline-item">
195 <span> {i18n.t('to')} </span>
196 <Link to={`/c/${node.comment.community_name}`}>
197 {node.comment.community_name}
201 <li className="list-inline-item">•</li>
202 <li className="list-inline-item">
204 <MomentTime data={node.comment} />
207 <li className="list-inline-item">
209 className="unselectable pointer text-monospace"
210 onClick={linkEvent(this, this.handleCommentCollapse)}
212 {this.state.collapsed ? (
214 <use xlinkHref="#icon-plus-square"></use>
218 <use xlinkHref="#icon-minus-square"></use>
224 {this.state.showEdit && (
228 onReplyCancel={this.handleReplyCancel}
229 disabled={this.props.locked}
232 {!this.state.showEdit && !this.state.collapsed && (
234 {this.state.viewSource ? (
235 <pre>{this.commentUnlessRemoved}</pre>
239 dangerouslySetInnerHTML={mdToHtml(this.commentUnlessRemoved)}
242 <ul class="list-inline mb-0 text-muted font-weight-bold h5">
243 {this.props.markable && (
244 <li className="list-inline-item-action">
247 onClick={linkEvent(this, this.handleMarkRead)}
250 ? i18n.t('mark_as_unread')
251 : i18n.t('mark_as_read')
255 class={`icon icon-inline ${node.comment.read &&
258 <use xlinkHref="#icon-check"></use>
263 {UserService.Instance.user && !this.props.viewOnly && (
265 <li className="list-inline-item-action">
267 className={`vote-animate btn btn-link p-0 mb-1 ${
268 this.state.my_vote == 1 ? 'text-info' : 'text-muted'
270 onClick={linkEvent(node, this.handleCommentUpvote)}
271 data-tippy-content={i18n.t('upvote')}
274 <use xlinkHref="#icon-arrow-up"></use>
278 {WebSocketService.Instance.site.enable_downvotes && (
279 <li className="list-inline-item-action">
281 className={`vote-animate btn btn-link p-0 mb-1 ${
282 this.state.my_vote == -1
286 onClick={linkEvent(node, this.handleCommentDownvote)}
287 data-tippy-content={i18n.t('downvote')}
290 <use xlinkHref="#icon-arrow-down"></use>
295 <li className="list-inline-item-action">
298 onClick={linkEvent(this, this.handleReplyClick)}
299 data-tippy-content={i18n.t('reply')}
301 <svg class="icon icon-inline">
302 <use xlinkHref="#icon-reply1"></use>
306 {!this.myComment && (
307 <li className="list-inline-item-action">
310 to={`/create_private_message?recipient_id=${node.comment.creator_id}`}
311 title={i18n.t('message').toLowerCase()}
314 <use xlinkHref="#icon-mail"></use>
319 <li className="list-inline-item-action">
321 className="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-action">
333 className="unselectable pointer"
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 <li className="list-inline-item-action">
349 this.handleSaveCommentClick
358 class={`icon icon-inline ${node.comment.saved &&
361 <use xlinkHref="#icon-star"></use>
365 <li className="list-inline-item-action">
368 onClick={linkEvent(this, this.handleViewSource)}
369 data-tippy-content={i18n.t('view_source')}
372 class={`icon icon-inline ${this.state
373 .viewSource && 'text-success'}`}
375 <use xlinkHref="#icon-file-text"></use>
381 <li className="list-inline-item-action">•</li>
382 <li className="list-inline-item-action">
385 onClick={linkEvent(this, this.handleEditClick)}
386 data-tippy-content={i18n.t('edit')}
388 <svg class="icon icon-inline">
389 <use xlinkHref="#icon-edit"></use>
393 <li className="list-inline-item-action">
398 this.handleDeleteClick
401 !node.comment.deleted
407 class={`icon icon-inline ${node.comment
408 .deleted && 'text-danger'}`}
410 <use xlinkHref="#icon-trash"></use>
416 {/* Admins and mods can remove comments */}
417 {(this.canMod || this.canAdmin) && (
419 <li className="list-inline-item-action">
420 {!node.comment.removed ? (
425 this.handleModRemoveShow
435 this.handleModRemoveSubmit
444 {/* Mods can ban from community, and appoint as mods to community */}
448 <li className="list-inline-item-action">
449 {!node.comment.banned_from_community ? (
454 this.handleModBanFromCommunityShow
464 this.handleModBanFromCommunitySubmit
472 {!node.comment.banned_from_community && (
473 <li className="list-inline-item-action">
474 {!this.state.showConfirmAppointAsMod ? (
479 this.handleShowConfirmAppointAsMod
483 ? i18n.t('remove_as_mod')
484 : i18n.t('appoint_as_mod')}
488 <span class="d-inline-block mr-1">
489 {i18n.t('are_you_sure')}
492 class="pointer d-inline-block mr-1"
495 this.handleAddModToCommunity
501 class="pointer d-inline-block"
504 this.handleCancelConfirmAppointAsMod
515 {/* Community creators and admins can transfer community to another mod */}
516 {(this.amCommunityCreator || this.canAdmin) &&
518 <li className="list-inline-item-action">
519 {!this.state.showConfirmTransferCommunity ? (
524 this.handleShowConfirmTransferCommunity
527 {i18n.t('transfer_community')}
531 <span class="d-inline-block mr-1">
532 {i18n.t('are_you_sure')}
535 class="pointer d-inline-block mr-1"
538 this.handleTransferCommunity
544 class="pointer d-inline-block"
548 .handleCancelShowConfirmTransferCommunity
557 {/* Admins can ban from all, and appoint other admins */}
561 <li className="list-inline-item-action">
562 {!node.comment.banned ? (
567 this.handleModBanShow
570 {i18n.t('ban_from_site')}
577 this.handleModBanSubmit
580 {i18n.t('unban_from_site')}
585 {!node.comment.banned && (
586 <li className="list-inline-item-action">
587 {!this.state.showConfirmAppointAsAdmin ? (
592 this.handleShowConfirmAppointAsAdmin
596 ? i18n.t('remove_as_admin')
597 : i18n.t('appoint_as_admin')}
601 <span class="d-inline-block mr-1">
602 {i18n.t('are_you_sure')}
605 class="pointer d-inline-block mr-1"
614 class="pointer d-inline-block"
617 this.handleCancelConfirmAppointAsAdmin
628 {/* Site Creator can transfer to another admin */}
629 {this.amSiteCreator && this.isAdmin && (
630 <li className="list-inline-item-action">
631 {!this.state.showConfirmTransferSite ? (
636 this.handleShowConfirmTransferSite
639 {i18n.t('transfer_site')}
643 <span class="d-inline-block mr-1">
644 {i18n.t('are_you_sure')}
647 class="pointer d-inline-block mr-1"
650 this.handleTransferSite
656 class="pointer d-inline-block"
659 this.handleCancelShowConfirmTransferSite
676 {this.state.showRemoveDialog && (
679 onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
683 class="form-control mr-2"
684 placeholder={i18n.t('reason')}
685 value={this.state.removeReason}
686 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
688 <button type="submit" class="btn btn-secondary">
689 {i18n.t('remove_comment')}
693 {this.state.showBanDialog && (
694 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
695 <div class="form-group row">
696 <label class="col-form-label">{i18n.t('reason')}</label>
699 class="form-control mr-2"
700 placeholder={i18n.t('reason')}
701 value={this.state.banReason}
702 onInput={linkEvent(this, this.handleModBanReasonChange)}
705 {/* TODO hold off on expires until later */}
706 {/* <div class="form-group row"> */}
707 {/* <label class="col-form-label">Expires</label> */}
708 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
710 <div class="form-group row">
711 <button type="submit" class="btn btn-secondary">
712 {i18n.t('ban')} {node.comment.creator_name}
717 {this.state.showReply && (
720 onReplyCancel={this.handleReplyCancel}
721 disabled={this.props.locked}
724 {node.children && !this.state.collapsed && (
726 nodes={node.children}
727 locked={this.props.locked}
728 moderators={this.props.moderators}
729 admins={this.props.admins}
730 postCreatorId={this.props.postCreatorId}
731 sort={this.props.sort}
732 sortType={this.props.sortType}
735 {/* A collapsed clearfix */}
736 {this.state.collapsed && <div class="row col-12"></div>}
741 get myComment(): boolean {
743 UserService.Instance.user &&
744 this.props.node.comment.creator_id == UserService.Instance.user.id
748 get isMod(): boolean {
750 this.props.moderators &&
752 this.props.moderators.map(m => m.user_id),
753 this.props.node.comment.creator_id
758 get isAdmin(): boolean {
762 this.props.admins.map(a => a.id),
763 this.props.node.comment.creator_id
768 get isPostCreator(): boolean {
769 return this.props.node.comment.creator_id == this.props.postCreatorId;
772 get canMod(): boolean {
773 if (this.props.admins && this.props.moderators) {
774 let adminsThenMods = this.props.admins
776 .concat(this.props.moderators.map(m => m.user_id));
779 UserService.Instance.user,
781 this.props.node.comment.creator_id
788 get canAdmin(): boolean {
792 UserService.Instance.user,
793 this.props.admins.map(a => a.id),
794 this.props.node.comment.creator_id
799 get amCommunityCreator(): boolean {
801 this.props.moderators &&
802 UserService.Instance.user &&
803 this.props.node.comment.creator_id != UserService.Instance.user.id &&
804 UserService.Instance.user.id == this.props.moderators[0].user_id
808 get amSiteCreator(): boolean {
811 UserService.Instance.user &&
812 this.props.node.comment.creator_id != UserService.Instance.user.id &&
813 UserService.Instance.user.id == this.props.admins[0].id
817 get commentUnlessRemoved(): string {
818 let node = this.props.node;
819 return node.comment.removed
820 ? `*${i18n.t('removed')}*`
821 : node.comment.deleted
822 ? `*${i18n.t('deleted')}*`
823 : node.comment.content;
826 handleReplyClick(i: CommentNode) {
827 i.state.showReply = true;
831 handleEditClick(i: CommentNode) {
832 i.state.showEdit = true;
836 handleDeleteClick(i: CommentNode) {
837 let deleteForm: CommentFormI = {
838 content: i.props.node.comment.content,
839 edit_id: i.props.node.comment.id,
840 creator_id: i.props.node.comment.creator_id,
841 post_id: i.props.node.comment.post_id,
842 parent_id: i.props.node.comment.parent_id,
843 deleted: !i.props.node.comment.deleted,
846 WebSocketService.Instance.editComment(deleteForm);
849 handleSaveCommentClick(i: CommentNode) {
851 i.props.node.comment.saved == undefined
853 : !i.props.node.comment.saved;
854 let form: SaveCommentForm = {
855 comment_id: i.props.node.comment.id,
859 WebSocketService.Instance.saveComment(form);
862 handleReplyCancel() {
863 this.state.showReply = false;
864 this.state.showEdit = false;
865 this.setState(this.state);
868 handleCommentUpvote(i: CommentNodeI) {
869 let new_vote = this.state.my_vote == 1 ? 0 : 1;
871 if (this.state.my_vote == 1) {
873 this.state.upvotes--;
874 } else if (this.state.my_vote == -1) {
875 this.state.downvotes--;
876 this.state.upvotes++;
877 this.state.score += 2;
879 this.state.upvotes++;
883 this.state.my_vote = new_vote;
885 let form: CommentLikeForm = {
886 comment_id: i.comment.id,
887 post_id: i.comment.post_id,
888 score: this.state.my_vote,
891 WebSocketService.Instance.likeComment(form);
892 this.setState(this.state);
895 handleCommentDownvote(i: CommentNodeI) {
896 let new_vote = this.state.my_vote == -1 ? 0 : -1;
898 if (this.state.my_vote == 1) {
899 this.state.score -= 2;
900 this.state.upvotes--;
901 this.state.downvotes++;
902 } else if (this.state.my_vote == -1) {
903 this.state.downvotes--;
906 this.state.downvotes++;
910 this.state.my_vote = new_vote;
912 let form: CommentLikeForm = {
913 comment_id: i.comment.id,
914 post_id: i.comment.post_id,
915 score: this.state.my_vote,
918 WebSocketService.Instance.likeComment(form);
919 this.setState(this.state);
922 handleModRemoveShow(i: CommentNode) {
923 i.state.showRemoveDialog = true;
927 handleModRemoveReasonChange(i: CommentNode, event: any) {
928 i.state.removeReason = event.target.value;
932 handleModRemoveSubmit(i: CommentNode) {
933 event.preventDefault();
934 let form: CommentFormI = {
935 content: i.props.node.comment.content,
936 edit_id: i.props.node.comment.id,
937 creator_id: i.props.node.comment.creator_id,
938 post_id: i.props.node.comment.post_id,
939 parent_id: i.props.node.comment.parent_id,
940 removed: !i.props.node.comment.removed,
941 reason: i.state.removeReason,
944 WebSocketService.Instance.editComment(form);
946 i.state.showRemoveDialog = false;
950 handleMarkRead(i: CommentNode) {
951 // if it has a user_mention_id field, then its a mention
952 if (i.props.node.comment.user_mention_id) {
953 let form: EditUserMentionForm = {
954 user_mention_id: i.props.node.comment.user_mention_id,
955 read: !i.props.node.comment.read,
957 WebSocketService.Instance.editUserMention(form);
959 let form: CommentFormI = {
960 content: i.props.node.comment.content,
961 edit_id: i.props.node.comment.id,
962 creator_id: i.props.node.comment.creator_id,
963 post_id: i.props.node.comment.post_id,
964 parent_id: i.props.node.comment.parent_id,
965 read: !i.props.node.comment.read,
968 WebSocketService.Instance.editComment(form);
972 handleModBanFromCommunityShow(i: CommentNode) {
973 i.state.showBanDialog = !i.state.showBanDialog;
974 i.state.banType = BanType.Community;
978 handleModBanShow(i: CommentNode) {
979 i.state.showBanDialog = !i.state.showBanDialog;
980 i.state.banType = BanType.Site;
984 handleModBanReasonChange(i: CommentNode, event: any) {
985 i.state.banReason = event.target.value;
989 handleModBanExpiresChange(i: CommentNode, event: any) {
990 i.state.banExpires = event.target.value;
994 handleModBanFromCommunitySubmit(i: CommentNode) {
995 i.state.banType = BanType.Community;
997 i.handleModBanBothSubmit(i);
1000 handleModBanSubmit(i: CommentNode) {
1001 i.state.banType = BanType.Site;
1002 i.setState(i.state);
1003 i.handleModBanBothSubmit(i);
1006 handleModBanBothSubmit(i: CommentNode) {
1007 event.preventDefault();
1009 if (i.state.banType == BanType.Community) {
1010 let form: BanFromCommunityForm = {
1011 user_id: i.props.node.comment.creator_id,
1012 community_id: i.props.node.comment.community_id,
1013 ban: !i.props.node.comment.banned_from_community,
1014 reason: i.state.banReason,
1015 expires: getUnixTime(i.state.banExpires),
1017 WebSocketService.Instance.banFromCommunity(form);
1019 let form: BanUserForm = {
1020 user_id: i.props.node.comment.creator_id,
1021 ban: !i.props.node.comment.banned,
1022 reason: i.state.banReason,
1023 expires: getUnixTime(i.state.banExpires),
1025 WebSocketService.Instance.banUser(form);
1028 i.state.showBanDialog = false;
1029 i.setState(i.state);
1032 handleShowConfirmAppointAsMod(i: CommentNode) {
1033 i.state.showConfirmAppointAsMod = true;
1034 i.setState(i.state);
1037 handleCancelConfirmAppointAsMod(i: CommentNode) {
1038 i.state.showConfirmAppointAsMod = false;
1039 i.setState(i.state);
1042 handleAddModToCommunity(i: CommentNode) {
1043 let form: AddModToCommunityForm = {
1044 user_id: i.props.node.comment.creator_id,
1045 community_id: i.props.node.comment.community_id,
1048 WebSocketService.Instance.addModToCommunity(form);
1049 i.state.showConfirmAppointAsMod = false;
1050 i.setState(i.state);
1053 handleShowConfirmAppointAsAdmin(i: CommentNode) {
1054 i.state.showConfirmAppointAsAdmin = true;
1055 i.setState(i.state);
1058 handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1059 i.state.showConfirmAppointAsAdmin = false;
1060 i.setState(i.state);
1063 handleAddAdmin(i: CommentNode) {
1064 let form: AddAdminForm = {
1065 user_id: i.props.node.comment.creator_id,
1068 WebSocketService.Instance.addAdmin(form);
1069 i.state.showConfirmAppointAsAdmin = false;
1070 i.setState(i.state);
1073 handleShowConfirmTransferCommunity(i: CommentNode) {
1074 i.state.showConfirmTransferCommunity = true;
1075 i.setState(i.state);
1078 handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1079 i.state.showConfirmTransferCommunity = false;
1080 i.setState(i.state);
1083 handleTransferCommunity(i: CommentNode) {
1084 let form: TransferCommunityForm = {
1085 community_id: i.props.node.comment.community_id,
1086 user_id: i.props.node.comment.creator_id,
1088 WebSocketService.Instance.transferCommunity(form);
1089 i.state.showConfirmTransferCommunity = false;
1090 i.setState(i.state);
1093 handleShowConfirmTransferSite(i: CommentNode) {
1094 i.state.showConfirmTransferSite = true;
1095 i.setState(i.state);
1098 handleCancelShowConfirmTransferSite(i: CommentNode) {
1099 i.state.showConfirmTransferSite = false;
1100 i.setState(i.state);
1103 handleTransferSite(i: CommentNode) {
1104 let form: TransferSiteForm = {
1105 user_id: i.props.node.comment.creator_id,
1107 WebSocketService.Instance.transferSite(form);
1108 i.state.showConfirmTransferSite = false;
1109 i.setState(i.state);
1112 get isCommentNew(): boolean {
1113 let now = moment.utc().subtract(10, 'minutes');
1114 let then = moment.utc(this.props.node.comment.published);
1115 return now.isBefore(then);
1118 handleCommentCollapse(i: CommentNode) {
1119 i.state.collapsed = !i.state.collapsed;
1120 i.setState(i.state);
1123 handleViewSource(i: CommentNode) {
1124 i.state.viewSource = !i.state.viewSource;
1125 i.setState(i.state);
1128 handleShowAdvanced(i: CommentNode) {
1129 i.state.showAdvanced = !i.state.showAdvanced;
1130 i.setState(i.state);
1135 if (this.state.my_vote == 1) {
1137 } else if (this.state.my_vote == -1) {
1138 return 'text-danger';
1140 return 'text-muted';