9 import { futureDaysToUnixTime, numToSI } from "@utils/helpers";
17 } from "@utils/roles";
18 import classNames from "classnames";
19 import isBefore from "date-fns/isBefore";
20 import parseISO from "date-fns/parseISO";
21 import subMinutes from "date-fns/subMinutes";
22 import { Component, InfernoNode, linkEvent } from "inferno";
23 import { Link } from "inferno-router";
33 CommunityModeratorView,
42 MarkCommentReplyAsRead,
43 MarkPersonMentionAsRead,
51 } from "lemmy-js-client";
52 import deepEqual from "lodash.isequal";
53 import { commentTreeMaxDepth } from "../../config";
60 } from "../../interfaces";
61 import { mdToHtml, mdToHtmlNoImages } from "../../markdown";
62 import { I18NextService, UserService } from "../../services";
63 import { setupTippy } from "../../tippy";
64 import { Icon, PurgeWarning, Spinner } from "../common/icon";
65 import { MomentTime } from "../common/moment-time";
66 import { CommunityLink } from "../community/community-link";
67 import { PersonListing } from "../person/person-listing";
68 import { CommentForm } from "./comment-form";
69 import { CommentNodes } from "./comment-nodes";
71 interface CommentNodeState {
74 showRemoveDialog: boolean;
75 removeReason?: string;
76 showBanDialog: boolean;
79 banExpireDays?: number;
81 showPurgeDialog: boolean;
84 showConfirmTransferSite: boolean;
85 showConfirmTransferCommunity: boolean;
86 showConfirmAppointAsMod: boolean;
87 showConfirmAppointAsAdmin: boolean;
90 showAdvanced: boolean;
91 showReportDialog: boolean;
92 reportReason?: string;
93 createOrEditCommentLoading: boolean;
94 upvoteLoading: boolean;
95 downvoteLoading: boolean;
98 blockPersonLoading: boolean;
99 deleteLoading: boolean;
100 removeLoading: boolean;
101 distinguishLoading: boolean;
103 addModLoading: boolean;
104 addAdminLoading: boolean;
105 transferCommunityLoading: boolean;
106 fetchChildrenLoading: boolean;
107 reportLoading: boolean;
108 purgeLoading: boolean;
111 interface CommentNodeProps {
113 moderators?: CommunityModeratorView[];
114 admins?: PersonView[];
120 showContext?: boolean;
121 showCommunity?: boolean;
122 enableDownvotes?: boolean;
123 viewType: CommentViewType;
124 allLanguages: Language[];
125 siteLanguages: number[];
126 hideImages?: boolean;
127 finished: Map<CommentId, boolean | undefined>;
128 onSaveComment(form: SaveComment): void;
129 onCommentReplyRead(form: MarkCommentReplyAsRead): void;
130 onPersonMentionRead(form: MarkPersonMentionAsRead): void;
131 onCreateComment(form: EditComment | CreateComment): void;
132 onEditComment(form: EditComment | CreateComment): void;
133 onCommentVote(form: CreateCommentLike): void;
134 onBlockPerson(form: BlockPerson): void;
135 onDeleteComment(form: DeleteComment): void;
136 onRemoveComment(form: RemoveComment): void;
137 onDistinguishComment(form: DistinguishComment): void;
138 onAddModToCommunity(form: AddModToCommunity): void;
139 onAddAdmin(form: AddAdmin): void;
140 onBanPersonFromCommunity(form: BanFromCommunity): void;
141 onBanPerson(form: BanPerson): void;
142 onTransferCommunity(form: TransferCommunity): void;
143 onFetchChildren?(form: GetComments): void;
144 onCommentReport(form: CreateCommentReport): void;
145 onPurgePerson(form: PurgePerson): void;
146 onPurgeComment(form: PurgeComment): void;
149 export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
150 state: CommentNodeState = {
153 showRemoveDialog: false,
154 showBanDialog: false,
156 banType: BanType.Community,
157 showPurgeDialog: false,
158 purgeType: PurgeType.Person,
162 showConfirmTransferSite: false,
163 showConfirmTransferCommunity: false,
164 showConfirmAppointAsMod: false,
165 showConfirmAppointAsAdmin: false,
166 showReportDialog: false,
167 createOrEditCommentLoading: false,
168 upvoteLoading: false,
169 downvoteLoading: false,
172 blockPersonLoading: false,
173 deleteLoading: false,
174 removeLoading: false,
175 distinguishLoading: false,
177 addModLoading: false,
178 addAdminLoading: false,
179 transferCommunityLoading: false,
180 fetchChildrenLoading: false,
181 reportLoading: false,
185 constructor(props: any, context: any) {
186 super(props, context);
188 this.handleReplyCancel = this.handleReplyCancel.bind(this);
191 get commentView(): CommentView {
192 return this.props.node.comment_view;
195 get commentId(): CommentId {
196 return this.commentView.comment.id;
199 componentWillReceiveProps(
200 nextProps: Readonly<{ children?: InfernoNode } & CommentNodeProps>
202 if (!deepEqual(this.props, nextProps)) {
206 showRemoveDialog: false,
207 showBanDialog: false,
209 banType: BanType.Community,
210 showPurgeDialog: false,
211 purgeType: PurgeType.Person,
215 showConfirmTransferSite: false,
216 showConfirmTransferCommunity: false,
217 showConfirmAppointAsMod: false,
218 showConfirmAppointAsAdmin: false,
219 showReportDialog: false,
220 createOrEditCommentLoading: false,
221 upvoteLoading: false,
222 downvoteLoading: false,
225 blockPersonLoading: false,
226 deleteLoading: false,
227 removeLoading: false,
228 distinguishLoading: false,
230 addModLoading: false,
231 addAdminLoading: false,
232 transferCommunityLoading: false,
233 fetchChildrenLoading: false,
234 reportLoading: false,
241 const node = this.props.node;
242 const cv = this.commentView;
244 const purgeTypeText =
245 this.state.purgeType == PurgeType.Comment
246 ? I18NextService.i18n.t("purge_comment")
247 : `${I18NextService.i18n.t("purge")} ${cv.creator.name}`;
249 const canMod_ = canMod(
251 this.props.moderators,
254 const canModOnSelf = canMod(
256 this.props.moderators,
258 UserService.Instance.myUserInfo,
261 const canAdmin_ = canAdmin(cv.creator.id, this.props.admins);
262 const canAdminOnSelf = canAdmin(
265 UserService.Instance.myUserInfo,
268 const isMod_ = isMod(cv.creator.id, this.props.moderators);
269 const isAdmin_ = isAdmin(cv.creator.id, this.props.admins);
270 const amCommunityCreator_ = amCommunityCreator(
272 this.props.moderators
275 const moreRepliesBorderColor = this.props.node.depth
276 ? colorList[this.props.node.depth % colorList.length]
279 const showMoreChildren =
280 this.props.viewType == CommentViewType.Tree &&
281 !this.state.collapsed &&
282 node.children.length == 0 &&
283 node.comment_view.counts.child_count > 0;
286 <li className="comment" role="comment">
288 id={`comment-${cv.comment.id}`}
289 className={classNames(`details comment-node py-2`, {
290 "border-top border-light": !this.props.noBorder,
291 mark: this.isCommentNew || this.commentView.comment.distinguished,
295 className={classNames({
296 "ms-2": !this.props.noIndent,
299 <div className="d-flex flex-wrap align-items-center text-muted small">
301 className="btn btn-sm text-muted me-2"
302 onClick={linkEvent(this, this.handleCommentCollapse)}
303 aria-label={this.expandText}
304 data-tippy-content={this.expandText}
307 icon={`${this.state.collapsed ? "plus" : "minus"}-square`}
308 classes="icon-inline"
311 <span className="me-2">
312 <PersonListing person={cv.creator} />
314 {cv.comment.distinguished && (
315 <Icon icon="shield" inline classes={`text-danger me-2`} />
317 {this.isPostCreator && (
318 <div className="badge text-bg-light d-none d-sm-inline me-2">
319 {I18NextService.i18n.t("creator")}
323 <div className="badge text-bg-light d-none d-sm-inline me-2">
324 {I18NextService.i18n.t("mod")}
328 <div className="badge text-bg-light d-none d-sm-inline me-2">
329 {I18NextService.i18n.t("admin")}
332 {cv.creator.bot_account && (
333 <div className="badge text-bg-light d-none d-sm-inline me-2">
334 {I18NextService.i18n.t("bot_account").toLowerCase()}
337 {this.props.showCommunity && (
339 <span className="mx-1">{I18NextService.i18n.t("to")}</span>
340 <CommunityLink community={cv.community} />
341 <span className="mx-2">•</span>
342 <Link className="me-2" to={`/post/${cv.post.id}`}>
348 {cv.comment.language_id !== 0 && (
349 <span className="badge text-bg-light d-none d-sm-inline me-2">
351 this.props.allLanguages.find(
352 lang => lang.id === cv.comment.language_id
357 {/* This is an expanding spacer for mobile */}
358 <div className="me-lg-5 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2" />
362 className={`unselectable pointer ${this.scoreColor}`}
363 onClick={linkEvent(this, this.handleUpvote)}
364 data-tippy-content={this.pointsTippy}
366 {this.state.upvoteLoading ? (
370 className="me-1 fw-bold"
371 aria-label={I18NextService.i18n.t("number_of_points", {
372 count: Number(this.commentView.counts.score),
373 formattedCount: numToSI(
374 this.commentView.counts.score
378 {numToSI(this.commentView.counts.score)}
382 <span className="me-1">•</span>
387 published={cv.comment.published}
388 updated={cv.comment.updated}
392 {/* end of user row */}
393 {this.state.showEdit && (
397 onReplyCancel={this.handleReplyCancel}
398 disabled={this.props.locked}
399 finished={this.props.finished.get(
400 this.props.node.comment_view.comment.id
403 allLanguages={this.props.allLanguages}
404 siteLanguages={this.props.siteLanguages}
405 containerClass="comment-comment-container"
406 onUpsertComment={this.props.onEditComment}
409 {!this.state.showEdit && !this.state.collapsed && (
411 {this.state.viewSource ? (
412 <pre>{this.commentUnlessRemoved}</pre>
416 dangerouslySetInnerHTML={
417 this.props.hideImages
418 ? mdToHtmlNoImages(this.commentUnlessRemoved)
419 : mdToHtml(this.commentUnlessRemoved)
423 <div className="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted fw-bold">
424 {this.props.showContext && this.linkBtn()}
425 {this.props.markable && (
427 className="btn btn-link btn-animate text-muted"
428 onClick={linkEvent(this, this.handleMarkAsRead)}
430 this.commentReplyOrMentionRead
431 ? I18NextService.i18n.t("mark_as_unread")
432 : I18NextService.i18n.t("mark_as_read")
435 this.commentReplyOrMentionRead
436 ? I18NextService.i18n.t("mark_as_unread")
437 : I18NextService.i18n.t("mark_as_read")
440 {this.state.readLoading ? (
445 classes={`icon-inline ${
446 this.commentReplyOrMentionRead && "text-success"
452 {UserService.Instance.myUserInfo && !this.props.viewOnly && (
455 className={`btn btn-link btn-animate ${
456 this.commentView.my_vote === 1
460 onClick={linkEvent(this, this.handleUpvote)}
461 data-tippy-content={I18NextService.i18n.t("upvote")}
462 aria-label={I18NextService.i18n.t("upvote")}
463 aria-pressed={this.commentView.my_vote === 1}
465 {this.state.upvoteLoading ? (
469 <Icon icon="arrow-up1" classes="icon-inline" />
471 this.commentView.counts.upvotes !==
472 this.commentView.counts.score && (
473 <span className="ms-1">
474 {numToSI(this.commentView.counts.upvotes)}
480 {this.props.enableDownvotes && (
482 className={`btn btn-link btn-animate ${
483 this.commentView.my_vote === -1
487 onClick={linkEvent(this, this.handleDownvote)}
488 data-tippy-content={I18NextService.i18n.t("downvote")}
489 aria-label={I18NextService.i18n.t("downvote")}
490 aria-pressed={this.commentView.my_vote === -1}
492 {this.state.downvoteLoading ? (
496 <Icon icon="arrow-down1" classes="icon-inline" />
498 this.commentView.counts.upvotes !==
499 this.commentView.counts.score && (
500 <span className="ms-1">
501 {numToSI(this.commentView.counts.downvotes)}
509 className="btn btn-link btn-animate text-muted"
510 onClick={linkEvent(this, this.handleReplyClick)}
511 data-tippy-content={I18NextService.i18n.t("reply")}
512 aria-label={I18NextService.i18n.t("reply")}
514 <Icon icon="reply1" classes="icon-inline" />
516 {!this.state.showAdvanced ? (
518 className="btn btn-link btn-animate text-muted btn-more"
519 onClick={linkEvent(this, this.handleShowAdvanced)}
520 data-tippy-content={I18NextService.i18n.t("more")}
521 aria-label={I18NextService.i18n.t("more")}
523 <Icon icon="more-vertical" classes="icon-inline" />
527 {!this.myComment && (
530 className="btn btn-link btn-animate text-muted"
531 to={`/create_private_message/${cv.creator.id}`}
532 title={I18NextService.i18n
539 className="btn btn-link btn-animate text-muted"
542 this.handleShowReportDialog
544 data-tippy-content={I18NextService.i18n.t(
547 aria-label={I18NextService.i18n.t(
554 className="btn btn-link btn-animate text-muted"
557 this.handleBlockPerson
559 data-tippy-content={I18NextService.i18n.t(
562 aria-label={I18NextService.i18n.t("block_user")}
564 {this.state.blockPersonLoading ? (
567 <Icon icon="slash" />
573 className="btn btn-link btn-animate text-muted"
574 onClick={linkEvent(this, this.handleSaveComment)}
577 ? I18NextService.i18n.t("unsave")
578 : I18NextService.i18n.t("save")
582 ? I18NextService.i18n.t("unsave")
583 : I18NextService.i18n.t("save")
586 {this.state.saveLoading ? (
591 classes={`icon-inline ${
592 cv.saved && "text-warning"
598 className="btn btn-link btn-animate text-muted"
599 onClick={linkEvent(this, this.handleViewSource)}
600 data-tippy-content={I18NextService.i18n.t(
603 aria-label={I18NextService.i18n.t("view_source")}
607 classes={`icon-inline ${
608 this.state.viewSource && "text-success"
615 className="btn btn-link btn-animate text-muted"
616 onClick={linkEvent(this, this.handleEditClick)}
617 data-tippy-content={I18NextService.i18n.t(
620 aria-label={I18NextService.i18n.t("edit")}
622 <Icon icon="edit" classes="icon-inline" />
625 className="btn btn-link btn-animate text-muted"
628 this.handleDeleteComment
632 ? I18NextService.i18n.t("delete")
633 : I18NextService.i18n.t("restore")
637 ? I18NextService.i18n.t("delete")
638 : I18NextService.i18n.t("restore")
641 {this.state.deleteLoading ? (
646 classes={`icon-inline ${
647 cv.comment.deleted && "text-danger"
653 {(canModOnSelf || canAdminOnSelf) && (
655 className="btn btn-link btn-animate text-muted"
658 this.handleDistinguishComment
661 !cv.comment.distinguished
662 ? I18NextService.i18n.t("distinguish")
663 : I18NextService.i18n.t("undistinguish")
666 !cv.comment.distinguished
667 ? I18NextService.i18n.t("distinguish")
668 : I18NextService.i18n.t("undistinguish")
673 classes={`icon-inline ${
674 cv.comment.distinguished && "text-danger"
681 {/* Admins and mods can remove comments */}
682 {(canMod_ || canAdmin_) && (
684 {!cv.comment.removed ? (
686 className="btn btn-link btn-animate text-muted"
689 this.handleModRemoveShow
691 aria-label={I18NextService.i18n.t("remove")}
693 {I18NextService.i18n.t("remove")}
697 className="btn btn-link btn-animate text-muted"
700 this.handleRemoveComment
702 aria-label={I18NextService.i18n.t("restore")}
704 {this.state.removeLoading ? (
707 I18NextService.i18n.t("restore")
713 {/* Mods can ban from community, and appoint as mods to community */}
717 (!cv.creator_banned_from_community ? (
719 className="btn btn-link btn-animate text-muted"
722 this.handleModBanFromCommunityShow
724 aria-label={I18NextService.i18n.t(
728 {I18NextService.i18n.t(
734 className="btn btn-link btn-animate text-muted"
737 this.handleBanPersonFromCommunity
739 aria-label={I18NextService.i18n.t("unban")}
741 {this.state.banLoading ? (
744 I18NextService.i18n.t("unban")
748 {!cv.creator_banned_from_community &&
749 (!this.state.showConfirmAppointAsMod ? (
751 className="btn btn-link btn-animate text-muted"
754 this.handleShowConfirmAppointAsMod
758 ? I18NextService.i18n.t("remove_as_mod")
759 : I18NextService.i18n.t(
765 ? I18NextService.i18n.t("remove_as_mod")
766 : I18NextService.i18n.t("appoint_as_mod")}
771 className="btn btn-link btn-animate text-muted"
772 aria-label={I18NextService.i18n.t(
776 {I18NextService.i18n.t("are_you_sure")}
779 className="btn btn-link btn-animate text-muted"
782 this.handleAddModToCommunity
784 aria-label={I18NextService.i18n.t("yes")}
786 {this.state.addModLoading ? (
789 I18NextService.i18n.t("yes")
793 className="btn btn-link btn-animate text-muted"
796 this.handleCancelConfirmAppointAsMod
798 aria-label={I18NextService.i18n.t("no")}
800 {I18NextService.i18n.t("no")}
806 {/* Community creators and admins can transfer community to another mod */}
807 {(amCommunityCreator_ || canAdmin_) &&
810 (!this.state.showConfirmTransferCommunity ? (
812 className="btn btn-link btn-animate text-muted"
815 this.handleShowConfirmTransferCommunity
817 aria-label={I18NextService.i18n.t(
821 {I18NextService.i18n.t("transfer_community")}
826 className="btn btn-link btn-animate text-muted"
827 aria-label={I18NextService.i18n.t(
831 {I18NextService.i18n.t("are_you_sure")}
834 className="btn btn-link btn-animate text-muted"
837 this.handleTransferCommunity
839 aria-label={I18NextService.i18n.t("yes")}
841 {this.state.transferCommunityLoading ? (
844 I18NextService.i18n.t("yes")
848 className="btn btn-link btn-animate text-muted"
852 .handleCancelShowConfirmTransferCommunity
854 aria-label={I18NextService.i18n.t("no")}
856 {I18NextService.i18n.t("no")}
860 {/* Admins can ban from all, and appoint other admins */}
866 className="btn btn-link btn-animate text-muted"
869 this.handlePurgePersonShow
871 aria-label={I18NextService.i18n.t(
875 {I18NextService.i18n.t("purge_user")}
878 className="btn btn-link btn-animate text-muted"
881 this.handlePurgeCommentShow
883 aria-label={I18NextService.i18n.t(
887 {I18NextService.i18n.t("purge_comment")}
890 {!isBanned(cv.creator) ? (
892 className="btn btn-link btn-animate text-muted"
895 this.handleModBanShow
897 aria-label={I18NextService.i18n.t(
901 {I18NextService.i18n.t("ban_from_site")}
905 className="btn btn-link btn-animate text-muted"
910 aria-label={I18NextService.i18n.t(
914 {this.state.banLoading ? (
917 I18NextService.i18n.t("unban_from_site")
923 {!isBanned(cv.creator) &&
925 (!this.state.showConfirmAppointAsAdmin ? (
927 className="btn btn-link btn-animate text-muted"
930 this.handleShowConfirmAppointAsAdmin
934 ? I18NextService.i18n.t(
937 : I18NextService.i18n.t(
943 ? I18NextService.i18n.t("remove_as_admin")
944 : I18NextService.i18n.t(
950 <button className="btn btn-link btn-animate text-muted">
951 {I18NextService.i18n.t("are_you_sure")}
954 className="btn btn-link btn-animate text-muted"
959 aria-label={I18NextService.i18n.t("yes")}
961 {this.state.addAdminLoading ? (
964 I18NextService.i18n.t("yes")
968 className="btn btn-link btn-animate text-muted"
971 this.handleCancelConfirmAppointAsAdmin
973 aria-label={I18NextService.i18n.t("no")}
975 {I18NextService.i18n.t("no")}
986 {/* end of button group */}
991 {showMoreChildren && (
993 className={classNames("details ms-1 comment-node py-2", {
994 "border-top border-light": !this.props.noBorder,
996 style={`border-left: 2px ${moreRepliesBorderColor} solid !important`}
999 className="btn btn-link text-muted"
1000 onClick={linkEvent(this, this.handleFetchChildren)}
1002 {this.state.fetchChildrenLoading ? (
1006 {I18NextService.i18n.t("x_more_replies", {
1007 count: node.comment_view.counts.child_count,
1008 formattedCount: numToSI(
1009 node.comment_view.counts.child_count
1018 {/* end of details */}
1019 {this.state.showRemoveDialog && (
1021 className="form-inline"
1022 onSubmit={linkEvent(this, this.handleRemoveComment)}
1025 className="visually-hidden"
1026 htmlFor={`mod-remove-reason-${cv.comment.id}`}
1028 {I18NextService.i18n.t("reason")}
1032 id={`mod-remove-reason-${cv.comment.id}`}
1033 className="form-control me-2"
1034 placeholder={I18NextService.i18n.t("reason")}
1035 value={this.state.removeReason}
1036 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
1040 className="btn btn-secondary"
1041 aria-label={I18NextService.i18n.t("remove_comment")}
1043 {I18NextService.i18n.t("remove_comment")}
1047 {this.state.showReportDialog && (
1049 className="form-inline"
1050 onSubmit={linkEvent(this, this.handleReportComment)}
1053 className="visually-hidden"
1054 htmlFor={`report-reason-${cv.comment.id}`}
1056 {I18NextService.i18n.t("reason")}
1061 id={`report-reason-${cv.comment.id}`}
1062 className="form-control me-2"
1063 placeholder={I18NextService.i18n.t("reason")}
1064 value={this.state.reportReason}
1065 onInput={linkEvent(this, this.handleReportReasonChange)}
1069 className="btn btn-secondary"
1070 aria-label={I18NextService.i18n.t("create_report")}
1072 {I18NextService.i18n.t("create_report")}
1076 {this.state.showBanDialog && (
1077 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
1078 <div className="mb-3 row col-12">
1080 className="col-form-label"
1081 htmlFor={`mod-ban-reason-${cv.comment.id}`}
1083 {I18NextService.i18n.t("reason")}
1087 id={`mod-ban-reason-${cv.comment.id}`}
1088 className="form-control me-2"
1089 placeholder={I18NextService.i18n.t("reason")}
1090 value={this.state.banReason}
1091 onInput={linkEvent(this, this.handleModBanReasonChange)}
1094 className="col-form-label"
1095 htmlFor={`mod-ban-expires-${cv.comment.id}`}
1097 {I18NextService.i18n.t("expires")}
1101 id={`mod-ban-expires-${cv.comment.id}`}
1102 className="form-control me-2"
1103 placeholder={I18NextService.i18n.t("number_of_days")}
1104 value={this.state.banExpireDays}
1105 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
1107 <div className="input-group mb-3">
1108 <div className="form-check">
1110 className="form-check-input"
1111 id="mod-ban-remove-data"
1113 checked={this.state.removeData}
1114 onChange={linkEvent(this, this.handleModRemoveDataChange)}
1117 className="form-check-label"
1118 htmlFor="mod-ban-remove-data"
1119 title={I18NextService.i18n.t("remove_content_more")}
1121 {I18NextService.i18n.t("remove_content")}
1126 {/* TODO hold off on expires until later */}
1127 {/* <div class="mb-3 row"> */}
1128 {/* <label class="col-form-label">Expires</label> */}
1129 {/* <input type="date" class="form-control me-2" placeholder={I18NextService.i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1131 <div className="mb-3 row">
1134 className="btn btn-secondary"
1135 aria-label={I18NextService.i18n.t("ban")}
1137 {this.state.banLoading ? (
1141 {I18NextService.i18n.t("ban")} {cv.creator.name}
1149 {this.state.showPurgeDialog && (
1150 <form onSubmit={linkEvent(this, this.handlePurgeBothSubmit)}>
1152 <label className="visually-hidden" htmlFor="purge-reason">
1153 {I18NextService.i18n.t("reason")}
1158 className="form-control my-3"
1159 placeholder={I18NextService.i18n.t("reason")}
1160 value={this.state.purgeReason}
1161 onInput={linkEvent(this, this.handlePurgeReasonChange)}
1163 <div className="mb-3 row col-12">
1164 {this.state.purgeLoading ? (
1169 className="btn btn-secondary"
1170 aria-label={purgeTypeText}
1178 {this.state.showReply && (
1181 onReplyCancel={this.handleReplyCancel}
1182 disabled={this.props.locked}
1183 finished={this.props.finished.get(
1184 this.props.node.comment_view.comment.id
1187 allLanguages={this.props.allLanguages}
1188 siteLanguages={this.props.siteLanguages}
1189 containerClass="comment-comment-container"
1190 onUpsertComment={this.props.onCreateComment}
1193 {!this.state.collapsed && node.children.length > 0 && (
1195 nodes={node.children}
1196 locked={this.props.locked}
1197 moderators={this.props.moderators}
1198 admins={this.props.admins}
1199 enableDownvotes={this.props.enableDownvotes}
1200 viewType={this.props.viewType}
1201 allLanguages={this.props.allLanguages}
1202 siteLanguages={this.props.siteLanguages}
1203 hideImages={this.props.hideImages}
1204 isChild={!this.props.noIndent}
1205 depth={this.props.node.depth + 1}
1206 finished={this.props.finished}
1207 onCommentReplyRead={this.props.onCommentReplyRead}
1208 onPersonMentionRead={this.props.onPersonMentionRead}
1209 onCreateComment={this.props.onCreateComment}
1210 onEditComment={this.props.onEditComment}
1211 onCommentVote={this.props.onCommentVote}
1212 onBlockPerson={this.props.onBlockPerson}
1213 onSaveComment={this.props.onSaveComment}
1214 onDeleteComment={this.props.onDeleteComment}
1215 onRemoveComment={this.props.onRemoveComment}
1216 onDistinguishComment={this.props.onDistinguishComment}
1217 onAddModToCommunity={this.props.onAddModToCommunity}
1218 onAddAdmin={this.props.onAddAdmin}
1219 onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
1220 onBanPerson={this.props.onBanPerson}
1221 onTransferCommunity={this.props.onTransferCommunity}
1222 onFetchChildren={this.props.onFetchChildren}
1223 onCommentReport={this.props.onCommentReport}
1224 onPurgePerson={this.props.onPurgePerson}
1225 onPurgeComment={this.props.onPurgeComment}
1228 {/* A collapsed clearfix */}
1229 {this.state.collapsed && <div className="row col-12" />}
1234 get commentReplyOrMentionRead(): boolean {
1235 const cv = this.commentView;
1237 if (this.isPersonMentionType(cv)) {
1238 return cv.person_mention.read;
1239 } else if (this.isCommentReplyType(cv)) {
1240 return cv.comment_reply.read;
1246 linkBtn(small = false) {
1247 const cv = this.commentView;
1249 const classnames = classNames("btn btn-link btn-animate text-muted", {
1253 const title = this.props.showContext
1254 ? I18NextService.i18n.t("show_context")
1255 : I18NextService.i18n.t("link");
1257 // The context button should show the parent comment by default
1258 const parentCommentId = getCommentParentId(cv.comment) ?? cv.comment.id;
1263 className={classnames}
1264 to={`/comment/${parentCommentId}`}
1267 <Icon icon="link" classes="icon-inline" />
1270 <a className={classnames} title={title} href={cv.comment.ap_id}>
1271 <Icon icon="fedilink" classes="icon-inline" />
1278 get myComment(): boolean {
1280 UserService.Instance.myUserInfo?.local_user_view.person.id ==
1281 this.commentView.creator.id
1285 get isPostCreator(): boolean {
1286 return this.commentView.creator.id == this.commentView.post.creator_id;
1290 if (this.commentView.my_vote == 1) {
1292 } else if (this.commentView.my_vote == -1) {
1293 return "text-danger";
1295 return "text-muted";
1299 get pointsTippy(): string {
1300 const points = I18NextService.i18n.t("number_of_points", {
1301 count: Number(this.commentView.counts.score),
1302 formattedCount: numToSI(this.commentView.counts.score),
1305 const upvotes = I18NextService.i18n.t("number_of_upvotes", {
1306 count: Number(this.commentView.counts.upvotes),
1307 formattedCount: numToSI(this.commentView.counts.upvotes),
1310 const downvotes = I18NextService.i18n.t("number_of_downvotes", {
1311 count: Number(this.commentView.counts.downvotes),
1312 formattedCount: numToSI(this.commentView.counts.downvotes),
1315 return `${points} • ${upvotes} • ${downvotes}`;
1318 get expandText(): string {
1319 return this.state.collapsed
1320 ? I18NextService.i18n.t("expand")
1321 : I18NextService.i18n.t("collapse");
1324 get commentUnlessRemoved(): string {
1325 const comment = this.commentView.comment;
1326 return comment.removed
1327 ? `*${I18NextService.i18n.t("removed")}*`
1329 ? `*${I18NextService.i18n.t("deleted")}*`
1333 handleReplyClick(i: CommentNode) {
1334 i.setState({ showReply: true });
1337 handleEditClick(i: CommentNode) {
1338 i.setState({ showEdit: true });
1341 handleReplyCancel() {
1342 this.setState({ showReply: false, showEdit: false });
1345 handleShowReportDialog(i: CommentNode) {
1346 i.setState({ showReportDialog: !i.state.showReportDialog });
1349 handleReportReasonChange(i: CommentNode, event: any) {
1350 i.setState({ reportReason: event.target.value });
1353 handleModRemoveShow(i: CommentNode) {
1355 showRemoveDialog: !i.state.showRemoveDialog,
1356 showBanDialog: false,
1360 handleModRemoveReasonChange(i: CommentNode, event: any) {
1361 i.setState({ removeReason: event.target.value });
1364 handleModRemoveDataChange(i: CommentNode, event: any) {
1365 i.setState({ removeData: event.target.checked });
1368 isPersonMentionType(
1369 item: CommentView | PersonMentionView | CommentReplyView
1370 ): item is PersonMentionView {
1371 return (item as PersonMentionView).person_mention?.id !== undefined;
1375 item: CommentView | PersonMentionView | CommentReplyView
1376 ): item is CommentReplyView {
1377 return (item as CommentReplyView).comment_reply?.id !== undefined;
1380 handleModBanFromCommunityShow(i: CommentNode) {
1382 showBanDialog: true,
1383 banType: BanType.Community,
1384 showRemoveDialog: false,
1388 handleModBanShow(i: CommentNode) {
1390 showBanDialog: true,
1391 banType: BanType.Site,
1392 showRemoveDialog: false,
1396 handleModBanReasonChange(i: CommentNode, event: any) {
1397 i.setState({ banReason: event.target.value });
1400 handleModBanExpireDaysChange(i: CommentNode, event: any) {
1401 i.setState({ banExpireDays: event.target.value });
1404 handlePurgePersonShow(i: CommentNode) {
1406 showPurgeDialog: true,
1407 purgeType: PurgeType.Person,
1408 showRemoveDialog: false,
1412 handlePurgeCommentShow(i: CommentNode) {
1414 showPurgeDialog: true,
1415 purgeType: PurgeType.Comment,
1416 showRemoveDialog: false,
1420 handlePurgeReasonChange(i: CommentNode, event: any) {
1421 i.setState({ purgeReason: event.target.value });
1424 handleShowConfirmAppointAsMod(i: CommentNode) {
1425 i.setState({ showConfirmAppointAsMod: true });
1428 handleCancelConfirmAppointAsMod(i: CommentNode) {
1429 i.setState({ showConfirmAppointAsMod: false });
1432 handleShowConfirmAppointAsAdmin(i: CommentNode) {
1433 i.setState({ showConfirmAppointAsAdmin: true });
1436 handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1437 i.setState({ showConfirmAppointAsAdmin: false });
1440 handleShowConfirmTransferCommunity(i: CommentNode) {
1441 i.setState({ showConfirmTransferCommunity: true });
1444 handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1445 i.setState({ showConfirmTransferCommunity: false });
1448 handleShowConfirmTransferSite(i: CommentNode) {
1449 i.setState({ showConfirmTransferSite: true });
1452 handleCancelShowConfirmTransferSite(i: CommentNode) {
1453 i.setState({ showConfirmTransferSite: false });
1456 get isCommentNew(): boolean {
1457 const now = subMinutes(new Date(), 10);
1458 const then = parseISO(this.commentView.comment.published);
1459 return isBefore(now, then);
1462 handleCommentCollapse(i: CommentNode) {
1463 i.setState({ collapsed: !i.state.collapsed });
1467 handleViewSource(i: CommentNode) {
1468 i.setState({ viewSource: !i.state.viewSource });
1471 handleShowAdvanced(i: CommentNode) {
1472 i.setState({ showAdvanced: !i.state.showAdvanced });
1476 handleSaveComment(i: CommentNode) {
1477 i.setState({ saveLoading: true });
1479 i.props.onSaveComment({
1480 comment_id: i.commentView.comment.id,
1481 save: !i.commentView.saved,
1482 auth: myAuthRequired(),
1486 handleUpvote(i: CommentNode) {
1487 i.setState({ upvoteLoading: true });
1488 i.props.onCommentVote({
1489 comment_id: i.commentId,
1490 score: newVote(VoteType.Upvote, i.commentView.my_vote),
1491 auth: myAuthRequired(),
1495 handleDownvote(i: CommentNode) {
1496 i.setState({ downvoteLoading: true });
1497 i.props.onCommentVote({
1498 comment_id: i.commentId,
1499 score: newVote(VoteType.Downvote, i.commentView.my_vote),
1500 auth: myAuthRequired(),
1504 handleBlockPerson(i: CommentNode) {
1505 i.setState({ blockPersonLoading: true });
1506 i.props.onBlockPerson({
1507 person_id: i.commentView.creator.id,
1509 auth: myAuthRequired(),
1513 handleMarkAsRead(i: CommentNode) {
1514 i.setState({ readLoading: true });
1515 const cv = i.commentView;
1516 if (i.isPersonMentionType(cv)) {
1517 i.props.onPersonMentionRead({
1518 person_mention_id: cv.person_mention.id,
1519 read: !cv.person_mention.read,
1520 auth: myAuthRequired(),
1522 } else if (i.isCommentReplyType(cv)) {
1523 i.props.onCommentReplyRead({
1524 comment_reply_id: cv.comment_reply.id,
1525 read: !cv.comment_reply.read,
1526 auth: myAuthRequired(),
1531 handleDeleteComment(i: CommentNode) {
1532 i.setState({ deleteLoading: true });
1533 i.props.onDeleteComment({
1534 comment_id: i.commentId,
1535 deleted: !i.commentView.comment.deleted,
1536 auth: myAuthRequired(),
1540 handleRemoveComment(i: CommentNode, event: any) {
1541 event.preventDefault();
1542 i.setState({ removeLoading: true });
1543 i.props.onRemoveComment({
1544 comment_id: i.commentId,
1545 removed: !i.commentView.comment.removed,
1546 auth: myAuthRequired(),
1550 handleDistinguishComment(i: CommentNode) {
1551 i.setState({ distinguishLoading: true });
1552 i.props.onDistinguishComment({
1553 comment_id: i.commentId,
1554 distinguished: !i.commentView.comment.distinguished,
1555 auth: myAuthRequired(),
1559 handleBanPersonFromCommunity(i: CommentNode) {
1560 i.setState({ banLoading: true });
1561 i.props.onBanPersonFromCommunity({
1562 community_id: i.commentView.community.id,
1563 person_id: i.commentView.creator.id,
1564 ban: !i.commentView.creator_banned_from_community,
1565 reason: i.state.banReason,
1566 remove_data: i.state.removeData,
1567 expires: futureDaysToUnixTime(i.state.banExpireDays),
1568 auth: myAuthRequired(),
1572 handleBanPerson(i: CommentNode) {
1573 i.setState({ banLoading: true });
1574 i.props.onBanPerson({
1575 person_id: i.commentView.creator.id,
1576 ban: !i.commentView.creator_banned_from_community,
1577 reason: i.state.banReason,
1578 remove_data: i.state.removeData,
1579 expires: futureDaysToUnixTime(i.state.banExpireDays),
1580 auth: myAuthRequired(),
1584 handleModBanBothSubmit(i: CommentNode, event: any) {
1585 event.preventDefault();
1586 if (i.state.banType == BanType.Community) {
1587 i.handleBanPersonFromCommunity(i);
1589 i.handleBanPerson(i);
1593 handleAddModToCommunity(i: CommentNode) {
1594 i.setState({ addModLoading: true });
1596 const added = !isMod(i.commentView.comment.creator_id, i.props.moderators);
1597 i.props.onAddModToCommunity({
1598 community_id: i.commentView.community.id,
1599 person_id: i.commentView.creator.id,
1601 auth: myAuthRequired(),
1605 handleAddAdmin(i: CommentNode) {
1606 i.setState({ addAdminLoading: true });
1608 const added = !isAdmin(i.commentView.comment.creator_id, i.props.admins);
1609 i.props.onAddAdmin({
1610 person_id: i.commentView.creator.id,
1612 auth: myAuthRequired(),
1616 handleTransferCommunity(i: CommentNode) {
1617 i.setState({ transferCommunityLoading: true });
1618 i.props.onTransferCommunity({
1619 community_id: i.commentView.community.id,
1620 person_id: i.commentView.creator.id,
1621 auth: myAuthRequired(),
1625 handleReportComment(i: CommentNode, event: any) {
1626 event.preventDefault();
1627 i.setState({ reportLoading: true });
1628 i.props.onCommentReport({
1629 comment_id: i.commentId,
1630 reason: i.state.reportReason ?? "",
1631 auth: myAuthRequired(),
1635 handlePurgeBothSubmit(i: CommentNode, event: any) {
1636 event.preventDefault();
1637 i.setState({ purgeLoading: true });
1639 if (i.state.purgeType == PurgeType.Person) {
1640 i.props.onPurgePerson({
1641 person_id: i.commentView.creator.id,
1642 reason: i.state.purgeReason,
1643 auth: myAuthRequired(),
1646 i.props.onPurgeComment({
1647 comment_id: i.commentId,
1648 reason: i.state.purgeReason,
1649 auth: myAuthRequired(),
1654 handleFetchChildren(i: CommentNode) {
1655 i.setState({ fetchChildrenLoading: true });
1656 i.props.onFetchChildren?.({
1657 parent_id: i.commentId,
1658 max_depth: commentTreeMaxDepth,