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' : ''
126 id={`comment-${node.comment.id}`}
127 className={`details comment-node mb-1 ${
128 this.isCommentNew ? 'mark' : ''
131 !this.props.noIndent &&
132 this.props.node.comment.parent_id &&
133 `border-left: 1px solid; border-color: ${this.state.borderColor} !important`
137 class={`${!this.props.noIndent &&
138 this.props.node.comment.parent_id &&
141 <ul class="list-inline mb-1 text-muted small">
142 <li className="list-inline-item">
144 className="text-body font-weight-bold"
145 to={`/u/${node.comment.creator_name}`}
147 {node.comment.creator_avatar && showAvatars() && (
151 src={pictshareAvatarThumbnail(
152 node.comment.creator_avatar
154 class="rounded-circle mr-1"
157 <span>{node.comment.creator_name}</span>
161 <li className="list-inline-item badge badge-light">
166 <li className="list-inline-item badge badge-light">
170 {this.isPostCreator && (
171 <li className="list-inline-item badge badge-light">
175 {(node.comment.banned_from_community || node.comment.banned) && (
176 <li className="list-inline-item badge badge-danger">
180 <li className="list-inline-item">•</li>
182 class="unselectable pointer mr-2"
183 data-tippy-content={i18n.t('number_of_points', {
184 count: this.state.score,
187 <li className="list-inline-item">
188 <span className={this.scoreColor}>
189 <svg className="small icon icon-inline mr-1">
190 <use xlinkHref="#icon-zap"></use>
195 <li className="list-inline-item">
196 <svg class="small icon icon-inline mr-1">
197 <use xlinkHref="#icon-arrow-up"></use>
201 <li className="list-inline-item">
202 <svg class="small icon icon-inline mr-1">
203 <use xlinkHref="#icon-arrow-down"></use>
205 {this.state.downvotes}
208 {this.props.showCommunity && (
209 <li className="list-inline-item">
210 <span> {i18n.t('to')} </span>
211 <Link to={`/c/${node.comment.community_name}`}>
212 {node.comment.community_name}
216 <li className="list-inline-item">•</li>
217 <li className="list-inline-item">
219 <MomentTime data={node.comment} />
222 <li className="list-inline-item">
224 className="unselectable pointer text-monospace"
225 onClick={linkEvent(this, this.handleCommentCollapse)}
227 {this.state.collapsed ? (
229 <use xlinkHref="#icon-plus-square"></use>
233 <use xlinkHref="#icon-minus-square"></use>
239 {this.state.showEdit && (
243 onReplyCancel={this.handleReplyCancel}
244 disabled={this.props.locked}
247 {!this.state.showEdit && !this.state.collapsed && (
249 {this.state.viewSource ? (
250 <pre>{this.commentUnlessRemoved}</pre>
254 dangerouslySetInnerHTML={mdToHtml(
255 this.commentUnlessRemoved
259 <ul class="list-inline mb-0 text-muted font-weight-bold h5">
260 {this.props.markable && (
261 <li className="list-inline-item-action">
264 onClick={linkEvent(this, this.handleMarkRead)}
267 ? i18n.t('mark_as_unread')
268 : i18n.t('mark_as_read')
272 class={`icon icon-inline ${node.comment.read &&
275 <use xlinkHref="#icon-check"></use>
280 {UserService.Instance.user && !this.props.viewOnly && (
282 <li className="list-inline-item-action">
284 className={`vote-animate btn btn-link p-0 mb-1 ${
285 this.state.my_vote == 1 ? 'text-info' : 'text-muted'
287 onClick={linkEvent(node, this.handleCommentUpvote)}
288 data-tippy-content={i18n.t('upvote')}
291 <use xlinkHref="#icon-arrow-up"></use>
295 {WebSocketService.Instance.site.enable_downvotes && (
296 <li className="list-inline-item-action">
298 className={`vote-animate btn btn-link p-0 mb-1 ${
299 this.state.my_vote == -1
305 this.handleCommentDownvote
307 data-tippy-content={i18n.t('downvote')}
310 <use xlinkHref="#icon-arrow-down"></use>
315 <li className="list-inline-item-action">
318 onClick={linkEvent(this, this.handleReplyClick)}
319 data-tippy-content={i18n.t('reply')}
321 <svg class="icon icon-inline">
322 <use xlinkHref="#icon-reply1"></use>
326 {!this.myComment && (
327 <li className="list-inline-item-action">
330 to={`/create_private_message?recipient_id=${node.comment.creator_id}`}
331 title={i18n.t('message').toLowerCase()}
334 <use xlinkHref="#icon-mail"></use>
339 <li className="list-inline-item-action">
341 className="text-muted"
342 to={`/post/${node.comment.post_id}/comment/${node.comment.id}`}
343 title={i18n.t('link')}
345 <svg class="icon icon-inline">
346 <use xlinkHref="#icon-link"></use>
350 {!this.state.showAdvanced ? (
351 <li className="list-inline-item-action">
353 className="unselectable pointer"
354 onClick={linkEvent(this, this.handleShowAdvanced)}
355 data-tippy-content={i18n.t('more')}
357 <svg class="icon icon-inline">
358 <use xlinkHref="#icon-more-vertical"></use>
364 <li className="list-inline-item-action">
369 this.handleSaveCommentClick
378 class={`icon icon-inline ${node.comment.saved &&
381 <use xlinkHref="#icon-star"></use>
385 <li className="list-inline-item-action">
388 onClick={linkEvent(this, this.handleViewSource)}
389 data-tippy-content={i18n.t('view_source')}
392 class={`icon icon-inline ${this.state
393 .viewSource && 'text-success'}`}
395 <use xlinkHref="#icon-file-text"></use>
401 <li className="list-inline-item-action">•</li>
402 <li className="list-inline-item-action">
409 data-tippy-content={i18n.t('edit')}
411 <svg class="icon icon-inline">
412 <use xlinkHref="#icon-edit"></use>
416 <li className="list-inline-item-action">
421 this.handleDeleteClick
424 !node.comment.deleted
430 class={`icon icon-inline ${node.comment
431 .deleted && 'text-danger'}`}
433 <use xlinkHref="#icon-trash"></use>
439 {/* Admins and mods can remove comments */}
440 {(this.canMod || this.canAdmin) && (
442 <li className="list-inline-item-action">
443 {!node.comment.removed ? (
448 this.handleModRemoveShow
458 this.handleModRemoveSubmit
467 {/* Mods can ban from community, and appoint as mods to community */}
471 <li className="list-inline-item-action">
472 {!node.comment.banned_from_community ? (
477 this.handleModBanFromCommunityShow
487 this.handleModBanFromCommunitySubmit
495 {!node.comment.banned_from_community && (
496 <li className="list-inline-item-action">
497 {!this.state.showConfirmAppointAsMod ? (
502 this.handleShowConfirmAppointAsMod
506 ? i18n.t('remove_as_mod')
507 : i18n.t('appoint_as_mod')}
511 <span class="d-inline-block mr-1">
512 {i18n.t('are_you_sure')}
515 class="pointer d-inline-block mr-1"
518 this.handleAddModToCommunity
524 class="pointer d-inline-block"
527 this.handleCancelConfirmAppointAsMod
538 {/* Community creators and admins can transfer community to another mod */}
539 {(this.amCommunityCreator || this.canAdmin) &&
541 <li className="list-inline-item-action">
542 {!this.state.showConfirmTransferCommunity ? (
547 this.handleShowConfirmTransferCommunity
550 {i18n.t('transfer_community')}
554 <span class="d-inline-block mr-1">
555 {i18n.t('are_you_sure')}
558 class="pointer d-inline-block mr-1"
561 this.handleTransferCommunity
567 class="pointer d-inline-block"
571 .handleCancelShowConfirmTransferCommunity
580 {/* Admins can ban from all, and appoint other admins */}
584 <li className="list-inline-item-action">
585 {!node.comment.banned ? (
590 this.handleModBanShow
593 {i18n.t('ban_from_site')}
600 this.handleModBanSubmit
603 {i18n.t('unban_from_site')}
608 {!node.comment.banned && (
609 <li className="list-inline-item-action">
610 {!this.state.showConfirmAppointAsAdmin ? (
615 this.handleShowConfirmAppointAsAdmin
619 ? i18n.t('remove_as_admin')
620 : i18n.t('appoint_as_admin')}
624 <span class="d-inline-block mr-1">
625 {i18n.t('are_you_sure')}
628 class="pointer d-inline-block mr-1"
637 class="pointer d-inline-block"
640 this.handleCancelConfirmAppointAsAdmin
651 {/* Site Creator can transfer to another admin */}
652 {this.amSiteCreator && this.isAdmin && (
653 <li className="list-inline-item-action">
654 {!this.state.showConfirmTransferSite ? (
659 this.handleShowConfirmTransferSite
662 {i18n.t('transfer_site')}
666 <span class="d-inline-block mr-1">
667 {i18n.t('are_you_sure')}
670 class="pointer d-inline-block mr-1"
673 this.handleTransferSite
679 class="pointer d-inline-block"
682 this.handleCancelShowConfirmTransferSite
700 {this.state.showRemoveDialog && (
703 onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
707 class="form-control mr-2"
708 placeholder={i18n.t('reason')}
709 value={this.state.removeReason}
710 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
712 <button type="submit" class="btn btn-secondary">
713 {i18n.t('remove_comment')}
717 {this.state.showBanDialog && (
718 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
719 <div class="form-group row">
720 <label class="col-form-label">{i18n.t('reason')}</label>
723 class="form-control mr-2"
724 placeholder={i18n.t('reason')}
725 value={this.state.banReason}
726 onInput={linkEvent(this, this.handleModBanReasonChange)}
729 {/* TODO hold off on expires until later */}
730 {/* <div class="form-group row"> */}
731 {/* <label class="col-form-label">Expires</label> */}
732 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
734 <div class="form-group row">
735 <button type="submit" class="btn btn-secondary">
736 {i18n.t('ban')} {node.comment.creator_name}
741 {this.state.showReply && (
744 onReplyCancel={this.handleReplyCancel}
745 disabled={this.props.locked}
748 {node.children && !this.state.collapsed && (
750 nodes={node.children}
751 locked={this.props.locked}
752 moderators={this.props.moderators}
753 admins={this.props.admins}
754 postCreatorId={this.props.postCreatorId}
755 sort={this.props.sort}
756 sortType={this.props.sortType}
759 {/* A collapsed clearfix */}
760 {this.state.collapsed && <div class="row col-12"></div>}
765 get myComment(): boolean {
767 UserService.Instance.user &&
768 this.props.node.comment.creator_id == UserService.Instance.user.id
772 get isMod(): boolean {
774 this.props.moderators &&
776 this.props.moderators.map(m => m.user_id),
777 this.props.node.comment.creator_id
782 get isAdmin(): boolean {
786 this.props.admins.map(a => a.id),
787 this.props.node.comment.creator_id
792 get isPostCreator(): boolean {
793 return this.props.node.comment.creator_id == this.props.postCreatorId;
796 get canMod(): boolean {
797 if (this.props.admins && this.props.moderators) {
798 let adminsThenMods = this.props.admins
800 .concat(this.props.moderators.map(m => m.user_id));
803 UserService.Instance.user,
805 this.props.node.comment.creator_id
812 get canAdmin(): boolean {
816 UserService.Instance.user,
817 this.props.admins.map(a => a.id),
818 this.props.node.comment.creator_id
823 get amCommunityCreator(): boolean {
825 this.props.moderators &&
826 UserService.Instance.user &&
827 this.props.node.comment.creator_id != UserService.Instance.user.id &&
828 UserService.Instance.user.id == this.props.moderators[0].user_id
832 get amSiteCreator(): boolean {
835 UserService.Instance.user &&
836 this.props.node.comment.creator_id != UserService.Instance.user.id &&
837 UserService.Instance.user.id == this.props.admins[0].id
841 get commentUnlessRemoved(): string {
842 let node = this.props.node;
843 return node.comment.removed
844 ? `*${i18n.t('removed')}*`
845 : node.comment.deleted
846 ? `*${i18n.t('deleted')}*`
847 : node.comment.content;
850 handleReplyClick(i: CommentNode) {
851 i.state.showReply = true;
855 handleEditClick(i: CommentNode) {
856 i.state.showEdit = true;
860 handleDeleteClick(i: CommentNode) {
861 let deleteForm: CommentFormI = {
862 content: i.props.node.comment.content,
863 edit_id: i.props.node.comment.id,
864 creator_id: i.props.node.comment.creator_id,
865 post_id: i.props.node.comment.post_id,
866 parent_id: i.props.node.comment.parent_id,
867 deleted: !i.props.node.comment.deleted,
870 WebSocketService.Instance.editComment(deleteForm);
873 handleSaveCommentClick(i: CommentNode) {
875 i.props.node.comment.saved == undefined
877 : !i.props.node.comment.saved;
878 let form: SaveCommentForm = {
879 comment_id: i.props.node.comment.id,
883 WebSocketService.Instance.saveComment(form);
886 handleReplyCancel() {
887 this.state.showReply = false;
888 this.state.showEdit = false;
889 this.setState(this.state);
892 handleCommentUpvote(i: CommentNodeI) {
893 let new_vote = this.state.my_vote == 1 ? 0 : 1;
895 if (this.state.my_vote == 1) {
897 this.state.upvotes--;
898 } else if (this.state.my_vote == -1) {
899 this.state.downvotes--;
900 this.state.upvotes++;
901 this.state.score += 2;
903 this.state.upvotes++;
907 this.state.my_vote = new_vote;
909 let form: CommentLikeForm = {
910 comment_id: i.comment.id,
911 post_id: i.comment.post_id,
912 score: this.state.my_vote,
915 WebSocketService.Instance.likeComment(form);
916 this.setState(this.state);
919 handleCommentDownvote(i: CommentNodeI) {
920 let new_vote = this.state.my_vote == -1 ? 0 : -1;
922 if (this.state.my_vote == 1) {
923 this.state.score -= 2;
924 this.state.upvotes--;
925 this.state.downvotes++;
926 } else if (this.state.my_vote == -1) {
927 this.state.downvotes--;
930 this.state.downvotes++;
934 this.state.my_vote = new_vote;
936 let form: CommentLikeForm = {
937 comment_id: i.comment.id,
938 post_id: i.comment.post_id,
939 score: this.state.my_vote,
942 WebSocketService.Instance.likeComment(form);
943 this.setState(this.state);
946 handleModRemoveShow(i: CommentNode) {
947 i.state.showRemoveDialog = true;
951 handleModRemoveReasonChange(i: CommentNode, event: any) {
952 i.state.removeReason = event.target.value;
956 handleModRemoveSubmit(i: CommentNode) {
957 event.preventDefault();
958 let form: CommentFormI = {
959 content: i.props.node.comment.content,
960 edit_id: i.props.node.comment.id,
961 creator_id: i.props.node.comment.creator_id,
962 post_id: i.props.node.comment.post_id,
963 parent_id: i.props.node.comment.parent_id,
964 removed: !i.props.node.comment.removed,
965 reason: i.state.removeReason,
968 WebSocketService.Instance.editComment(form);
970 i.state.showRemoveDialog = false;
974 handleMarkRead(i: CommentNode) {
975 // if it has a user_mention_id field, then its a mention
976 if (i.props.node.comment.user_mention_id) {
977 let form: EditUserMentionForm = {
978 user_mention_id: i.props.node.comment.user_mention_id,
979 read: !i.props.node.comment.read,
981 WebSocketService.Instance.editUserMention(form);
983 let form: CommentFormI = {
984 content: i.props.node.comment.content,
985 edit_id: i.props.node.comment.id,
986 creator_id: i.props.node.comment.creator_id,
987 post_id: i.props.node.comment.post_id,
988 parent_id: i.props.node.comment.parent_id,
989 read: !i.props.node.comment.read,
992 WebSocketService.Instance.editComment(form);
996 handleModBanFromCommunityShow(i: CommentNode) {
997 i.state.showBanDialog = !i.state.showBanDialog;
998 i.state.banType = BanType.Community;
1002 handleModBanShow(i: CommentNode) {
1003 i.state.showBanDialog = !i.state.showBanDialog;
1004 i.state.banType = BanType.Site;
1005 i.setState(i.state);
1008 handleModBanReasonChange(i: CommentNode, event: any) {
1009 i.state.banReason = event.target.value;
1010 i.setState(i.state);
1013 handleModBanExpiresChange(i: CommentNode, event: any) {
1014 i.state.banExpires = event.target.value;
1015 i.setState(i.state);
1018 handleModBanFromCommunitySubmit(i: CommentNode) {
1019 i.state.banType = BanType.Community;
1020 i.setState(i.state);
1021 i.handleModBanBothSubmit(i);
1024 handleModBanSubmit(i: CommentNode) {
1025 i.state.banType = BanType.Site;
1026 i.setState(i.state);
1027 i.handleModBanBothSubmit(i);
1030 handleModBanBothSubmit(i: CommentNode) {
1031 event.preventDefault();
1033 if (i.state.banType == BanType.Community) {
1034 let form: BanFromCommunityForm = {
1035 user_id: i.props.node.comment.creator_id,
1036 community_id: i.props.node.comment.community_id,
1037 ban: !i.props.node.comment.banned_from_community,
1038 reason: i.state.banReason,
1039 expires: getUnixTime(i.state.banExpires),
1041 WebSocketService.Instance.banFromCommunity(form);
1043 let form: BanUserForm = {
1044 user_id: i.props.node.comment.creator_id,
1045 ban: !i.props.node.comment.banned,
1046 reason: i.state.banReason,
1047 expires: getUnixTime(i.state.banExpires),
1049 WebSocketService.Instance.banUser(form);
1052 i.state.showBanDialog = false;
1053 i.setState(i.state);
1056 handleShowConfirmAppointAsMod(i: CommentNode) {
1057 i.state.showConfirmAppointAsMod = true;
1058 i.setState(i.state);
1061 handleCancelConfirmAppointAsMod(i: CommentNode) {
1062 i.state.showConfirmAppointAsMod = false;
1063 i.setState(i.state);
1066 handleAddModToCommunity(i: CommentNode) {
1067 let form: AddModToCommunityForm = {
1068 user_id: i.props.node.comment.creator_id,
1069 community_id: i.props.node.comment.community_id,
1072 WebSocketService.Instance.addModToCommunity(form);
1073 i.state.showConfirmAppointAsMod = false;
1074 i.setState(i.state);
1077 handleShowConfirmAppointAsAdmin(i: CommentNode) {
1078 i.state.showConfirmAppointAsAdmin = true;
1079 i.setState(i.state);
1082 handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1083 i.state.showConfirmAppointAsAdmin = false;
1084 i.setState(i.state);
1087 handleAddAdmin(i: CommentNode) {
1088 let form: AddAdminForm = {
1089 user_id: i.props.node.comment.creator_id,
1092 WebSocketService.Instance.addAdmin(form);
1093 i.state.showConfirmAppointAsAdmin = false;
1094 i.setState(i.state);
1097 handleShowConfirmTransferCommunity(i: CommentNode) {
1098 i.state.showConfirmTransferCommunity = true;
1099 i.setState(i.state);
1102 handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1103 i.state.showConfirmTransferCommunity = false;
1104 i.setState(i.state);
1107 handleTransferCommunity(i: CommentNode) {
1108 let form: TransferCommunityForm = {
1109 community_id: i.props.node.comment.community_id,
1110 user_id: i.props.node.comment.creator_id,
1112 WebSocketService.Instance.transferCommunity(form);
1113 i.state.showConfirmTransferCommunity = false;
1114 i.setState(i.state);
1117 handleShowConfirmTransferSite(i: CommentNode) {
1118 i.state.showConfirmTransferSite = true;
1119 i.setState(i.state);
1122 handleCancelShowConfirmTransferSite(i: CommentNode) {
1123 i.state.showConfirmTransferSite = false;
1124 i.setState(i.state);
1127 handleTransferSite(i: CommentNode) {
1128 let form: TransferSiteForm = {
1129 user_id: i.props.node.comment.creator_id,
1131 WebSocketService.Instance.transferSite(form);
1132 i.state.showConfirmTransferSite = false;
1133 i.setState(i.state);
1136 get isCommentNew(): boolean {
1137 let now = moment.utc().subtract(10, 'minutes');
1138 let then = moment.utc(this.props.node.comment.published);
1139 return now.isBefore(then);
1142 handleCommentCollapse(i: CommentNode) {
1143 i.state.collapsed = !i.state.collapsed;
1144 i.setState(i.state);
1147 handleViewSource(i: CommentNode) {
1148 i.state.viewSource = !i.state.viewSource;
1149 i.setState(i.state);
1152 handleShowAdvanced(i: CommentNode) {
1153 i.state.showAdvanced = !i.state.showAdvanced;
1154 i.setState(i.state);
1159 if (this.state.my_vote == 1) {
1161 } else if (this.state.my_vote == -1) {
1162 return 'text-danger';
1164 return 'text-muted';