]> Untitled Git - lemmy-ui.git/blob - src/shared/components/comment/comment-node.tsx
Comment Tree paging (#726)
[lemmy-ui.git] / src / shared / components / comment / comment-node.tsx
1 import { Left, None, Option, Some } from "@sniptt/monads";
2 import classNames from "classnames";
3 import { Component, linkEvent } from "inferno";
4 import { Link } from "inferno-router";
5 import {
6   AddAdmin,
7   AddModToCommunity,
8   BanFromCommunity,
9   BanPerson,
10   BlockPerson,
11   CommentNode as CommentNodeI,
12   CommentReplyView,
13   CommentView,
14   CommunityModeratorView,
15   CreateCommentLike,
16   CreateCommentReport,
17   DeleteComment,
18   GetComments,
19   ListingType,
20   MarkCommentReplyAsRead,
21   MarkPersonMentionAsRead,
22   PersonMentionView,
23   PersonViewSafe,
24   PurgeComment,
25   PurgePerson,
26   RemoveComment,
27   SaveComment,
28   toUndefined,
29   TransferCommunity,
30 } from "lemmy-js-client";
31 import moment from "moment";
32 import { i18n } from "../../i18next";
33 import { BanType, CommentViewType, PurgeType } from "../../interfaces";
34 import { UserService, WebSocketService } from "../../services";
35 import {
36   amCommunityCreator,
37   auth,
38   canAdmin,
39   canMod,
40   colorList,
41   commentTreeMaxDepth,
42   futureDaysToUnixTime,
43   isAdmin,
44   isBanned,
45   isMod,
46   mdToHtml,
47   numToSI,
48   setupTippy,
49   showScores,
50   wsClient,
51 } from "../../utils";
52 import { Icon, PurgeWarning, Spinner } from "../common/icon";
53 import { MomentTime } from "../common/moment-time";
54 import { CommunityLink } from "../community/community-link";
55 import { PersonListing } from "../person/person-listing";
56 import { CommentForm } from "./comment-form";
57 import { CommentNodes } from "./comment-nodes";
58
59 interface CommentNodeState {
60   showReply: boolean;
61   showEdit: boolean;
62   showRemoveDialog: boolean;
63   removeReason: Option<string>;
64   showBanDialog: boolean;
65   removeData: boolean;
66   banReason: Option<string>;
67   banExpireDays: Option<number>;
68   banType: BanType;
69   showPurgeDialog: boolean;
70   purgeReason: Option<string>;
71   purgeType: PurgeType;
72   purgeLoading: boolean;
73   showConfirmTransferSite: boolean;
74   showConfirmTransferCommunity: boolean;
75   showConfirmAppointAsMod: boolean;
76   showConfirmAppointAsAdmin: boolean;
77   collapsed: boolean;
78   viewSource: boolean;
79   showAdvanced: boolean;
80   showReportDialog: boolean;
81   reportReason: string;
82   my_vote: Option<number>;
83   score: number;
84   upvotes: number;
85   downvotes: number;
86   readLoading: boolean;
87   saveLoading: boolean;
88 }
89
90 interface CommentNodeProps {
91   node: CommentNodeI;
92   moderators: Option<CommunityModeratorView[]>;
93   admins: Option<PersonViewSafe[]>;
94   noBorder?: boolean;
95   noIndent?: boolean;
96   viewOnly?: boolean;
97   locked?: boolean;
98   markable?: boolean;
99   showContext?: boolean;
100   showCommunity?: boolean;
101   enableDownvotes: boolean;
102   viewType: CommentViewType;
103 }
104
105 export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
106   private emptyState: CommentNodeState = {
107     showReply: false,
108     showEdit: false,
109     showRemoveDialog: false,
110     removeReason: None,
111     showBanDialog: false,
112     removeData: false,
113     banReason: None,
114     banExpireDays: None,
115     banType: BanType.Community,
116     showPurgeDialog: false,
117     purgeLoading: false,
118     purgeReason: None,
119     purgeType: PurgeType.Person,
120     collapsed: false,
121     viewSource: false,
122     showAdvanced: false,
123     showConfirmTransferSite: false,
124     showConfirmTransferCommunity: false,
125     showConfirmAppointAsMod: false,
126     showConfirmAppointAsAdmin: false,
127     showReportDialog: false,
128     reportReason: null,
129     my_vote: this.props.node.comment_view.my_vote,
130     score: this.props.node.comment_view.counts.score,
131     upvotes: this.props.node.comment_view.counts.upvotes,
132     downvotes: this.props.node.comment_view.counts.downvotes,
133     readLoading: false,
134     saveLoading: false,
135   };
136
137   constructor(props: any, context: any) {
138     super(props, context);
139
140     this.state = this.emptyState;
141     this.handleReplyCancel = this.handleReplyCancel.bind(this);
142     this.handleCommentUpvote = this.handleCommentUpvote.bind(this);
143     this.handleCommentDownvote = this.handleCommentDownvote.bind(this);
144   }
145
146   // TODO see if there's a better way to do this, and all willReceiveProps
147   componentWillReceiveProps(nextProps: CommentNodeProps) {
148     let cv = nextProps.node.comment_view;
149     this.state.my_vote = cv.my_vote;
150     this.state.upvotes = cv.counts.upvotes;
151     this.state.downvotes = cv.counts.downvotes;
152     this.state.score = cv.counts.score;
153     this.state.readLoading = false;
154     this.state.saveLoading = false;
155     this.setState(this.state);
156   }
157
158   render() {
159     let node = this.props.node;
160     let cv = this.props.node.comment_view;
161
162     let purgeTypeText: string;
163     if (this.state.purgeType == PurgeType.Comment) {
164       purgeTypeText = i18n.t("purge_comment");
165     } else if (this.state.purgeType == PurgeType.Person) {
166       purgeTypeText = `${i18n.t("purge")} ${cv.creator.name}`;
167     }
168
169     let canMod_ = canMod(
170       this.props.moderators,
171       this.props.admins,
172       cv.creator.id
173     );
174     let canAdmin_ = canAdmin(this.props.admins, cv.creator.id);
175     let isMod_ = isMod(this.props.moderators, cv.creator.id);
176     let isAdmin_ = isAdmin(this.props.admins, cv.creator.id);
177     let amCommunityCreator_ = amCommunityCreator(
178       this.props.moderators,
179       cv.creator.id
180     );
181
182     let borderColor = this.props.node.depth
183       ? colorList[(this.props.node.depth - 1) % colorList.length]
184       : colorList[0];
185     let moreRepliesBorderColor = this.props.node.depth
186       ? colorList[this.props.node.depth % colorList.length]
187       : colorList[0];
188
189     let showMoreChildren =
190       this.props.viewType == CommentViewType.Tree &&
191       !this.state.collapsed &&
192       node.children.length == 0 &&
193       node.comment_view.counts.child_count > 0;
194
195     return (
196       <div
197         className={`comment ${
198           this.props.node.depth && !this.props.noIndent ? "ml-1" : ""
199         }`}
200       >
201         <div
202           id={`comment-${cv.comment.id}`}
203           className={`details comment-node py-2 ${
204             !this.props.noBorder ? "border-top border-light" : ""
205           } ${this.isCommentNew ? "mark" : ""}`}
206           style={
207             !this.props.noIndent &&
208             this.props.node.depth &&
209             `border-left: 2px ${borderColor} solid !important`
210           }
211         >
212           <div
213             class={`${!this.props.noIndent && this.props.node.depth && "ml-2"}`}
214           >
215             <div class="d-flex flex-wrap align-items-center text-muted small">
216               <span class="mr-2">
217                 <PersonListing person={cv.creator} />
218               </span>
219
220               {isMod_ && (
221                 <div className="badge badge-light d-none d-sm-inline mr-2">
222                   {i18n.t("mod")}
223                 </div>
224               )}
225               {isAdmin_ && (
226                 <div className="badge badge-light d-none d-sm-inline mr-2">
227                   {i18n.t("admin")}
228                 </div>
229               )}
230               {this.isPostCreator && (
231                 <div className="badge badge-light d-none d-sm-inline mr-2">
232                   {i18n.t("creator")}
233                 </div>
234               )}
235               {cv.creator.bot_account && (
236                 <div className="badge badge-light d-none d-sm-inline mr-2">
237                   {i18n.t("bot_account").toLowerCase()}
238                 </div>
239               )}
240               {(cv.creator_banned_from_community || isBanned(cv.creator)) && (
241                 <div className="badge badge-danger mr-2">
242                   {i18n.t("banned")}
243                 </div>
244               )}
245               {this.props.showCommunity && (
246                 <>
247                   <span class="mx-1">{i18n.t("to")}</span>
248                   <CommunityLink community={cv.community} />
249                   <span class="mx-2">•</span>
250                   <Link className="mr-2" to={`/post/${cv.post.id}`}>
251                     {cv.post.name}
252                   </Link>
253                 </>
254               )}
255               <button
256                 class="btn btn-sm text-muted"
257                 onClick={linkEvent(this, this.handleCommentCollapse)}
258                 aria-label={this.expandText}
259                 data-tippy-content={this.expandText}
260               >
261                 {this.state.collapsed ? (
262                   <Icon icon="plus-square" classes="icon-inline" />
263                 ) : (
264                   <Icon icon="minus-square" classes="icon-inline" />
265                 )}
266               </button>
267               {this.linkBtn(true)}
268               {/* This is an expanding spacer for mobile */}
269               <div className="mr-lg-5 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2"></div>
270               {showScores() && (
271                 <>
272                   <a
273                     className={`unselectable pointer ${this.scoreColor}`}
274                     onClick={this.handleCommentUpvote}
275                     data-tippy-content={this.pointsTippy}
276                   >
277                     <span
278                       class="mr-1 font-weight-bold"
279                       aria-label={i18n.t("number_of_points", {
280                         count: this.state.score,
281                         formattedCount: this.state.score,
282                       })}
283                     >
284                       {numToSI(this.state.score)}
285                     </span>
286                   </a>
287                   <span className="mr-1">•</span>
288                 </>
289               )}
290               <span>
291                 <MomentTime
292                   published={cv.comment.published}
293                   updated={cv.comment.updated}
294                 />
295               </span>
296             </div>
297             {/* end of user row */}
298             {this.state.showEdit && (
299               <CommentForm
300                 node={Left(node)}
301                 edit
302                 onReplyCancel={this.handleReplyCancel}
303                 disabled={this.props.locked}
304                 focus
305               />
306             )}
307             {!this.state.showEdit && !this.state.collapsed && (
308               <div>
309                 {this.state.viewSource ? (
310                   <pre>{this.commentUnlessRemoved}</pre>
311                 ) : (
312                   <div
313                     className="md-div"
314                     dangerouslySetInnerHTML={mdToHtml(
315                       this.commentUnlessRemoved
316                     )}
317                   />
318                 )}
319                 <div class="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted font-weight-bold">
320                   {this.props.showContext && this.linkBtn()}
321                   {this.props.markable && (
322                     <button
323                       class="btn btn-link btn-animate text-muted"
324                       onClick={linkEvent(this, this.handleMarkRead)}
325                       data-tippy-content={
326                         this.commentReplyOrMentionRead
327                           ? i18n.t("mark_as_unread")
328                           : i18n.t("mark_as_read")
329                       }
330                       aria-label={
331                         this.commentReplyOrMentionRead
332                           ? i18n.t("mark_as_unread")
333                           : i18n.t("mark_as_read")
334                       }
335                     >
336                       {this.state.readLoading ? (
337                         this.loadingIcon
338                       ) : (
339                         <Icon
340                           icon="check"
341                           classes={`icon-inline ${
342                             this.commentReplyOrMentionRead && "text-success"
343                           }`}
344                         />
345                       )}
346                     </button>
347                   )}
348                   {UserService.Instance.myUserInfo.isSome() &&
349                     !this.props.viewOnly && (
350                       <>
351                         <button
352                           className={`btn btn-link btn-animate ${
353                             this.state.my_vote.unwrapOr(0) == 1
354                               ? "text-info"
355                               : "text-muted"
356                           }`}
357                           onClick={this.handleCommentUpvote}
358                           data-tippy-content={i18n.t("upvote")}
359                           aria-label={i18n.t("upvote")}
360                         >
361                           <Icon icon="arrow-up1" classes="icon-inline" />
362                           {showScores() &&
363                             this.state.upvotes !== this.state.score && (
364                               <span class="ml-1">
365                                 {numToSI(this.state.upvotes)}
366                               </span>
367                             )}
368                         </button>
369                         {this.props.enableDownvotes && (
370                           <button
371                             className={`btn btn-link btn-animate ${
372                               this.state.my_vote.unwrapOr(0) == -1
373                                 ? "text-danger"
374                                 : "text-muted"
375                             }`}
376                             onClick={this.handleCommentDownvote}
377                             data-tippy-content={i18n.t("downvote")}
378                             aria-label={i18n.t("downvote")}
379                           >
380                             <Icon icon="arrow-down1" classes="icon-inline" />
381                             {showScores() &&
382                               this.state.upvotes !== this.state.score && (
383                                 <span class="ml-1">
384                                   {numToSI(this.state.downvotes)}
385                                 </span>
386                               )}
387                           </button>
388                         )}
389                         <button
390                           class="btn btn-link btn-animate text-muted"
391                           onClick={linkEvent(this, this.handleReplyClick)}
392                           data-tippy-content={i18n.t("reply")}
393                           aria-label={i18n.t("reply")}
394                         >
395                           <Icon icon="reply1" classes="icon-inline" />
396                         </button>
397                         {!this.state.showAdvanced ? (
398                           <button
399                             className="btn btn-link btn-animate text-muted"
400                             onClick={linkEvent(this, this.handleShowAdvanced)}
401                             data-tippy-content={i18n.t("more")}
402                             aria-label={i18n.t("more")}
403                           >
404                             <Icon icon="more-vertical" classes="icon-inline" />
405                           </button>
406                         ) : (
407                           <>
408                             {!this.myComment && (
409                               <>
410                                 <button class="btn btn-link btn-animate">
411                                   <Link
412                                     className="text-muted"
413                                     to={`/create_private_message/recipient/${cv.creator.id}`}
414                                     title={i18n.t("message").toLowerCase()}
415                                   >
416                                     <Icon icon="mail" />
417                                   </Link>
418                                 </button>
419                                 <button
420                                   class="btn btn-link btn-animate text-muted"
421                                   onClick={linkEvent(
422                                     this,
423                                     this.handleShowReportDialog
424                                   )}
425                                   data-tippy-content={i18n.t(
426                                     "show_report_dialog"
427                                   )}
428                                   aria-label={i18n.t("show_report_dialog")}
429                                 >
430                                   <Icon icon="flag" />
431                                 </button>
432                                 <button
433                                   class="btn btn-link btn-animate text-muted"
434                                   onClick={linkEvent(
435                                     this,
436                                     this.handleBlockUserClick
437                                   )}
438                                   data-tippy-content={i18n.t("block_user")}
439                                   aria-label={i18n.t("block_user")}
440                                 >
441                                   <Icon icon="slash" />
442                                 </button>
443                               </>
444                             )}
445                             <button
446                               class="btn btn-link btn-animate text-muted"
447                               onClick={linkEvent(
448                                 this,
449                                 this.handleSaveCommentClick
450                               )}
451                               data-tippy-content={
452                                 cv.saved ? i18n.t("unsave") : i18n.t("save")
453                               }
454                               aria-label={
455                                 cv.saved ? i18n.t("unsave") : i18n.t("save")
456                               }
457                             >
458                               {this.state.saveLoading ? (
459                                 this.loadingIcon
460                               ) : (
461                                 <Icon
462                                   icon="star"
463                                   classes={`icon-inline ${
464                                     cv.saved && "text-warning"
465                                   }`}
466                                 />
467                               )}
468                             </button>
469                             <button
470                               className="btn btn-link btn-animate text-muted"
471                               onClick={linkEvent(this, this.handleViewSource)}
472                               data-tippy-content={i18n.t("view_source")}
473                               aria-label={i18n.t("view_source")}
474                             >
475                               <Icon
476                                 icon="file-text"
477                                 classes={`icon-inline ${
478                                   this.state.viewSource && "text-success"
479                                 }`}
480                               />
481                             </button>
482                             {this.myComment && (
483                               <>
484                                 <button
485                                   class="btn btn-link btn-animate text-muted"
486                                   onClick={linkEvent(
487                                     this,
488                                     this.handleEditClick
489                                   )}
490                                   data-tippy-content={i18n.t("edit")}
491                                   aria-label={i18n.t("edit")}
492                                 >
493                                   <Icon icon="edit" classes="icon-inline" />
494                                 </button>
495                                 <button
496                                   class="btn btn-link btn-animate text-muted"
497                                   onClick={linkEvent(
498                                     this,
499                                     this.handleDeleteClick
500                                   )}
501                                   data-tippy-content={
502                                     !cv.comment.deleted
503                                       ? i18n.t("delete")
504                                       : i18n.t("restore")
505                                   }
506                                   aria-label={
507                                     !cv.comment.deleted
508                                       ? i18n.t("delete")
509                                       : i18n.t("restore")
510                                   }
511                                 >
512                                   <Icon
513                                     icon="trash"
514                                     classes={`icon-inline ${
515                                       cv.comment.deleted && "text-danger"
516                                     }`}
517                                   />
518                                 </button>
519                               </>
520                             )}
521                             {/* Admins and mods can remove comments */}
522                             {(canMod_ || canAdmin_) && (
523                               <>
524                                 {!cv.comment.removed ? (
525                                   <button
526                                     class="btn btn-link btn-animate text-muted"
527                                     onClick={linkEvent(
528                                       this,
529                                       this.handleModRemoveShow
530                                     )}
531                                     aria-label={i18n.t("remove")}
532                                   >
533                                     {i18n.t("remove")}
534                                   </button>
535                                 ) : (
536                                   <button
537                                     class="btn btn-link btn-animate text-muted"
538                                     onClick={linkEvent(
539                                       this,
540                                       this.handleModRemoveSubmit
541                                     )}
542                                     aria-label={i18n.t("restore")}
543                                   >
544                                     {i18n.t("restore")}
545                                   </button>
546                                 )}
547                               </>
548                             )}
549                             {/* Mods can ban from community, and appoint as mods to community */}
550                             {canMod_ && (
551                               <>
552                                 {!isMod_ &&
553                                   (!cv.creator_banned_from_community ? (
554                                     <button
555                                       class="btn btn-link btn-animate text-muted"
556                                       onClick={linkEvent(
557                                         this,
558                                         this.handleModBanFromCommunityShow
559                                       )}
560                                       aria-label={i18n.t("ban")}
561                                     >
562                                       {i18n.t("ban")}
563                                     </button>
564                                   ) : (
565                                     <button
566                                       class="btn btn-link btn-animate text-muted"
567                                       onClick={linkEvent(
568                                         this,
569                                         this.handleModBanFromCommunitySubmit
570                                       )}
571                                       aria-label={i18n.t("unban")}
572                                     >
573                                       {i18n.t("unban")}
574                                     </button>
575                                   ))}
576                                 {!cv.creator_banned_from_community &&
577                                   (!this.state.showConfirmAppointAsMod ? (
578                                     <button
579                                       class="btn btn-link btn-animate text-muted"
580                                       onClick={linkEvent(
581                                         this,
582                                         this.handleShowConfirmAppointAsMod
583                                       )}
584                                       aria-label={
585                                         isMod_
586                                           ? i18n.t("remove_as_mod")
587                                           : i18n.t("appoint_as_mod")
588                                       }
589                                     >
590                                       {isMod_
591                                         ? i18n.t("remove_as_mod")
592                                         : i18n.t("appoint_as_mod")}
593                                     </button>
594                                   ) : (
595                                     <>
596                                       <button
597                                         class="btn btn-link btn-animate text-muted"
598                                         aria-label={i18n.t("are_you_sure")}
599                                       >
600                                         {i18n.t("are_you_sure")}
601                                       </button>
602                                       <button
603                                         class="btn btn-link btn-animate text-muted"
604                                         onClick={linkEvent(
605                                           this,
606                                           this.handleAddModToCommunity
607                                         )}
608                                         aria-label={i18n.t("yes")}
609                                       >
610                                         {i18n.t("yes")}
611                                       </button>
612                                       <button
613                                         class="btn btn-link btn-animate text-muted"
614                                         onClick={linkEvent(
615                                           this,
616                                           this.handleCancelConfirmAppointAsMod
617                                         )}
618                                         aria-label={i18n.t("no")}
619                                       >
620                                         {i18n.t("no")}
621                                       </button>
622                                     </>
623                                   ))}
624                               </>
625                             )}
626                             {/* Community creators and admins can transfer community to another mod */}
627                             {(amCommunityCreator_ || canAdmin_) &&
628                               isMod_ &&
629                               cv.creator.local &&
630                               (!this.state.showConfirmTransferCommunity ? (
631                                 <button
632                                   class="btn btn-link btn-animate text-muted"
633                                   onClick={linkEvent(
634                                     this,
635                                     this.handleShowConfirmTransferCommunity
636                                   )}
637                                   aria-label={i18n.t("transfer_community")}
638                                 >
639                                   {i18n.t("transfer_community")}
640                                 </button>
641                               ) : (
642                                 <>
643                                   <button
644                                     class="btn btn-link btn-animate text-muted"
645                                     aria-label={i18n.t("are_you_sure")}
646                                   >
647                                     {i18n.t("are_you_sure")}
648                                   </button>
649                                   <button
650                                     class="btn btn-link btn-animate text-muted"
651                                     onClick={linkEvent(
652                                       this,
653                                       this.handleTransferCommunity
654                                     )}
655                                     aria-label={i18n.t("yes")}
656                                   >
657                                     {i18n.t("yes")}
658                                   </button>
659                                   <button
660                                     class="btn btn-link btn-animate text-muted"
661                                     onClick={linkEvent(
662                                       this,
663                                       this
664                                         .handleCancelShowConfirmTransferCommunity
665                                     )}
666                                     aria-label={i18n.t("no")}
667                                   >
668                                     {i18n.t("no")}
669                                   </button>
670                                 </>
671                               ))}
672                             {/* Admins can ban from all, and appoint other admins */}
673                             {canAdmin_ && (
674                               <>
675                                 {!isAdmin_ && (
676                                   <>
677                                     <button
678                                       class="btn btn-link btn-animate text-muted"
679                                       onClick={linkEvent(
680                                         this,
681                                         this.handlePurgePersonShow
682                                       )}
683                                       aria-label={i18n.t("purge_user")}
684                                     >
685                                       {i18n.t("purge_user")}
686                                     </button>
687                                     <button
688                                       class="btn btn-link btn-animate text-muted"
689                                       onClick={linkEvent(
690                                         this,
691                                         this.handlePurgeCommentShow
692                                       )}
693                                       aria-label={i18n.t("purge_comment")}
694                                     >
695                                       {i18n.t("purge_comment")}
696                                     </button>
697
698                                     {!isBanned(cv.creator) ? (
699                                       <button
700                                         class="btn btn-link btn-animate text-muted"
701                                         onClick={linkEvent(
702                                           this,
703                                           this.handleModBanShow
704                                         )}
705                                         aria-label={i18n.t("ban_from_site")}
706                                       >
707                                         {i18n.t("ban_from_site")}
708                                       </button>
709                                     ) : (
710                                       <button
711                                         class="btn btn-link btn-animate text-muted"
712                                         onClick={linkEvent(
713                                           this,
714                                           this.handleModBanSubmit
715                                         )}
716                                         aria-label={i18n.t("unban_from_site")}
717                                       >
718                                         {i18n.t("unban_from_site")}
719                                       </button>
720                                     )}
721                                   </>
722                                 )}
723                                 {!isBanned(cv.creator) &&
724                                   cv.creator.local &&
725                                   (!this.state.showConfirmAppointAsAdmin ? (
726                                     <button
727                                       class="btn btn-link btn-animate text-muted"
728                                       onClick={linkEvent(
729                                         this,
730                                         this.handleShowConfirmAppointAsAdmin
731                                       )}
732                                       aria-label={
733                                         isAdmin_
734                                           ? i18n.t("remove_as_admin")
735                                           : i18n.t("appoint_as_admin")
736                                       }
737                                     >
738                                       {isAdmin_
739                                         ? i18n.t("remove_as_admin")
740                                         : i18n.t("appoint_as_admin")}
741                                     </button>
742                                   ) : (
743                                     <>
744                                       <button class="btn btn-link btn-animate text-muted">
745                                         {i18n.t("are_you_sure")}
746                                       </button>
747                                       <button
748                                         class="btn btn-link btn-animate text-muted"
749                                         onClick={linkEvent(
750                                           this,
751                                           this.handleAddAdmin
752                                         )}
753                                         aria-label={i18n.t("yes")}
754                                       >
755                                         {i18n.t("yes")}
756                                       </button>
757                                       <button
758                                         class="btn btn-link btn-animate text-muted"
759                                         onClick={linkEvent(
760                                           this,
761                                           this.handleCancelConfirmAppointAsAdmin
762                                         )}
763                                         aria-label={i18n.t("no")}
764                                       >
765                                         {i18n.t("no")}
766                                       </button>
767                                     </>
768                                   ))}
769                               </>
770                             )}
771                           </>
772                         )}
773                       </>
774                     )}
775                 </div>
776                 {/* end of button group */}
777               </div>
778             )}
779           </div>
780         </div>
781         {showMoreChildren && (
782           <div
783             className={`details ml-1 comment-node py-2 ${
784               !this.props.noBorder ? "border-top border-light" : ""
785             }`}
786             style={`border-left: 2px ${moreRepliesBorderColor} solid !important`}
787           >
788             <button
789               class="btn btn-link text-muted"
790               onClick={linkEvent(this, this.handleFetchChildren)}
791             >
792               {i18n.t("x_more_replies", {
793                 count: node.comment_view.counts.child_count,
794                 formattedCount: numToSI(node.comment_view.counts.child_count),
795               })}{" "}
796               ➔
797             </button>
798           </div>
799         )}
800         {/* end of details */}
801         {this.state.showRemoveDialog && (
802           <form
803             class="form-inline"
804             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
805           >
806             <label
807               class="sr-only"
808               htmlFor={`mod-remove-reason-${cv.comment.id}`}
809             >
810               {i18n.t("reason")}
811             </label>
812             <input
813               type="text"
814               id={`mod-remove-reason-${cv.comment.id}`}
815               class="form-control mr-2"
816               placeholder={i18n.t("reason")}
817               value={toUndefined(this.state.removeReason)}
818               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
819             />
820             <button
821               type="submit"
822               class="btn btn-secondary"
823               aria-label={i18n.t("remove_comment")}
824             >
825               {i18n.t("remove_comment")}
826             </button>
827           </form>
828         )}
829         {this.state.showReportDialog && (
830           <form
831             class="form-inline"
832             onSubmit={linkEvent(this, this.handleReportSubmit)}
833           >
834             <label class="sr-only" htmlFor={`report-reason-${cv.comment.id}`}>
835               {i18n.t("reason")}
836             </label>
837             <input
838               type="text"
839               required
840               id={`report-reason-${cv.comment.id}`}
841               class="form-control mr-2"
842               placeholder={i18n.t("reason")}
843               value={this.state.reportReason}
844               onInput={linkEvent(this, this.handleReportReasonChange)}
845             />
846             <button
847               type="submit"
848               class="btn btn-secondary"
849               aria-label={i18n.t("create_report")}
850             >
851               {i18n.t("create_report")}
852             </button>
853           </form>
854         )}
855         {this.state.showBanDialog && (
856           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
857             <div class="form-group row col-12">
858               <label
859                 class="col-form-label"
860                 htmlFor={`mod-ban-reason-${cv.comment.id}`}
861               >
862                 {i18n.t("reason")}
863               </label>
864               <input
865                 type="text"
866                 id={`mod-ban-reason-${cv.comment.id}`}
867                 class="form-control mr-2"
868                 placeholder={i18n.t("reason")}
869                 value={toUndefined(this.state.banReason)}
870                 onInput={linkEvent(this, this.handleModBanReasonChange)}
871               />
872               <label
873                 class="col-form-label"
874                 htmlFor={`mod-ban-expires-${cv.comment.id}`}
875               >
876                 {i18n.t("expires")}
877               </label>
878               <input
879                 type="number"
880                 id={`mod-ban-expires-${cv.comment.id}`}
881                 class="form-control mr-2"
882                 placeholder={i18n.t("number_of_days")}
883                 value={toUndefined(this.state.banExpireDays)}
884                 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
885               />
886               <div class="form-group">
887                 <div class="form-check">
888                   <input
889                     class="form-check-input"
890                     id="mod-ban-remove-data"
891                     type="checkbox"
892                     checked={this.state.removeData}
893                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
894                   />
895                   <label
896                     class="form-check-label"
897                     htmlFor="mod-ban-remove-data"
898                     title={i18n.t("remove_content_more")}
899                   >
900                     {i18n.t("remove_content")}
901                   </label>
902                 </div>
903               </div>
904             </div>
905             {/* TODO hold off on expires until later */}
906             {/* <div class="form-group row"> */}
907             {/*   <label class="col-form-label">Expires</label> */}
908             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
909             {/* </div> */}
910             <div class="form-group row">
911               <button
912                 type="submit"
913                 class="btn btn-secondary"
914                 aria-label={i18n.t("ban")}
915               >
916                 {i18n.t("ban")} {cv.creator.name}
917               </button>
918             </div>
919           </form>
920         )}
921
922         {this.state.showPurgeDialog && (
923           <form onSubmit={linkEvent(this, this.handlePurgeSubmit)}>
924             <PurgeWarning />
925             <label class="sr-only" htmlFor="purge-reason">
926               {i18n.t("reason")}
927             </label>
928             <input
929               type="text"
930               id="purge-reason"
931               class="form-control my-3"
932               placeholder={i18n.t("reason")}
933               value={toUndefined(this.state.purgeReason)}
934               onInput={linkEvent(this, this.handlePurgeReasonChange)}
935             />
936             <div class="form-group row col-12">
937               {this.state.purgeLoading ? (
938                 <Spinner />
939               ) : (
940                 <button
941                   type="submit"
942                   class="btn btn-secondary"
943                   aria-label={purgeTypeText}
944                 >
945                   {purgeTypeText}
946                 </button>
947               )}
948             </div>
949           </form>
950         )}
951         {this.state.showReply && (
952           <CommentForm
953             node={Left(node)}
954             onReplyCancel={this.handleReplyCancel}
955             disabled={this.props.locked}
956             focus
957           />
958         )}
959         {!this.state.collapsed && node.children.length > 0 && (
960           <CommentNodes
961             nodes={node.children}
962             locked={this.props.locked}
963             moderators={this.props.moderators}
964             admins={this.props.admins}
965             maxCommentsShown={None}
966             enableDownvotes={this.props.enableDownvotes}
967             viewType={this.props.viewType}
968           />
969         )}
970         {/* A collapsed clearfix */}
971         {this.state.collapsed && <div class="row col-12"></div>}
972       </div>
973     );
974   }
975
976   get commentReplyOrMentionRead(): boolean {
977     let cv = this.props.node.comment_view;
978
979     if (this.isPersonMentionType(cv)) {
980       return cv.person_mention.read;
981     } else if (this.isCommentReplyType(cv)) {
982       return cv.comment_reply.read;
983     } else {
984       return false;
985     }
986   }
987
988   linkBtn(small = false) {
989     let cv = this.props.node.comment_view;
990     let classnames = classNames("btn btn-link btn-animate text-muted", {
991       "btn-sm": small,
992     });
993
994     let title = this.props.showContext
995       ? i18n.t("show_context")
996       : i18n.t("link");
997
998     return (
999       <>
1000         <Link
1001           className={classnames}
1002           to={`/comment/${cv.comment.id}`}
1003           title={title}
1004         >
1005           <Icon icon="link" classes="icon-inline" />
1006         </Link>
1007         {
1008           <a className={classnames} title={title} href={cv.comment.ap_id}>
1009             <Icon icon="fedilink" classes="icon-inline" />
1010           </a>
1011         }
1012       </>
1013     );
1014   }
1015
1016   get loadingIcon() {
1017     return <Spinner />;
1018   }
1019
1020   get myComment(): boolean {
1021     return UserService.Instance.myUserInfo
1022       .map(
1023         m =>
1024           m.local_user_view.person.id == this.props.node.comment_view.creator.id
1025       )
1026       .unwrapOr(false);
1027   }
1028
1029   get isPostCreator(): boolean {
1030     return (
1031       this.props.node.comment_view.creator.id ==
1032       this.props.node.comment_view.post.creator_id
1033     );
1034   }
1035
1036   get commentUnlessRemoved(): string {
1037     let comment = this.props.node.comment_view.comment;
1038     return comment.removed
1039       ? `*${i18n.t("removed")}*`
1040       : comment.deleted
1041       ? `*${i18n.t("deleted")}*`
1042       : comment.content;
1043   }
1044
1045   handleReplyClick(i: CommentNode) {
1046     i.state.showReply = true;
1047     i.setState(i.state);
1048   }
1049
1050   handleEditClick(i: CommentNode) {
1051     i.state.showEdit = true;
1052     i.setState(i.state);
1053   }
1054
1055   handleBlockUserClick(i: CommentNode) {
1056     let blockUserForm = new BlockPerson({
1057       person_id: i.props.node.comment_view.creator.id,
1058       block: true,
1059       auth: auth().unwrap(),
1060     });
1061     WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
1062   }
1063
1064   handleDeleteClick(i: CommentNode) {
1065     let comment = i.props.node.comment_view.comment;
1066     let deleteForm = new DeleteComment({
1067       comment_id: comment.id,
1068       deleted: !comment.deleted,
1069       auth: auth().unwrap(),
1070     });
1071     WebSocketService.Instance.send(wsClient.deleteComment(deleteForm));
1072   }
1073
1074   handleSaveCommentClick(i: CommentNode) {
1075     let cv = i.props.node.comment_view;
1076     let save = cv.saved == undefined ? true : !cv.saved;
1077     let form = new SaveComment({
1078       comment_id: cv.comment.id,
1079       save,
1080       auth: auth().unwrap(),
1081     });
1082
1083     WebSocketService.Instance.send(wsClient.saveComment(form));
1084
1085     i.state.saveLoading = true;
1086     i.setState(this.state);
1087   }
1088
1089   handleReplyCancel() {
1090     this.state.showReply = false;
1091     this.state.showEdit = false;
1092     this.setState(this.state);
1093   }
1094
1095   handleCommentUpvote(event: any) {
1096     event.preventDefault();
1097     let myVote = this.state.my_vote.unwrapOr(0);
1098     let newVote = myVote == 1 ? 0 : 1;
1099
1100     if (myVote == 1) {
1101       this.state.score--;
1102       this.state.upvotes--;
1103     } else if (myVote == -1) {
1104       this.state.downvotes--;
1105       this.state.upvotes++;
1106       this.state.score += 2;
1107     } else {
1108       this.state.upvotes++;
1109       this.state.score++;
1110     }
1111
1112     this.state.my_vote = Some(newVote);
1113
1114     let form = new CreateCommentLike({
1115       comment_id: this.props.node.comment_view.comment.id,
1116       score: newVote,
1117       auth: auth().unwrap(),
1118     });
1119     WebSocketService.Instance.send(wsClient.likeComment(form));
1120     this.setState(this.state);
1121     setupTippy();
1122   }
1123
1124   handleCommentDownvote(event: any) {
1125     event.preventDefault();
1126     let myVote = this.state.my_vote.unwrapOr(0);
1127     let newVote = myVote == -1 ? 0 : -1;
1128
1129     if (myVote == 1) {
1130       this.state.score -= 2;
1131       this.state.upvotes--;
1132       this.state.downvotes++;
1133     } else if (myVote == -1) {
1134       this.state.downvotes--;
1135       this.state.score++;
1136     } else {
1137       this.state.downvotes++;
1138       this.state.score--;
1139     }
1140
1141     this.state.my_vote = Some(newVote);
1142
1143     let form = new CreateCommentLike({
1144       comment_id: this.props.node.comment_view.comment.id,
1145       score: newVote,
1146       auth: auth().unwrap(),
1147     });
1148
1149     WebSocketService.Instance.send(wsClient.likeComment(form));
1150     this.setState(this.state);
1151     setupTippy();
1152   }
1153
1154   handleShowReportDialog(i: CommentNode) {
1155     i.state.showReportDialog = !i.state.showReportDialog;
1156     i.setState(i.state);
1157   }
1158
1159   handleReportReasonChange(i: CommentNode, event: any) {
1160     i.state.reportReason = event.target.value;
1161     i.setState(i.state);
1162   }
1163
1164   handleReportSubmit(i: CommentNode) {
1165     let comment = i.props.node.comment_view.comment;
1166     let form = new CreateCommentReport({
1167       comment_id: comment.id,
1168       reason: i.state.reportReason,
1169       auth: auth().unwrap(),
1170     });
1171     WebSocketService.Instance.send(wsClient.createCommentReport(form));
1172
1173     i.state.showReportDialog = false;
1174     i.setState(i.state);
1175   }
1176
1177   handleModRemoveShow(i: CommentNode) {
1178     i.state.showRemoveDialog = !i.state.showRemoveDialog;
1179     i.state.showBanDialog = false;
1180     i.setState(i.state);
1181   }
1182
1183   handleModRemoveReasonChange(i: CommentNode, event: any) {
1184     i.state.removeReason = Some(event.target.value);
1185     i.setState(i.state);
1186   }
1187
1188   handleModRemoveDataChange(i: CommentNode, event: any) {
1189     i.state.removeData = event.target.checked;
1190     i.setState(i.state);
1191   }
1192
1193   handleModRemoveSubmit(i: CommentNode) {
1194     let comment = i.props.node.comment_view.comment;
1195     let form = new RemoveComment({
1196       comment_id: comment.id,
1197       removed: !comment.removed,
1198       reason: i.state.removeReason,
1199       auth: auth().unwrap(),
1200     });
1201     WebSocketService.Instance.send(wsClient.removeComment(form));
1202
1203     i.state.showRemoveDialog = false;
1204     i.setState(i.state);
1205   }
1206
1207   isPersonMentionType(
1208     item: CommentView | PersonMentionView | CommentReplyView
1209   ): item is PersonMentionView {
1210     return (item as PersonMentionView).person_mention?.id !== undefined;
1211   }
1212
1213   isCommentReplyType(
1214     item: CommentView | PersonMentionView | CommentReplyView
1215   ): item is CommentReplyView {
1216     return (item as CommentReplyView).comment_reply?.id !== undefined;
1217   }
1218
1219   handleMarkRead(i: CommentNode) {
1220     if (i.isPersonMentionType(i.props.node.comment_view)) {
1221       let form = new MarkPersonMentionAsRead({
1222         person_mention_id: i.props.node.comment_view.person_mention.id,
1223         read: !i.props.node.comment_view.person_mention.read,
1224         auth: auth().unwrap(),
1225       });
1226       WebSocketService.Instance.send(wsClient.markPersonMentionAsRead(form));
1227     } else if (i.isCommentReplyType(i.props.node.comment_view)) {
1228       let form = new MarkCommentReplyAsRead({
1229         comment_reply_id: i.props.node.comment_view.comment_reply.id,
1230         read: !i.props.node.comment_view.comment_reply.read,
1231         auth: auth().unwrap(),
1232       });
1233       WebSocketService.Instance.send(wsClient.markCommentReplyAsRead(form));
1234     }
1235
1236     i.state.readLoading = true;
1237     i.setState(this.state);
1238   }
1239
1240   handleModBanFromCommunityShow(i: CommentNode) {
1241     i.state.showBanDialog = true;
1242     i.state.banType = BanType.Community;
1243     i.state.showRemoveDialog = false;
1244     i.setState(i.state);
1245   }
1246
1247   handleModBanShow(i: CommentNode) {
1248     i.state.showBanDialog = true;
1249     i.state.banType = BanType.Site;
1250     i.state.showRemoveDialog = false;
1251     i.setState(i.state);
1252   }
1253
1254   handleModBanReasonChange(i: CommentNode, event: any) {
1255     i.state.banReason = Some(event.target.value);
1256     i.setState(i.state);
1257   }
1258
1259   handleModBanExpireDaysChange(i: CommentNode, event: any) {
1260     i.state.banExpireDays = Some(event.target.value);
1261     i.setState(i.state);
1262   }
1263
1264   handleModBanFromCommunitySubmit(i: CommentNode) {
1265     i.state.banType = BanType.Community;
1266     i.setState(i.state);
1267     i.handleModBanBothSubmit(i);
1268   }
1269
1270   handleModBanSubmit(i: CommentNode) {
1271     i.state.banType = BanType.Site;
1272     i.setState(i.state);
1273     i.handleModBanBothSubmit(i);
1274   }
1275
1276   handleModBanBothSubmit(i: CommentNode) {
1277     let cv = i.props.node.comment_view;
1278
1279     if (i.state.banType == BanType.Community) {
1280       // If its an unban, restore all their data
1281       let ban = !cv.creator_banned_from_community;
1282       if (ban == false) {
1283         i.state.removeData = false;
1284       }
1285       let form = new BanFromCommunity({
1286         person_id: cv.creator.id,
1287         community_id: cv.community.id,
1288         ban,
1289         remove_data: Some(i.state.removeData),
1290         reason: i.state.banReason,
1291         expires: i.state.banExpireDays.map(futureDaysToUnixTime),
1292         auth: auth().unwrap(),
1293       });
1294       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1295     } else {
1296       // If its an unban, restore all their data
1297       let ban = !cv.creator.banned;
1298       if (ban == false) {
1299         i.state.removeData = false;
1300       }
1301       let form = new BanPerson({
1302         person_id: cv.creator.id,
1303         ban,
1304         remove_data: Some(i.state.removeData),
1305         reason: i.state.banReason,
1306         expires: i.state.banExpireDays.map(futureDaysToUnixTime),
1307         auth: auth().unwrap(),
1308       });
1309       WebSocketService.Instance.send(wsClient.banPerson(form));
1310     }
1311
1312     i.state.showBanDialog = false;
1313     i.setState(i.state);
1314   }
1315
1316   handlePurgePersonShow(i: CommentNode) {
1317     i.state.showPurgeDialog = true;
1318     i.state.purgeType = PurgeType.Person;
1319     i.state.showRemoveDialog = false;
1320     i.setState(i.state);
1321   }
1322
1323   handlePurgeCommentShow(i: CommentNode) {
1324     i.state.showPurgeDialog = true;
1325     i.state.purgeType = PurgeType.Comment;
1326     i.state.showRemoveDialog = false;
1327     i.setState(i.state);
1328   }
1329
1330   handlePurgeReasonChange(i: CommentNode, event: any) {
1331     i.state.purgeReason = Some(event.target.value);
1332     i.setState(i.state);
1333   }
1334
1335   handlePurgeSubmit(i: CommentNode, event: any) {
1336     event.preventDefault();
1337
1338     if (i.state.purgeType == PurgeType.Person) {
1339       let form = new PurgePerson({
1340         person_id: i.props.node.comment_view.creator.id,
1341         reason: i.state.purgeReason,
1342         auth: auth().unwrap(),
1343       });
1344       WebSocketService.Instance.send(wsClient.purgePerson(form));
1345     } else if (i.state.purgeType == PurgeType.Comment) {
1346       let form = new PurgeComment({
1347         comment_id: i.props.node.comment_view.comment.id,
1348         reason: i.state.purgeReason,
1349         auth: auth().unwrap(),
1350       });
1351       WebSocketService.Instance.send(wsClient.purgeComment(form));
1352     }
1353
1354     i.state.purgeLoading = true;
1355     i.setState(i.state);
1356   }
1357
1358   handleShowConfirmAppointAsMod(i: CommentNode) {
1359     i.state.showConfirmAppointAsMod = true;
1360     i.setState(i.state);
1361   }
1362
1363   handleCancelConfirmAppointAsMod(i: CommentNode) {
1364     i.state.showConfirmAppointAsMod = false;
1365     i.setState(i.state);
1366   }
1367
1368   handleAddModToCommunity(i: CommentNode) {
1369     let cv = i.props.node.comment_view;
1370     let form = new AddModToCommunity({
1371       person_id: cv.creator.id,
1372       community_id: cv.community.id,
1373       added: !isMod(i.props.moderators, cv.creator.id),
1374       auth: auth().unwrap(),
1375     });
1376     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1377     i.state.showConfirmAppointAsMod = false;
1378     i.setState(i.state);
1379   }
1380
1381   handleShowConfirmAppointAsAdmin(i: CommentNode) {
1382     i.state.showConfirmAppointAsAdmin = true;
1383     i.setState(i.state);
1384   }
1385
1386   handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1387     i.state.showConfirmAppointAsAdmin = false;
1388     i.setState(i.state);
1389   }
1390
1391   handleAddAdmin(i: CommentNode) {
1392     let creatorId = i.props.node.comment_view.creator.id;
1393     let form = new AddAdmin({
1394       person_id: creatorId,
1395       added: !isAdmin(i.props.admins, creatorId),
1396       auth: auth().unwrap(),
1397     });
1398     WebSocketService.Instance.send(wsClient.addAdmin(form));
1399     i.state.showConfirmAppointAsAdmin = false;
1400     i.setState(i.state);
1401   }
1402
1403   handleShowConfirmTransferCommunity(i: CommentNode) {
1404     i.state.showConfirmTransferCommunity = true;
1405     i.setState(i.state);
1406   }
1407
1408   handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1409     i.state.showConfirmTransferCommunity = false;
1410     i.setState(i.state);
1411   }
1412
1413   handleTransferCommunity(i: CommentNode) {
1414     let cv = i.props.node.comment_view;
1415     let form = new TransferCommunity({
1416       community_id: cv.community.id,
1417       person_id: cv.creator.id,
1418       auth: auth().unwrap(),
1419     });
1420     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1421     i.state.showConfirmTransferCommunity = false;
1422     i.setState(i.state);
1423   }
1424
1425   handleShowConfirmTransferSite(i: CommentNode) {
1426     i.state.showConfirmTransferSite = true;
1427     i.setState(i.state);
1428   }
1429
1430   handleCancelShowConfirmTransferSite(i: CommentNode) {
1431     i.state.showConfirmTransferSite = false;
1432     i.setState(i.state);
1433   }
1434
1435   get isCommentNew(): boolean {
1436     let now = moment.utc().subtract(10, "minutes");
1437     let then = moment.utc(this.props.node.comment_view.comment.published);
1438     return now.isBefore(then);
1439   }
1440
1441   handleCommentCollapse(i: CommentNode) {
1442     i.state.collapsed = !i.state.collapsed;
1443     i.setState(i.state);
1444     setupTippy();
1445   }
1446
1447   handleViewSource(i: CommentNode) {
1448     i.state.viewSource = !i.state.viewSource;
1449     i.setState(i.state);
1450   }
1451
1452   handleShowAdvanced(i: CommentNode) {
1453     i.state.showAdvanced = !i.state.showAdvanced;
1454     i.setState(i.state);
1455     setupTippy();
1456   }
1457
1458   handleFetchChildren(i: CommentNode) {
1459     let form = new GetComments({
1460       post_id: Some(i.props.node.comment_view.post.id),
1461       parent_id: Some(i.props.node.comment_view.comment.id),
1462       max_depth: Some(commentTreeMaxDepth),
1463       page: None,
1464       sort: None,
1465       limit: Some(999),
1466       type_: Some(ListingType.All),
1467       community_name: None,
1468       community_id: None,
1469       saved_only: Some(false),
1470       auth: auth(false).ok(),
1471     });
1472
1473     WebSocketService.Instance.send(wsClient.getComments(form));
1474   }
1475
1476   get scoreColor() {
1477     if (this.state.my_vote.unwrapOr(0) == 1) {
1478       return "text-info";
1479     } else if (this.state.my_vote.unwrapOr(0) == -1) {
1480       return "text-danger";
1481     } else {
1482       return "text-muted";
1483     }
1484   }
1485
1486   get pointsTippy(): string {
1487     let points = i18n.t("number_of_points", {
1488       count: this.state.score,
1489       formattedCount: this.state.score,
1490     });
1491
1492     let upvotes = i18n.t("number_of_upvotes", {
1493       count: this.state.upvotes,
1494       formattedCount: this.state.upvotes,
1495     });
1496
1497     let downvotes = i18n.t("number_of_downvotes", {
1498       count: this.state.downvotes,
1499       formattedCount: this.state.downvotes,
1500     });
1501
1502     return `${points} • ${upvotes} • ${downvotes}`;
1503   }
1504
1505   get expandText(): string {
1506     return this.state.collapsed ? i18n.t("expand") : i18n.t("collapse");
1507   }
1508 }