8 import { futureDaysToUnixTime, numToSI } from "@utils/helpers";
16 } from "@utils/roles";
17 import classNames from "classnames";
18 import isBefore from "date-fns/isBefore";
19 import parseISO from "date-fns/parseISO";
20 import subMinutes from "date-fns/subMinutes";
21 import { Component, InfernoNode, linkEvent } from "inferno";
22 import { Link } from "inferno-router";
32 CommunityModeratorView,
41 MarkCommentReplyAsRead,
42 MarkPersonMentionAsRead,
50 } from "lemmy-js-client";
51 import deepEqual from "lodash.isequal";
52 import { commentTreeMaxDepth } from "../../config";
59 } from "../../interfaces";
60 import { mdToHtml, mdToHtmlNoImages } from "../../markdown";
61 import { I18NextService, UserService } from "../../services";
62 import { setupTippy } from "../../tippy";
63 import { Icon, PurgeWarning, Spinner } from "../common/icon";
64 import { MomentTime } from "../common/moment-time";
65 import { VoteButtonsCompact } from "../common/vote-buttons";
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">
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"
312 <span className="me-2">
313 <PersonListing person={cv.creator} />
316 {cv.comment.distinguished && (
317 <Icon icon="shield" inline classes="text-danger me-2" />
320 {this.isPostCreator &&
321 this.getRoleLabelPill({
322 label: I18NextService.i18n.t("op").toUpperCase(),
323 tooltip: I18NextService.i18n.t("creator"),
324 parentClasses: "text-info",
325 shrinkToSingleLetter: false,
329 this.getRoleLabelPill({
330 label: I18NextService.i18n.t("mod"),
331 tooltip: I18NextService.i18n.t("mod"),
332 shrunkenLabelClasses: "text-info",
336 this.getRoleLabelPill({
337 label: I18NextService.i18n.t("admin"),
338 tooltip: I18NextService.i18n.t("admin"),
339 shrunkenLabelClasses: "text-danger",
343 this.getRoleLabelPill({
344 label: I18NextService.i18n.t("bot_account").toLowerCase(),
345 tooltip: I18NextService.i18n.t("bot_account"),
348 {this.props.showCommunity && (
350 <span className="mx-1">{I18NextService.i18n.t("to")}</span>
351 <CommunityLink community={cv.community} />
352 <span className="mx-2">•</span>
353 <Link className="me-2" to={`/post/${cv.post.id}`}>
359 {this.getLinkButton(true)}
361 {cv.comment.language_id !== 0 && (
362 <span className="badge text-bg-light d-none d-sm-inline me-2">
364 this.props.allLanguages.find(
365 lang => lang.id === cv.comment.language_id
370 {/* This is an expanding spacer for mobile */}
371 <div className="me-lg-5 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2" />
376 className="me-1 fw-bold"
377 aria-label={I18NextService.i18n.t("number_of_points", {
378 count: Number(this.commentView.counts.score),
379 formattedCount: numToSI(this.commentView.counts.score),
382 {numToSI(this.commentView.counts.score)}
384 <span className="me-1">•</span>
389 published={cv.comment.published}
390 updated={cv.comment.updated}
394 {/* end of user row */}
395 {this.state.showEdit && (
399 onReplyCancel={this.handleReplyCancel}
400 disabled={this.props.locked}
401 finished={this.props.finished.get(
402 this.props.node.comment_view.comment.id
405 allLanguages={this.props.allLanguages}
406 siteLanguages={this.props.siteLanguages}
407 containerClass="comment-comment-container"
408 onUpsertComment={this.props.onEditComment}
411 {!this.state.showEdit && !this.state.collapsed && (
413 {this.state.viewSource ? (
414 <pre>{this.commentUnlessRemoved}</pre>
418 dangerouslySetInnerHTML={
419 this.props.hideImages
420 ? mdToHtmlNoImages(this.commentUnlessRemoved)
421 : mdToHtml(this.commentUnlessRemoved)
425 <div className="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted fw-bold">
426 {this.props.showContext && this.getLinkButton()}
427 {this.props.markable && (
429 className="btn btn-link btn-animate text-muted"
430 onClick={linkEvent(this, this.handleMarkAsRead)}
432 this.commentReplyOrMentionRead
433 ? I18NextService.i18n.t("mark_as_unread")
434 : I18NextService.i18n.t("mark_as_read")
437 this.commentReplyOrMentionRead
438 ? I18NextService.i18n.t("mark_as_unread")
439 : I18NextService.i18n.t("mark_as_read")
442 {this.state.readLoading ? (
447 classes={`icon-inline ${
448 this.commentReplyOrMentionRead && "text-success"
454 {UserService.Instance.myUserInfo && !this.props.viewOnly && (
457 voteContentType={VoteContentType.Comment}
458 id={this.commentView.comment.id}
459 onVote={this.props.onCommentVote}
460 enableDownvotes={this.props.enableDownvotes}
461 counts={this.commentView.counts}
462 my_vote={this.commentView.my_vote}
465 className="btn btn-link btn-animate text-muted"
466 onClick={linkEvent(this, this.handleReplyClick)}
467 data-tippy-content={I18NextService.i18n.t("reply")}
468 aria-label={I18NextService.i18n.t("reply")}
470 <Icon icon="reply1" classes="icon-inline" />
472 {!this.state.showAdvanced ? (
474 className="btn btn-link btn-animate text-muted btn-more"
475 onClick={linkEvent(this, this.handleShowAdvanced)}
476 data-tippy-content={I18NextService.i18n.t("more")}
477 aria-label={I18NextService.i18n.t("more")}
479 <Icon icon="more-vertical" classes="icon-inline" />
483 {!this.myComment && (
486 className="btn btn-link btn-animate text-muted"
487 to={`/create_private_message/${cv.creator.id}`}
488 title={I18NextService.i18n
495 className="btn btn-link btn-animate text-muted"
498 this.handleShowReportDialog
500 data-tippy-content={I18NextService.i18n.t(
503 aria-label={I18NextService.i18n.t(
510 className="btn btn-link btn-animate text-muted"
513 this.handleBlockPerson
515 data-tippy-content={I18NextService.i18n.t(
518 aria-label={I18NextService.i18n.t("block_user")}
520 {this.state.blockPersonLoading ? (
523 <Icon icon="slash" />
529 className="btn btn-link btn-animate text-muted"
530 onClick={linkEvent(this, this.handleSaveComment)}
533 ? I18NextService.i18n.t("unsave")
534 : I18NextService.i18n.t("save")
538 ? I18NextService.i18n.t("unsave")
539 : I18NextService.i18n.t("save")
542 {this.state.saveLoading ? (
547 classes={`icon-inline ${
548 cv.saved && "text-warning"
554 className="btn btn-link btn-animate text-muted"
555 onClick={linkEvent(this, this.handleViewSource)}
556 data-tippy-content={I18NextService.i18n.t(
559 aria-label={I18NextService.i18n.t("view_source")}
563 classes={`icon-inline ${
564 this.state.viewSource && "text-success"
571 className="btn btn-link btn-animate text-muted"
572 onClick={linkEvent(this, this.handleEditClick)}
573 data-tippy-content={I18NextService.i18n.t(
576 aria-label={I18NextService.i18n.t("edit")}
578 <Icon icon="edit" classes="icon-inline" />
581 className="btn btn-link btn-animate text-muted"
584 this.handleDeleteComment
588 ? I18NextService.i18n.t("delete")
589 : I18NextService.i18n.t("restore")
593 ? I18NextService.i18n.t("delete")
594 : I18NextService.i18n.t("restore")
597 {this.state.deleteLoading ? (
602 classes={`icon-inline ${
603 cv.comment.deleted && "text-danger"
609 {(canModOnSelf || canAdminOnSelf) && (
611 className="btn btn-link btn-animate text-muted"
614 this.handleDistinguishComment
617 !cv.comment.distinguished
618 ? I18NextService.i18n.t("distinguish")
619 : I18NextService.i18n.t("undistinguish")
622 !cv.comment.distinguished
623 ? I18NextService.i18n.t("distinguish")
624 : I18NextService.i18n.t("undistinguish")
629 classes={`icon-inline ${
630 cv.comment.distinguished && "text-danger"
637 {/* Admins and mods can remove comments */}
638 {(canMod_ || canAdmin_) && (
640 {!cv.comment.removed ? (
642 className="btn btn-link btn-animate text-muted"
645 this.handleModRemoveShow
647 aria-label={I18NextService.i18n.t("remove")}
649 {I18NextService.i18n.t("remove")}
653 className="btn btn-link btn-animate text-muted"
656 this.handleRemoveComment
658 aria-label={I18NextService.i18n.t("restore")}
660 {this.state.removeLoading ? (
663 I18NextService.i18n.t("restore")
669 {/* Mods can ban from community, and appoint as mods to community */}
673 (!cv.creator_banned_from_community ? (
675 className="btn btn-link btn-animate text-muted"
678 this.handleModBanFromCommunityShow
680 aria-label={I18NextService.i18n.t(
684 {I18NextService.i18n.t(
690 className="btn btn-link btn-animate text-muted"
693 this.handleBanPersonFromCommunity
695 aria-label={I18NextService.i18n.t("unban")}
697 {this.state.banLoading ? (
700 I18NextService.i18n.t("unban")
704 {!cv.creator_banned_from_community &&
705 (!this.state.showConfirmAppointAsMod ? (
707 className="btn btn-link btn-animate text-muted"
710 this.handleShowConfirmAppointAsMod
714 ? I18NextService.i18n.t("remove_as_mod")
715 : I18NextService.i18n.t(
721 ? I18NextService.i18n.t("remove_as_mod")
722 : I18NextService.i18n.t("appoint_as_mod")}
727 className="btn btn-link btn-animate text-muted"
728 aria-label={I18NextService.i18n.t(
732 {I18NextService.i18n.t("are_you_sure")}
735 className="btn btn-link btn-animate text-muted"
738 this.handleAddModToCommunity
740 aria-label={I18NextService.i18n.t("yes")}
742 {this.state.addModLoading ? (
745 I18NextService.i18n.t("yes")
749 className="btn btn-link btn-animate text-muted"
752 this.handleCancelConfirmAppointAsMod
754 aria-label={I18NextService.i18n.t("no")}
756 {I18NextService.i18n.t("no")}
762 {/* Community creators and admins can transfer community to another mod */}
763 {(amCommunityCreator_ || canAdmin_) &&
766 (!this.state.showConfirmTransferCommunity ? (
768 className="btn btn-link btn-animate text-muted"
771 this.handleShowConfirmTransferCommunity
773 aria-label={I18NextService.i18n.t(
777 {I18NextService.i18n.t("transfer_community")}
782 className="btn btn-link btn-animate text-muted"
783 aria-label={I18NextService.i18n.t(
787 {I18NextService.i18n.t("are_you_sure")}
790 className="btn btn-link btn-animate text-muted"
793 this.handleTransferCommunity
795 aria-label={I18NextService.i18n.t("yes")}
797 {this.state.transferCommunityLoading ? (
800 I18NextService.i18n.t("yes")
804 className="btn btn-link btn-animate text-muted"
808 .handleCancelShowConfirmTransferCommunity
810 aria-label={I18NextService.i18n.t("no")}
812 {I18NextService.i18n.t("no")}
816 {/* Admins can ban from all, and appoint other admins */}
822 className="btn btn-link btn-animate text-muted"
825 this.handlePurgePersonShow
827 aria-label={I18NextService.i18n.t(
831 {I18NextService.i18n.t("purge_user")}
834 className="btn btn-link btn-animate text-muted"
837 this.handlePurgeCommentShow
839 aria-label={I18NextService.i18n.t(
843 {I18NextService.i18n.t("purge_comment")}
846 {!isBanned(cv.creator) ? (
848 className="btn btn-link btn-animate text-muted"
851 this.handleModBanShow
853 aria-label={I18NextService.i18n.t(
857 {I18NextService.i18n.t("ban_from_site")}
861 className="btn btn-link btn-animate text-muted"
866 aria-label={I18NextService.i18n.t(
870 {this.state.banLoading ? (
873 I18NextService.i18n.t("unban_from_site")
879 {!isBanned(cv.creator) &&
881 (!this.state.showConfirmAppointAsAdmin ? (
883 className="btn btn-link btn-animate text-muted"
886 this.handleShowConfirmAppointAsAdmin
890 ? I18NextService.i18n.t(
893 : I18NextService.i18n.t(
899 ? I18NextService.i18n.t("remove_as_admin")
900 : I18NextService.i18n.t(
906 <button className="btn btn-link btn-animate text-muted">
907 {I18NextService.i18n.t("are_you_sure")}
910 className="btn btn-link btn-animate text-muted"
915 aria-label={I18NextService.i18n.t("yes")}
917 {this.state.addAdminLoading ? (
920 I18NextService.i18n.t("yes")
924 className="btn btn-link btn-animate text-muted"
927 this.handleCancelConfirmAppointAsAdmin
929 aria-label={I18NextService.i18n.t("no")}
931 {I18NextService.i18n.t("no")}
942 {/* end of button group */}
947 {showMoreChildren && (
949 className={classNames("details ms-1 comment-node py-2", {
950 "border-top border-light": !this.props.noBorder,
952 style={`border-left: 2px ${moreRepliesBorderColor} solid !important`}
955 className="btn btn-link text-muted"
956 onClick={linkEvent(this, this.handleFetchChildren)}
958 {this.state.fetchChildrenLoading ? (
962 {I18NextService.i18n.t("x_more_replies", {
963 count: node.comment_view.counts.child_count,
964 formattedCount: numToSI(
965 node.comment_view.counts.child_count
974 {/* end of details */}
975 {this.state.showRemoveDialog && (
977 className="form-inline"
978 onSubmit={linkEvent(this, this.handleRemoveComment)}
981 className="visually-hidden"
982 htmlFor={`mod-remove-reason-${cv.comment.id}`}
984 {I18NextService.i18n.t("reason")}
988 id={`mod-remove-reason-${cv.comment.id}`}
989 className="form-control me-2"
990 placeholder={I18NextService.i18n.t("reason")}
991 value={this.state.removeReason}
992 onInput={linkEvent(this, this.handleModRemoveReasonChange)}
996 className="btn btn-secondary"
997 aria-label={I18NextService.i18n.t("remove_comment")}
999 {I18NextService.i18n.t("remove_comment")}
1003 {this.state.showReportDialog && (
1005 className="form-inline"
1006 onSubmit={linkEvent(this, this.handleReportComment)}
1009 className="visually-hidden"
1010 htmlFor={`report-reason-${cv.comment.id}`}
1012 {I18NextService.i18n.t("reason")}
1017 id={`report-reason-${cv.comment.id}`}
1018 className="form-control me-2"
1019 placeholder={I18NextService.i18n.t("reason")}
1020 value={this.state.reportReason}
1021 onInput={linkEvent(this, this.handleReportReasonChange)}
1025 className="btn btn-secondary"
1026 aria-label={I18NextService.i18n.t("create_report")}
1028 {I18NextService.i18n.t("create_report")}
1032 {this.state.showBanDialog && (
1033 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
1034 <div className="mb-3 row col-12">
1036 className="col-form-label"
1037 htmlFor={`mod-ban-reason-${cv.comment.id}`}
1039 {I18NextService.i18n.t("reason")}
1043 id={`mod-ban-reason-${cv.comment.id}`}
1044 className="form-control me-2"
1045 placeholder={I18NextService.i18n.t("reason")}
1046 value={this.state.banReason}
1047 onInput={linkEvent(this, this.handleModBanReasonChange)}
1050 className="col-form-label"
1051 htmlFor={`mod-ban-expires-${cv.comment.id}`}
1053 {I18NextService.i18n.t("expires")}
1057 id={`mod-ban-expires-${cv.comment.id}`}
1058 className="form-control me-2"
1059 placeholder={I18NextService.i18n.t("number_of_days")}
1060 value={this.state.banExpireDays}
1061 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
1063 <div className="input-group mb-3">
1064 <div className="form-check">
1066 className="form-check-input"
1067 id="mod-ban-remove-data"
1069 checked={this.state.removeData}
1070 onChange={linkEvent(this, this.handleModRemoveDataChange)}
1073 className="form-check-label"
1074 htmlFor="mod-ban-remove-data"
1075 title={I18NextService.i18n.t("remove_content_more")}
1077 {I18NextService.i18n.t("remove_content")}
1082 {/* TODO hold off on expires until later */}
1083 {/* <div class="mb-3 row"> */}
1084 {/* <label class="col-form-label">Expires</label> */}
1085 {/* <input type="date" class="form-control me-2" placeholder={I18NextService.i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1087 <div className="mb-3 row">
1090 className="btn btn-secondary"
1091 aria-label={I18NextService.i18n.t("ban")}
1093 {this.state.banLoading ? (
1097 {I18NextService.i18n.t("ban")} {cv.creator.name}
1105 {this.state.showPurgeDialog && (
1106 <form onSubmit={linkEvent(this, this.handlePurgeBothSubmit)}>
1108 <label className="visually-hidden" htmlFor="purge-reason">
1109 {I18NextService.i18n.t("reason")}
1114 className="form-control my-3"
1115 placeholder={I18NextService.i18n.t("reason")}
1116 value={this.state.purgeReason}
1117 onInput={linkEvent(this, this.handlePurgeReasonChange)}
1119 <div className="mb-3 row col-12">
1120 {this.state.purgeLoading ? (
1125 className="btn btn-secondary"
1126 aria-label={purgeTypeText}
1134 {this.state.showReply && (
1137 onReplyCancel={this.handleReplyCancel}
1138 disabled={this.props.locked}
1139 finished={this.props.finished.get(
1140 this.props.node.comment_view.comment.id
1143 allLanguages={this.props.allLanguages}
1144 siteLanguages={this.props.siteLanguages}
1145 containerClass="comment-comment-container"
1146 onUpsertComment={this.props.onCreateComment}
1149 {!this.state.collapsed && node.children.length > 0 && (
1151 nodes={node.children}
1152 locked={this.props.locked}
1153 moderators={this.props.moderators}
1154 admins={this.props.admins}
1155 enableDownvotes={this.props.enableDownvotes}
1156 viewType={this.props.viewType}
1157 allLanguages={this.props.allLanguages}
1158 siteLanguages={this.props.siteLanguages}
1159 hideImages={this.props.hideImages}
1160 isChild={!this.props.noIndent}
1161 depth={this.props.node.depth + 1}
1162 finished={this.props.finished}
1163 onCommentReplyRead={this.props.onCommentReplyRead}
1164 onPersonMentionRead={this.props.onPersonMentionRead}
1165 onCreateComment={this.props.onCreateComment}
1166 onEditComment={this.props.onEditComment}
1167 onCommentVote={this.props.onCommentVote}
1168 onBlockPerson={this.props.onBlockPerson}
1169 onSaveComment={this.props.onSaveComment}
1170 onDeleteComment={this.props.onDeleteComment}
1171 onRemoveComment={this.props.onRemoveComment}
1172 onDistinguishComment={this.props.onDistinguishComment}
1173 onAddModToCommunity={this.props.onAddModToCommunity}
1174 onAddAdmin={this.props.onAddAdmin}
1175 onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
1176 onBanPerson={this.props.onBanPerson}
1177 onTransferCommunity={this.props.onTransferCommunity}
1178 onFetchChildren={this.props.onFetchChildren}
1179 onCommentReport={this.props.onCommentReport}
1180 onPurgePerson={this.props.onPurgePerson}
1181 onPurgeComment={this.props.onPurgeComment}
1184 {/* A collapsed clearfix */}
1185 {this.state.collapsed && <div className="row col-12" />}
1190 get commentReplyOrMentionRead(): boolean {
1191 const cv = this.commentView;
1193 if (this.isPersonMentionType(cv)) {
1194 return cv.person_mention.read;
1195 } else if (this.isCommentReplyType(cv)) {
1196 return cv.comment_reply.read;
1206 shrunkenLabelClasses,
1207 hideOnMobile = false,
1208 shrinkToSingleLetter = true,
1212 parentClasses?: string;
1213 shrunkenLabelClasses?: string;
1214 hideOnMobile?: boolean;
1215 shrinkToSingleLetter?: boolean;
1217 const parentClassNames = classNames(
1218 `badge me-2 text-bg-light ${parentClasses}`,
1220 "d-none d-md-inline": hideOnMobile,
1224 let fullLabelClassNames = "d-none d-md-inline";
1225 let shrunkenLabelClassNames = `d-inline d-md-none ${shrunkenLabelClasses}`;
1227 if (!shrinkToSingleLetter) {
1228 fullLabelClassNames = "";
1229 shrunkenLabelClassNames = "d-none";
1234 className={parentClassNames}
1235 aria-label={tooltip}
1236 data-tippy-content={tooltip}
1238 <span className={fullLabelClassNames}>{label}</span>
1239 <span className={shrunkenLabelClassNames}>
1240 {label[0].toUpperCase()}
1246 getLinkButton(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 handleBlockPerson(i: CommentNode) {
1487 i.setState({ blockPersonLoading: true });
1488 i.props.onBlockPerson({
1489 person_id: i.commentView.creator.id,
1491 auth: myAuthRequired(),
1495 handleMarkAsRead(i: CommentNode) {
1496 i.setState({ readLoading: true });
1497 const cv = i.commentView;
1498 if (i.isPersonMentionType(cv)) {
1499 i.props.onPersonMentionRead({
1500 person_mention_id: cv.person_mention.id,
1501 read: !cv.person_mention.read,
1502 auth: myAuthRequired(),
1504 } else if (i.isCommentReplyType(cv)) {
1505 i.props.onCommentReplyRead({
1506 comment_reply_id: cv.comment_reply.id,
1507 read: !cv.comment_reply.read,
1508 auth: myAuthRequired(),
1513 handleDeleteComment(i: CommentNode) {
1514 i.setState({ deleteLoading: true });
1515 i.props.onDeleteComment({
1516 comment_id: i.commentId,
1517 deleted: !i.commentView.comment.deleted,
1518 auth: myAuthRequired(),
1522 handleRemoveComment(i: CommentNode, event: any) {
1523 event.preventDefault();
1524 i.setState({ removeLoading: true });
1525 i.props.onRemoveComment({
1526 comment_id: i.commentId,
1527 removed: !i.commentView.comment.removed,
1528 auth: myAuthRequired(),
1532 handleDistinguishComment(i: CommentNode) {
1533 i.setState({ distinguishLoading: true });
1534 i.props.onDistinguishComment({
1535 comment_id: i.commentId,
1536 distinguished: !i.commentView.comment.distinguished,
1537 auth: myAuthRequired(),
1541 handleBanPersonFromCommunity(i: CommentNode) {
1542 i.setState({ banLoading: true });
1543 i.props.onBanPersonFromCommunity({
1544 community_id: i.commentView.community.id,
1545 person_id: i.commentView.creator.id,
1546 ban: !i.commentView.creator_banned_from_community,
1547 reason: i.state.banReason,
1548 remove_data: i.state.removeData,
1549 expires: futureDaysToUnixTime(i.state.banExpireDays),
1550 auth: myAuthRequired(),
1554 handleBanPerson(i: CommentNode) {
1555 i.setState({ banLoading: true });
1556 i.props.onBanPerson({
1557 person_id: i.commentView.creator.id,
1558 ban: !i.commentView.creator_banned_from_community,
1559 reason: i.state.banReason,
1560 remove_data: i.state.removeData,
1561 expires: futureDaysToUnixTime(i.state.banExpireDays),
1562 auth: myAuthRequired(),
1566 handleModBanBothSubmit(i: CommentNode, event: any) {
1567 event.preventDefault();
1568 if (i.state.banType == BanType.Community) {
1569 i.handleBanPersonFromCommunity(i);
1571 i.handleBanPerson(i);
1575 handleAddModToCommunity(i: CommentNode) {
1576 i.setState({ addModLoading: true });
1578 const added = !isMod(i.commentView.comment.creator_id, i.props.moderators);
1579 i.props.onAddModToCommunity({
1580 community_id: i.commentView.community.id,
1581 person_id: i.commentView.creator.id,
1583 auth: myAuthRequired(),
1587 handleAddAdmin(i: CommentNode) {
1588 i.setState({ addAdminLoading: true });
1590 const added = !isAdmin(i.commentView.comment.creator_id, i.props.admins);
1591 i.props.onAddAdmin({
1592 person_id: i.commentView.creator.id,
1594 auth: myAuthRequired(),
1598 handleTransferCommunity(i: CommentNode) {
1599 i.setState({ transferCommunityLoading: true });
1600 i.props.onTransferCommunity({
1601 community_id: i.commentView.community.id,
1602 person_id: i.commentView.creator.id,
1603 auth: myAuthRequired(),
1607 handleReportComment(i: CommentNode, event: any) {
1608 event.preventDefault();
1609 i.setState({ reportLoading: true });
1610 i.props.onCommentReport({
1611 comment_id: i.commentId,
1612 reason: i.state.reportReason ?? "",
1613 auth: myAuthRequired(),
1617 handlePurgeBothSubmit(i: CommentNode, event: any) {
1618 event.preventDefault();
1619 i.setState({ purgeLoading: true });
1621 if (i.state.purgeType == PurgeType.Person) {
1622 i.props.onPurgePerson({
1623 person_id: i.commentView.creator.id,
1624 reason: i.state.purgeReason,
1625 auth: myAuthRequired(),
1628 i.props.onPurgeComment({
1629 comment_id: i.commentId,
1630 reason: i.state.purgeReason,
1631 auth: myAuthRequired(),
1636 handleFetchChildren(i: CommentNode) {
1637 i.setState({ fetchChildrenLoading: true });
1638 i.props.onFetchChildren?.({
1639 parent_id: i.commentId,
1640 max_depth: commentTreeMaxDepth,