]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post-listing.tsx
Merge branch 'main' into feat/hide-avatars-on-listings
[lemmy-ui.git] / src / shared / components / post / post-listing.tsx
1 import { myAuthRequired } from "@utils/app";
2 import { canShare, share } from "@utils/browser";
3 import { getExternalHost, getHttpBase } from "@utils/env";
4 import {
5   capitalizeFirstLetter,
6   futureDaysToUnixTime,
7   hostname,
8 } from "@utils/helpers";
9 import { isImage, isVideo } from "@utils/media";
10 import {
11   amAdmin,
12   amCommunityCreator,
13   amMod,
14   canAdmin,
15   canMod,
16   isAdmin,
17   isBanned,
18   isMod,
19 } from "@utils/roles";
20 import classNames from "classnames";
21 import { Component, linkEvent } from "inferno";
22 import { Link } from "inferno-router";
23 import {
24   AddAdmin,
25   AddModToCommunity,
26   BanFromCommunity,
27   BanPerson,
28   BlockPerson,
29   CommunityModeratorView,
30   CreatePostLike,
31   CreatePostReport,
32   DeletePost,
33   EditPost,
34   FeaturePost,
35   Language,
36   LockPost,
37   PersonView,
38   PostView,
39   PurgePerson,
40   PurgePost,
41   RemovePost,
42   SavePost,
43   TransferCommunity,
44 } from "lemmy-js-client";
45 import { relTags } from "../../config";
46 import {
47   BanType,
48   PostFormParams,
49   PurgeType,
50   VoteContentType,
51 } from "../../interfaces";
52 import { mdNoImages, mdToHtml, mdToHtmlInline } from "../../markdown";
53 import { I18NextService, UserService } from "../../services";
54 import { setupTippy } from "../../tippy";
55 import { Icon, PurgeWarning, Spinner } from "../common/icon";
56 import { MomentTime } from "../common/moment-time";
57 import { PictrsImage } from "../common/pictrs-image";
58 import { VoteButtons, VoteButtonsCompact } from "../common/vote-buttons";
59 import { CommunityLink } from "../community/community-link";
60 import { PersonListing } from "../person/person-listing";
61 import { MetadataCard } from "./metadata-card";
62 import { PostForm } from "./post-form";
63
64 interface PostListingState {
65   showEdit: boolean;
66   showRemoveDialog: boolean;
67   showPurgeDialog: boolean;
68   purgeReason?: string;
69   purgeType?: PurgeType;
70   purgeLoading: boolean;
71   removeReason?: string;
72   showBanDialog: boolean;
73   banReason?: string;
74   banExpireDays?: number;
75   banType?: BanType;
76   removeData?: boolean;
77   showConfirmTransferSite: boolean;
78   showConfirmTransferCommunity: boolean;
79   imageExpanded: boolean;
80   viewSource: boolean;
81   showAdvanced: boolean;
82   showMoreMobile: boolean;
83   showBody: boolean;
84   showReportDialog: boolean;
85   reportReason?: string;
86   reportLoading: boolean;
87   blockLoading: boolean;
88   lockLoading: boolean;
89   deleteLoading: boolean;
90   removeLoading: boolean;
91   saveLoading: boolean;
92   featureCommunityLoading: boolean;
93   featureLocalLoading: boolean;
94   banLoading: boolean;
95   addModLoading: boolean;
96   addAdminLoading: boolean;
97   transferLoading: boolean;
98 }
99
100 interface PostListingProps {
101   post_view: PostView;
102   crossPosts?: PostView[];
103   moderators?: CommunityModeratorView[];
104   admins?: PersonView[];
105   allLanguages: Language[];
106   siteLanguages: number[];
107   showCommunity?: boolean;
108   showBody?: boolean;
109   hideAvatars?: boolean;
110   hideImage?: boolean;
111   enableDownvotes?: boolean;
112   enableNsfw?: boolean;
113   viewOnly?: boolean;
114   onPostEdit(form: EditPost): void;
115   onPostVote(form: CreatePostLike): void;
116   onPostReport(form: CreatePostReport): void;
117   onBlockPerson(form: BlockPerson): void;
118   onLockPost(form: LockPost): void;
119   onDeletePost(form: DeletePost): void;
120   onRemovePost(form: RemovePost): void;
121   onSavePost(form: SavePost): void;
122   onFeaturePost(form: FeaturePost): void;
123   onPurgePerson(form: PurgePerson): void;
124   onPurgePost(form: PurgePost): void;
125   onBanPersonFromCommunity(form: BanFromCommunity): void;
126   onBanPerson(form: BanPerson): void;
127   onAddModToCommunity(form: AddModToCommunity): void;
128   onAddAdmin(form: AddAdmin): void;
129   onTransferCommunity(form: TransferCommunity): void;
130 }
131
132 export class PostListing extends Component<PostListingProps, PostListingState> {
133   state: PostListingState = {
134     showEdit: false,
135     showRemoveDialog: false,
136     showPurgeDialog: false,
137     purgeType: PurgeType.Person,
138     showBanDialog: false,
139     banType: BanType.Community,
140     removeData: false,
141     showConfirmTransferSite: false,
142     showConfirmTransferCommunity: false,
143     imageExpanded: false,
144     viewSource: false,
145     showAdvanced: false,
146     showMoreMobile: false,
147     showBody: false,
148     showReportDialog: false,
149     purgeLoading: false,
150     reportLoading: false,
151     blockLoading: false,
152     lockLoading: false,
153     deleteLoading: false,
154     removeLoading: false,
155     saveLoading: false,
156     featureCommunityLoading: false,
157     featureLocalLoading: false,
158     banLoading: false,
159     addModLoading: false,
160     addAdminLoading: false,
161     transferLoading: false,
162   };
163
164   constructor(props: any, context: any) {
165     super(props, context);
166
167     this.handleEditPost = this.handleEditPost.bind(this);
168     this.handleEditCancel = this.handleEditCancel.bind(this);
169   }
170
171   componentWillReceiveProps(nextProps: PostListingProps) {
172     if (this.props !== nextProps) {
173       this.setState({
174         purgeLoading: false,
175         reportLoading: false,
176         blockLoading: false,
177         lockLoading: false,
178         deleteLoading: false,
179         removeLoading: false,
180         saveLoading: false,
181         featureCommunityLoading: false,
182         featureLocalLoading: false,
183         banLoading: false,
184         addModLoading: false,
185         addAdminLoading: false,
186         transferLoading: false,
187         imageExpanded: false,
188       });
189     }
190   }
191
192   get postView(): PostView {
193     return this.props.post_view;
194   }
195
196   render() {
197     const post = this.postView.post;
198
199     return (
200       <div className="post-listing mt-2">
201         {!this.state.showEdit ? (
202           <>
203             {this.listing()}
204             {this.state.imageExpanded && !this.props.hideImage && this.img}
205             {post.url && this.state.showBody && post.embed_title && (
206               <MetadataCard post={post} />
207             )}
208             {this.showBody && this.body()}
209           </>
210         ) : (
211           <PostForm
212             post_view={this.postView}
213             crossPosts={this.props.crossPosts}
214             onEdit={this.handleEditPost}
215             onCancel={this.handleEditCancel}
216             enableNsfw={this.props.enableNsfw}
217             enableDownvotes={this.props.enableDownvotes}
218             allLanguages={this.props.allLanguages}
219             siteLanguages={this.props.siteLanguages}
220           />
221         )}
222       </div>
223     );
224   }
225
226   body() {
227     const body = this.postView.post.body;
228     return body ? (
229       <article id="postContent" className="col-12 card my-2 p-2">
230         {this.state.viewSource ? (
231           <pre>{body}</pre>
232         ) : (
233           <div className="md-div" dangerouslySetInnerHTML={mdToHtml(body)} />
234         )}
235       </article>
236     ) : (
237       <></>
238     );
239   }
240
241   get img() {
242     if (this.imageSrc) {
243       return (
244         <>
245           <div className="offset-sm-3 my-2 d-none d-sm-block">
246             <a href={this.imageSrc} className="d-inline-block">
247               <PictrsImage src={this.imageSrc} />
248             </a>
249           </div>
250           <div className="my-2 d-block d-sm-none">
251             <button
252               type="button"
253               className="p-0 border-0 bg-transparent d-inline-block"
254               onClick={linkEvent(this, this.handleImageExpandClick)}
255             >
256               <PictrsImage src={this.imageSrc} />
257             </button>
258           </div>
259         </>
260       );
261     }
262
263     const { post } = this.postView;
264     const { url } = post;
265
266     // if direct video link
267     if (url && isVideo(url)) {
268       return (
269         <div className="embed-responsive mt-3">
270           <video muted controls className="embed-responsive-item col-12">
271             <source src={url} type="video/mp4" />
272           </video>
273         </div>
274       );
275     }
276
277     // if embedded video link
278     if (url && post.embed_video_url) {
279       return (
280         <div className="ratio ratio-16x9">
281           <iframe
282             allowFullScreen
283             className="post-metadata-iframe"
284             src={post.embed_video_url}
285             title={post.embed_title}
286           ></iframe>
287         </div>
288       );
289     }
290
291     return <></>;
292   }
293
294   imgThumb(src: string) {
295     const post_view = this.postView;
296     return (
297       <PictrsImage
298         src={src}
299         thumbnail
300         alt=""
301         nsfw={post_view.post.nsfw || post_view.community.nsfw}
302       />
303     );
304   }
305
306   get imageSrc(): string | undefined {
307     const post = this.postView.post;
308     const url = post.url;
309     const thumbnail = post.thumbnail_url;
310
311     if (url && isImage(url)) {
312       if (url.includes("pictrs")) {
313         return url;
314       } else if (thumbnail) {
315         return thumbnail;
316       } else {
317         return url;
318       }
319     } else if (thumbnail) {
320       return thumbnail;
321     } else {
322       return undefined;
323     }
324   }
325
326   thumbnail() {
327     const post = this.postView.post;
328     const url = post.url;
329     const thumbnail = post.thumbnail_url;
330
331     if (!this.props.hideImage && url && isImage(url) && this.imageSrc) {
332       return (
333         <a
334           href={this.imageSrc}
335           className="text-body d-inline-block position-relative mb-2"
336           data-tippy-content={I18NextService.i18n.t("expand_here")}
337           onClick={linkEvent(this, this.handleImageExpandClick)}
338           aria-label={I18NextService.i18n.t("expand_here")}
339         >
340           {this.imgThumb(this.imageSrc)}
341           <Icon icon="image" classes="mini-overlay" />
342         </a>
343       );
344     } else if (!this.props.hideImage && url && thumbnail && this.imageSrc) {
345       return (
346         <a
347           className="text-body d-inline-block position-relative mb-2"
348           href={url}
349           rel={relTags}
350           title={url}
351         >
352           {this.imgThumb(this.imageSrc)}
353           <Icon icon="external-link" classes="mini-overlay" />
354         </a>
355       );
356     } else if (url) {
357       if ((!this.props.hideImage && isVideo(url)) || post.embed_video_url) {
358         return (
359           <a
360             className="text-body"
361             href={url}
362             title={url}
363             rel={relTags}
364             data-tippy-content={I18NextService.i18n.t("expand_here")}
365             onClick={linkEvent(this, this.handleImageExpandClick)}
366             aria-label={I18NextService.i18n.t("expand_here")}
367           >
368             <div className="thumbnail rounded bg-light d-flex justify-content-center">
369               <Icon icon="play" classes="d-flex align-items-center" />
370             </div>
371           </a>
372         );
373       } else {
374         return (
375           <a className="text-body" href={url} title={url} rel={relTags}>
376             <div className="thumbnail rounded bg-light d-flex justify-content-center">
377               <Icon icon="external-link" classes="d-flex align-items-center" />
378             </div>
379           </a>
380         );
381       }
382     } else {
383       return (
384         <Link
385           className="text-body"
386           to={`/post/${post.id}`}
387           title={I18NextService.i18n.t("comments")}
388         >
389           <div className="thumbnail rounded bg-light d-flex justify-content-center">
390             <Icon icon="message-square" classes="d-flex align-items-center" />
391           </div>
392         </Link>
393       );
394     }
395   }
396
397   createdLine() {
398     const post_view = this.postView;
399     return (
400       <span className="small">
401         <PersonListing
402           person={post_view.creator}
403           hideAvatar={this.props.hideAvatars}
404         />
405         {this.creatorIsMod_ && (
406           <span className="mx-1 badge text-bg-light">
407             {I18NextService.i18n.t("mod")}
408           </span>
409         )}
410         {this.creatorIsAdmin_ && (
411           <span className="mx-1 badge text-bg-light">
412             {I18NextService.i18n.t("admin")}
413           </span>
414         )}
415         {post_view.creator.bot_account && (
416           <span className="mx-1 badge text-bg-light">
417             {I18NextService.i18n.t("bot_account").toLowerCase()}
418           </span>
419         )}
420         {this.props.showCommunity && (
421           <>
422             {" "}
423             {I18NextService.i18n.t("to")}{" "}
424             <CommunityLink
425               community={post_view.community}
426               hideAvatar={this.props.hideAvatars}
427             />
428           </>
429         )}
430         {post_view.post.language_id !== 0 && (
431           <span className="mx-1 badge text-bg-light">
432             {
433               this.props.allLanguages.find(
434                 lang => lang.id === post_view.post.language_id
435               )?.name
436             }
437           </span>
438         )}{" "}
439         •{" "}
440         <MomentTime
441           published={post_view.post.published}
442           updated={post_view.post.updated}
443         />
444       </span>
445     );
446   }
447
448   get postLink() {
449     const post = this.postView.post;
450     return (
451       <Link
452         className={`d-inline ${
453           !post.featured_community && !post.featured_local
454             ? "link-dark"
455             : "link-primary"
456         }`}
457         to={`/post/${post.id}`}
458         title={I18NextService.i18n.t("comments")}
459       >
460         <span
461           className="d-inline"
462           dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
463         />
464       </Link>
465     );
466   }
467
468   postTitleLine() {
469     const post = this.postView.post;
470     const url = post.url;
471
472     return (
473       <>
474         <div className="post-title overflow-hidden">
475           <h5 className="d-inline">
476             {url && this.props.showBody ? (
477               <a
478                 className={
479                   !post.featured_community && !post.featured_local
480                     ? "link-dark"
481                     : "link-primary"
482                 }
483                 href={url}
484                 title={url}
485                 rel={relTags}
486                 dangerouslySetInnerHTML={mdToHtmlInline(post.name)}
487               ></a>
488             ) : (
489               this.postLink
490             )}
491           </h5>
492           {(url && isImage(url)) ||
493             (post.thumbnail_url && (
494               <button
495                 className="btn btn-sm text-monospace text-muted d-inline-block"
496                 data-tippy-content={I18NextService.i18n.t("expand_here")}
497                 onClick={linkEvent(this, this.handleImageExpandClick)}
498               >
499                 <Icon
500                   icon={
501                     !this.state.imageExpanded ? "plus-square" : "minus-square"
502                   }
503                   classes="icon-inline"
504                 />
505               </button>
506             ))}
507           {post.removed && (
508             <small className="ms-2 badge text-bg-secondary">
509               {I18NextService.i18n.t("removed")}
510             </small>
511           )}
512           {post.deleted && (
513             <small
514               className="unselectable pointer ms-2 text-muted fst-italic"
515               data-tippy-content={I18NextService.i18n.t("deleted")}
516             >
517               <Icon icon="trash" classes="icon-inline text-danger" />
518             </small>
519           )}
520           {post.locked && (
521             <small
522               className="unselectable pointer ms-2 text-muted fst-italic"
523               data-tippy-content={I18NextService.i18n.t("locked")}
524             >
525               <Icon icon="lock" classes="icon-inline text-danger" />
526             </small>
527           )}
528           {post.featured_community && (
529             <small
530               className="unselectable pointer ms-2 text-muted fst-italic"
531               data-tippy-content={I18NextService.i18n.t(
532                 "featured_in_community"
533               )}
534               aria-label={I18NextService.i18n.t("featured_in_community")}
535             >
536               <Icon icon="pin" classes="icon-inline text-primary" />
537             </small>
538           )}
539           {post.featured_local && (
540             <small
541               className="unselectable pointer ms-2 text-muted fst-italic"
542               data-tippy-content={I18NextService.i18n.t("featured_in_local")}
543               aria-label={I18NextService.i18n.t("featured_in_local")}
544             >
545               <Icon icon="pin" classes="icon-inline text-secondary" />
546             </small>
547           )}
548           {post.nsfw && (
549             <small className="ms-2 badge text-bg-danger">
550               {I18NextService.i18n.t("nsfw")}
551             </small>
552           )}
553         </div>
554         {url && this.urlLine()}
555       </>
556     );
557   }
558
559   urlLine() {
560     const post = this.postView.post;
561     const url = post.url;
562
563     return (
564       <p className="small m-0">
565         {url && !(hostname(url) === getExternalHost()) && (
566           <a
567             className="fst-italic link-dark link-opacity-75 link-opacity-100-hover"
568             href={url}
569             title={url}
570             rel={relTags}
571           >
572             {hostname(url)}
573           </a>
574         )}
575       </p>
576     );
577   }
578
579   duplicatesLine() {
580     const dupes = this.props.crossPosts;
581     return dupes && dupes.length > 0 ? (
582       <ul className="list-inline mb-1 small text-muted">
583         <>
584           <li className="list-inline-item me-2">
585             {I18NextService.i18n.t("cross_posted_to")}
586           </li>
587           {dupes.map(pv => (
588             <li key={pv.post.id} className="list-inline-item me-2">
589               <Link to={`/post/${pv.post.id}`}>
590                 {pv.community.local
591                   ? pv.community.name
592                   : `${pv.community.name}@${hostname(pv.community.actor_id)}`}
593               </Link>
594             </li>
595           ))}
596         </>
597       </ul>
598     ) : (
599       <></>
600     );
601   }
602
603   commentsLine(mobile = false) {
604     const post = this.postView.post;
605
606     return (
607       <div className="d-flex align-items-center justify-content-start flex-wrap text-muted">
608         {this.commentsButton}
609         {canShare() && (
610           <button
611             className="btn btn-sm btn-animate text-muted py-0"
612             onClick={linkEvent(this, this.handleShare)}
613             type="button"
614           >
615             <Icon icon="share" inline />
616           </button>
617         )}
618         {!post.local && (
619           <a
620             className="btn btn-sm btn-animate text-muted py-0"
621             title={I18NextService.i18n.t("link")}
622             href={post.ap_id}
623           >
624             <Icon icon="fedilink" inline />
625           </a>
626         )}
627         {mobile && !this.props.viewOnly && (
628           <VoteButtonsCompact
629             voteContentType={VoteContentType.Post}
630             id={this.postView.post.id}
631             onVote={this.props.onPostVote}
632             enableDownvotes={this.props.enableDownvotes}
633             counts={this.postView.counts}
634             my_vote={this.postView.my_vote}
635           />
636         )}
637         {UserService.Instance.myUserInfo &&
638           !this.props.viewOnly &&
639           this.postActions()}
640       </div>
641     );
642   }
643
644   showPreviewButton() {
645     const post_view = this.postView;
646     const body = post_view.post.body;
647
648     return (
649       <button
650         className="btn btn-sm btn-animate text-muted py-0"
651         data-tippy-content={body && mdNoImages.render(body)}
652         data-tippy-allowHtml={true}
653         onClick={linkEvent(this, this.handleShowBody)}
654       >
655         <Icon
656           icon="book-open"
657           classes={classNames("icon-inline me-1", {
658             "text-success": this.state.showBody,
659           })}
660         />
661       </button>
662     );
663   }
664
665   postActions() {
666     // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
667     // Possible enhancement: Make each button a component.
668     const post_view = this.postView;
669     const post = post_view.post;
670
671     return (
672       <>
673         {this.saveButton}
674         {this.crossPostButton}
675
676         {/**
677          * If there is a URL, or if the post has a body and we were told not to
678          * show the body, show the MetadataCard/body toggle.
679          */}
680         {(post.url || (post.body && !this.props.showBody)) &&
681           this.showPreviewButton()}
682
683         {this.showBody && post_view.post.body && this.viewSourceButton}
684
685         <div className="dropdown">
686           <button
687             className="btn btn-sm btn-animate text-muted py-0 dropdown-toggle"
688             onClick={linkEvent(this, this.handleShowAdvanced)}
689             data-tippy-content={I18NextService.i18n.t("more")}
690             data-bs-toggle="dropdown"
691             aria-expanded="false"
692             aria-controls={`advancedButtonsDropdown${post.id}`}
693             aria-label={I18NextService.i18n.t("more")}
694           >
695             <Icon icon="more-vertical" inline />
696           </button>
697
698           <ul
699             className="dropdown-menu"
700             id={`advancedButtonsDropdown${post.id}`}
701           >
702             {!this.myPost ? (
703               <>
704                 <li>{this.reportButton}</li>
705                 <li>{this.blockButton}</li>
706               </>
707             ) : (
708               <>
709                 <li>{this.editButton}</li>
710                 <li>{this.deleteButton}</li>
711               </>
712             )}
713
714             {/* Any mod can do these, not limited to hierarchy*/}
715             {(amMod(this.props.moderators) || amAdmin()) && (
716               <>
717                 <li>
718                   <hr className="dropdown-divider" />
719                 </li>
720                 <li>{this.lockButton}</li>
721                 {this.featureButtons}
722               </>
723             )}
724
725             {(this.canMod_ || this.canAdmin_) && (
726               <li>{this.modRemoveButton}</li>
727             )}
728           </ul>
729         </div>
730       </>
731     );
732   }
733
734   get commentsButton() {
735     const post_view = this.postView;
736     const title = I18NextService.i18n.t("number_of_comments", {
737       count: Number(post_view.counts.comments),
738       formattedCount: Number(post_view.counts.comments),
739     });
740
741     return (
742       <Link
743         className="btn btn-link btn-sm text-muted ps-0"
744         title={title}
745         to={`/post/${post_view.post.id}?scrollToComments=true`}
746         data-tippy-content={title}
747       >
748         <Icon icon="message-square" classes="me-1" inline />
749         {post_view.counts.comments}
750         {this.unreadCount && (
751           <>
752             {" "}
753             <span className="fst-italic">
754               ({this.unreadCount} {I18NextService.i18n.t("new")})
755             </span>
756           </>
757         )}
758       </Link>
759     );
760   }
761
762   get unreadCount(): number | undefined {
763     const pv = this.postView;
764     return pv.unread_comments == pv.counts.comments || pv.unread_comments == 0
765       ? undefined
766       : pv.unread_comments;
767   }
768
769   get saveButton() {
770     const saved = this.postView.saved;
771     const label = saved
772       ? I18NextService.i18n.t("unsave")
773       : I18NextService.i18n.t("save");
774     return (
775       <button
776         className="btn btn-sm btn-animate text-muted py-0"
777         onClick={linkEvent(this, this.handleSavePostClick)}
778         data-tippy-content={label}
779         aria-label={label}
780       >
781         {this.state.saveLoading ? (
782           <Spinner />
783         ) : (
784           <Icon
785             icon="star"
786             classes={classNames({ "text-warning": saved })}
787             inline
788           />
789         )}
790       </button>
791     );
792   }
793
794   get crossPostButton() {
795     return (
796       <Link
797         className="btn btn-sm btn-animate text-muted py-0"
798         to={{
799           /* Empty string properties are required to satisfy type*/
800           pathname: "/create_post",
801           state: { ...this.crossPostParams },
802           hash: "",
803           key: "",
804           search: "",
805         }}
806         title={I18NextService.i18n.t("cross_post")}
807         data-tippy-content={I18NextService.i18n.t("cross_post")}
808         aria-label={I18NextService.i18n.t("cross_post")}
809       >
810         <Icon icon="copy" inline />
811       </Link>
812     );
813   }
814
815   get reportButton() {
816     return (
817       <button
818         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
819         onClick={linkEvent(this, this.handleShowReportDialog)}
820         aria-label={I18NextService.i18n.t("show_report_dialog")}
821       >
822         <Icon classes="me-1" icon="flag" inline />
823         {I18NextService.i18n.t("create_report")}
824       </button>
825     );
826   }
827
828   get blockButton() {
829     return (
830       <button
831         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
832         onClick={linkEvent(this, this.handleBlockPersonClick)}
833         aria-label={I18NextService.i18n.t("block_user")}
834       >
835         {this.state.blockLoading ? (
836           <Spinner />
837         ) : (
838           <Icon classes="me-1" icon="slash" inline />
839         )}
840         {I18NextService.i18n.t("block_user")}
841       </button>
842     );
843   }
844
845   get editButton() {
846     return (
847       <button
848         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
849         onClick={linkEvent(this, this.handleEditClick)}
850         aria-label={I18NextService.i18n.t("edit")}
851       >
852         <Icon classes="me-1" icon="edit" inline />
853         {I18NextService.i18n.t("edit")}
854       </button>
855     );
856   }
857
858   get deleteButton() {
859     const deleted = this.postView.post.deleted;
860     const label = !deleted
861       ? I18NextService.i18n.t("delete")
862       : I18NextService.i18n.t("restore");
863     return (
864       <button
865         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
866         onClick={linkEvent(this, this.handleDeleteClick)}
867       >
868         {this.state.deleteLoading ? (
869           <Spinner />
870         ) : (
871           <>
872             <Icon
873               icon="trash"
874               classes={classNames("me-1", { "text-danger": deleted })}
875               inline
876             />
877             {label}
878           </>
879         )}
880       </button>
881     );
882   }
883
884   get viewSourceButton() {
885     return (
886       <button
887         className="btn btn-sm btn-animate text-muted py-0"
888         onClick={linkEvent(this, this.handleViewSource)}
889         data-tippy-content={I18NextService.i18n.t("view_source")}
890         aria-label={I18NextService.i18n.t("view_source")}
891       >
892         <Icon
893           icon="file-text"
894           classes={classNames({ "text-success": this.state.viewSource })}
895           inline
896         />
897       </button>
898     );
899   }
900
901   get lockButton() {
902     const locked = this.postView.post.locked;
903     const label = locked
904       ? I18NextService.i18n.t("unlock")
905       : I18NextService.i18n.t("lock");
906     return (
907       <button
908         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
909         onClick={linkEvent(this, this.handleModLock)}
910         aria-label={label}
911       >
912         {this.state.lockLoading ? (
913           <Spinner />
914         ) : (
915           <>
916             <Icon
917               icon="lock"
918               classes={classNames("me-1", { "text-danger": locked })}
919               inline
920             />
921             {capitalizeFirstLetter(label)}
922           </>
923         )}
924       </button>
925     );
926   }
927
928   get featureButtons() {
929     const featuredCommunity = this.postView.post.featured_community;
930     const labelCommunity = featuredCommunity
931       ? I18NextService.i18n.t("unfeature_from_community")
932       : I18NextService.i18n.t("feature_in_community");
933
934     const featuredLocal = this.postView.post.featured_local;
935     const labelLocal = featuredLocal
936       ? I18NextService.i18n.t("unfeature_from_local")
937       : I18NextService.i18n.t("feature_in_local");
938     return (
939       <>
940         <li>
941           <button
942             className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
943             onClick={linkEvent(this, this.handleModFeaturePostCommunity)}
944             data-tippy-content={labelCommunity}
945             aria-label={labelCommunity}
946           >
947             {this.state.featureCommunityLoading ? (
948               <Spinner />
949             ) : (
950               <>
951                 <Icon
952                   icon="pin"
953                   classes={classNames("me-1", {
954                     "text-success": featuredCommunity,
955                   })}
956                   inline
957                 />
958                 {I18NextService.i18n.t("community")}
959               </>
960             )}
961           </button>
962         </li>
963         <li>
964           {amAdmin() && (
965             <button
966               className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
967               onClick={linkEvent(this, this.handleModFeaturePostLocal)}
968               data-tippy-content={labelLocal}
969               aria-label={labelLocal}
970             >
971               {this.state.featureLocalLoading ? (
972                 <Spinner />
973               ) : (
974                 <>
975                   <Icon
976                     icon="pin"
977                     classes={classNames("me-1", {
978                       "text-success": featuredLocal,
979                     })}
980                     inline
981                   />
982                   {I18NextService.i18n.t("local")}
983                 </>
984               )}
985             </button>
986           )}
987         </li>
988       </>
989     );
990   }
991
992   get modRemoveButton() {
993     const removed = this.postView.post.removed;
994     return (
995       <button
996         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
997         onClick={linkEvent(
998           this,
999           !removed ? this.handleModRemoveShow : this.handleModRemoveSubmit
1000         )}
1001       >
1002         {/* TODO: Find an icon for this. */}
1003         {this.state.removeLoading ? (
1004           <Spinner />
1005         ) : !removed ? (
1006           I18NextService.i18n.t("remove")
1007         ) : (
1008           I18NextService.i18n.t("restore")
1009         )}
1010       </button>
1011     );
1012   }
1013
1014   /**
1015    * Mod/Admin actions to be taken against the author.
1016    */
1017   userActionsLine() {
1018     // TODO: make nicer
1019     const post_view = this.postView;
1020     return (
1021       this.state.showAdvanced && (
1022         <div className="mt-3">
1023           {this.canMod_ && (
1024             <>
1025               {!this.creatorIsMod_ &&
1026                 (!post_view.creator_banned_from_community ? (
1027                   <button
1028                     className="btn btn-link btn-animate text-muted py-0"
1029                     onClick={linkEvent(
1030                       this,
1031                       this.handleModBanFromCommunityShow
1032                     )}
1033                     aria-label={I18NextService.i18n.t("ban_from_community")}
1034                   >
1035                     {I18NextService.i18n.t("ban_from_community")}
1036                   </button>
1037                 ) : (
1038                   <button
1039                     className="btn btn-link btn-animate text-muted py-0"
1040                     onClick={linkEvent(
1041                       this,
1042                       this.handleModBanFromCommunitySubmit
1043                     )}
1044                     aria-label={I18NextService.i18n.t("unban")}
1045                   >
1046                     {this.state.banLoading ? (
1047                       <Spinner />
1048                     ) : (
1049                       I18NextService.i18n.t("unban")
1050                     )}
1051                   </button>
1052                 ))}
1053               {!post_view.creator_banned_from_community && (
1054                 <button
1055                   className="btn btn-link btn-animate text-muted py-0"
1056                   onClick={linkEvent(this, this.handleAddModToCommunity)}
1057                   aria-label={
1058                     this.creatorIsMod_
1059                       ? I18NextService.i18n.t("remove_as_mod")
1060                       : I18NextService.i18n.t("appoint_as_mod")
1061                   }
1062                 >
1063                   {this.state.addModLoading ? (
1064                     <Spinner />
1065                   ) : this.creatorIsMod_ ? (
1066                     I18NextService.i18n.t("remove_as_mod")
1067                   ) : (
1068                     I18NextService.i18n.t("appoint_as_mod")
1069                   )}
1070                 </button>
1071               )}
1072             </>
1073           )}
1074           {/* Community creators and admins can transfer community to another mod */}
1075           {(amCommunityCreator(post_view.creator.id, this.props.moderators) ||
1076             this.canAdmin_) &&
1077             this.creatorIsMod_ &&
1078             (!this.state.showConfirmTransferCommunity ? (
1079               <button
1080                 className="btn btn-link btn-animate text-muted py-0"
1081                 onClick={linkEvent(
1082                   this,
1083                   this.handleShowConfirmTransferCommunity
1084                 )}
1085                 aria-label={I18NextService.i18n.t("transfer_community")}
1086               >
1087                 {I18NextService.i18n.t("transfer_community")}
1088               </button>
1089             ) : (
1090               <>
1091                 <button
1092                   className="d-inline-block me-1 btn btn-link btn-animate text-muted py-0"
1093                   aria-label={I18NextService.i18n.t("are_you_sure")}
1094                 >
1095                   {I18NextService.i18n.t("are_you_sure")}
1096                 </button>
1097                 <button
1098                   className="btn btn-link btn-animate text-muted py-0 d-inline-block me-1"
1099                   aria-label={I18NextService.i18n.t("yes")}
1100                   onClick={linkEvent(this, this.handleTransferCommunity)}
1101                 >
1102                   {this.state.transferLoading ? (
1103                     <Spinner />
1104                   ) : (
1105                     I18NextService.i18n.t("yes")
1106                   )}
1107                 </button>
1108                 <button
1109                   className="btn btn-link btn-animate text-muted py-0 d-inline-block"
1110                   onClick={linkEvent(
1111                     this,
1112                     this.handleCancelShowConfirmTransferCommunity
1113                   )}
1114                   aria-label={I18NextService.i18n.t("no")}
1115                 >
1116                   {I18NextService.i18n.t("no")}
1117                 </button>
1118               </>
1119             ))}
1120           {/* Admins can ban from all, and appoint other admins */}
1121           {this.canAdmin_ && (
1122             <>
1123               {!this.creatorIsAdmin_ && (
1124                 <>
1125                   {!isBanned(post_view.creator) ? (
1126                     <button
1127                       className="btn btn-link btn-animate text-muted py-0"
1128                       onClick={linkEvent(this, this.handleModBanShow)}
1129                       aria-label={I18NextService.i18n.t("ban_from_site")}
1130                     >
1131                       {I18NextService.i18n.t("ban_from_site")}
1132                     </button>
1133                   ) : (
1134                     <button
1135                       className="btn btn-link btn-animate text-muted py-0"
1136                       onClick={linkEvent(this, this.handleModBanSubmit)}
1137                       aria-label={I18NextService.i18n.t("unban_from_site")}
1138                     >
1139                       {this.state.banLoading ? (
1140                         <Spinner />
1141                       ) : (
1142                         I18NextService.i18n.t("unban_from_site")
1143                       )}
1144                     </button>
1145                   )}
1146                   <button
1147                     className="btn btn-link btn-animate text-muted py-0"
1148                     onClick={linkEvent(this, this.handlePurgePersonShow)}
1149                     aria-label={I18NextService.i18n.t("purge_user")}
1150                   >
1151                     {I18NextService.i18n.t("purge_user")}
1152                   </button>
1153                   <button
1154                     className="btn btn-link btn-animate text-muted py-0"
1155                     onClick={linkEvent(this, this.handlePurgePostShow)}
1156                     aria-label={I18NextService.i18n.t("purge_post")}
1157                   >
1158                     {I18NextService.i18n.t("purge_post")}
1159                   </button>
1160                 </>
1161               )}
1162               {!isBanned(post_view.creator) && post_view.creator.local && (
1163                 <button
1164                   className="btn btn-link btn-animate text-muted py-0"
1165                   onClick={linkEvent(this, this.handleAddAdmin)}
1166                   aria-label={
1167                     this.creatorIsAdmin_
1168                       ? I18NextService.i18n.t("remove_as_admin")
1169                       : I18NextService.i18n.t("appoint_as_admin")
1170                   }
1171                 >
1172                   {this.state.addAdminLoading ? (
1173                     <Spinner />
1174                   ) : this.creatorIsAdmin_ ? (
1175                     I18NextService.i18n.t("remove_as_admin")
1176                   ) : (
1177                     I18NextService.i18n.t("appoint_as_admin")
1178                   )}
1179                 </button>
1180               )}
1181             </>
1182           )}
1183         </div>
1184       )
1185     );
1186   }
1187
1188   removeAndBanDialogs() {
1189     const post = this.postView;
1190     const purgeTypeText =
1191       this.state.purgeType == PurgeType.Post
1192         ? I18NextService.i18n.t("purge_post")
1193         : `${I18NextService.i18n.t("purge")} ${post.creator.name}`;
1194     return (
1195       <>
1196         {this.state.showRemoveDialog && (
1197           <form
1198             className="form-inline"
1199             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
1200           >
1201             <label
1202               className="visually-hidden"
1203               htmlFor="post-listing-remove-reason"
1204             >
1205               {I18NextService.i18n.t("reason")}
1206             </label>
1207             <input
1208               type="text"
1209               id="post-listing-remove-reason"
1210               className="form-control me-2"
1211               placeholder={I18NextService.i18n.t("reason")}
1212               value={this.state.removeReason}
1213               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
1214             />
1215             <button
1216               type="submit"
1217               className="btn btn-secondary"
1218               aria-label={I18NextService.i18n.t("remove_post")}
1219             >
1220               {this.state.removeLoading ? (
1221                 <Spinner />
1222               ) : (
1223                 I18NextService.i18n.t("remove_post")
1224               )}
1225             </button>
1226           </form>
1227         )}
1228         {this.state.showBanDialog && (
1229           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
1230             <div className="mb-3 row col-12">
1231               <label
1232                 className="col-form-label"
1233                 htmlFor="post-listing-ban-reason"
1234               >
1235                 {I18NextService.i18n.t("reason")}
1236               </label>
1237               <input
1238                 type="text"
1239                 id="post-listing-ban-reason"
1240                 className="form-control me-2"
1241                 placeholder={I18NextService.i18n.t("reason")}
1242                 value={this.state.banReason}
1243                 onInput={linkEvent(this, this.handleModBanReasonChange)}
1244               />
1245               <label className="col-form-label" htmlFor={`mod-ban-expires`}>
1246                 {I18NextService.i18n.t("expires")}
1247               </label>
1248               <input
1249                 type="number"
1250                 id={`mod-ban-expires`}
1251                 className="form-control me-2"
1252                 placeholder={I18NextService.i18n.t("number_of_days")}
1253                 value={this.state.banExpireDays}
1254                 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
1255               />
1256               <div className="input-group mb-3">
1257                 <div className="form-check">
1258                   <input
1259                     className="form-check-input"
1260                     id="mod-ban-remove-data"
1261                     type="checkbox"
1262                     checked={this.state.removeData}
1263                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
1264                   />
1265                   <label
1266                     className="form-check-label"
1267                     htmlFor="mod-ban-remove-data"
1268                     title={I18NextService.i18n.t("remove_content_more")}
1269                   >
1270                     {I18NextService.i18n.t("remove_content")}
1271                   </label>
1272                 </div>
1273               </div>
1274             </div>
1275             {/* TODO hold off on expires until later */}
1276             {/* <div class="mb-3 row"> */}
1277             {/*   <label class="col-form-label">Expires</label> */}
1278             {/*   <input type="date" class="form-control me-2" placeholder={I18NextService.i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1279             {/* </div> */}
1280             <div className="mb-3 row">
1281               <button
1282                 type="submit"
1283                 className="btn btn-secondary"
1284                 aria-label={I18NextService.i18n.t("ban")}
1285               >
1286                 {this.state.banLoading ? (
1287                   <Spinner />
1288                 ) : (
1289                   <span>
1290                     {I18NextService.i18n.t("ban")} {post.creator.name}
1291                   </span>
1292                 )}
1293               </button>
1294             </div>
1295           </form>
1296         )}
1297         {this.state.showReportDialog && (
1298           <form
1299             className="form-inline"
1300             onSubmit={linkEvent(this, this.handleReportSubmit)}
1301           >
1302             <label className="visually-hidden" htmlFor="post-report-reason">
1303               {I18NextService.i18n.t("reason")}
1304             </label>
1305             <input
1306               type="text"
1307               id="post-report-reason"
1308               className="form-control me-2"
1309               placeholder={I18NextService.i18n.t("reason")}
1310               required
1311               value={this.state.reportReason}
1312               onInput={linkEvent(this, this.handleReportReasonChange)}
1313             />
1314             <button
1315               type="submit"
1316               className="btn btn-secondary"
1317               aria-label={I18NextService.i18n.t("create_report")}
1318             >
1319               {this.state.reportLoading ? (
1320                 <Spinner />
1321               ) : (
1322                 I18NextService.i18n.t("create_report")
1323               )}
1324             </button>
1325           </form>
1326         )}
1327         {this.state.showPurgeDialog && (
1328           <form
1329             className="form-inline"
1330             onSubmit={linkEvent(this, this.handlePurgeSubmit)}
1331           >
1332             <PurgeWarning />
1333             <label className="visually-hidden" htmlFor="purge-reason">
1334               {I18NextService.i18n.t("reason")}
1335             </label>
1336             <input
1337               type="text"
1338               id="purge-reason"
1339               className="form-control me-2"
1340               placeholder={I18NextService.i18n.t("reason")}
1341               value={this.state.purgeReason}
1342               onInput={linkEvent(this, this.handlePurgeReasonChange)}
1343             />
1344             {this.state.purgeLoading ? (
1345               <Spinner />
1346             ) : (
1347               <button
1348                 type="submit"
1349                 className="btn btn-secondary"
1350                 aria-label={purgeTypeText}
1351               >
1352                 {this.state.purgeLoading ? <Spinner /> : { purgeTypeText }}
1353               </button>
1354             )}
1355           </form>
1356         )}
1357       </>
1358     );
1359   }
1360
1361   mobileThumbnail() {
1362     const post = this.postView.post;
1363     return post.thumbnail_url || (post.url && isImage(post.url)) ? (
1364       <div className="row">
1365         <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
1366           {this.postTitleLine()}
1367         </div>
1368         <div className="col-4">
1369           {/* Post thumbnail */}
1370           {!this.state.imageExpanded && this.thumbnail()}
1371         </div>
1372       </div>
1373     ) : (
1374       this.postTitleLine()
1375     );
1376   }
1377
1378   showBodyPreview() {
1379     const { body, id } = this.postView.post;
1380
1381     return !this.showBody && body ? (
1382       <Link className="text-body mt-2 d-block" to={`/post/${id}`}>
1383         <div className="md-div mb-1 preview-lines">{body}</div>
1384       </Link>
1385     ) : (
1386       <></>
1387     );
1388   }
1389
1390   listing() {
1391     return (
1392       <>
1393         {/* The mobile view*/}
1394         <div className="d-block d-sm-none">
1395           <article className="row post-container">
1396             <div className="col-12">
1397               {this.createdLine()}
1398
1399               {/* If it has a thumbnail, do a right aligned thumbnail */}
1400               {this.mobileThumbnail()}
1401
1402               {/* Show a preview of the post body */}
1403               {this.showBodyPreview()}
1404
1405               {this.commentsLine(true)}
1406               {this.userActionsLine()}
1407               {this.duplicatesLine()}
1408               {this.removeAndBanDialogs()}
1409             </div>
1410           </article>
1411         </div>
1412
1413         {/* The larger view*/}
1414         <div className="d-none d-sm-block">
1415           <article className="row post-container">
1416             {!this.props.viewOnly && (
1417               <VoteButtons
1418                 voteContentType={VoteContentType.Post}
1419                 id={this.postView.post.id}
1420                 onVote={this.props.onPostVote}
1421                 enableDownvotes={this.props.enableDownvotes}
1422                 counts={this.postView.counts}
1423                 my_vote={this.postView.my_vote}
1424               />
1425             )}
1426             <div className="col-sm-2 pe-0 post-media">
1427               <div className="">{this.thumbnail()}</div>
1428             </div>
1429             <div className="col-12 col-sm-9">
1430               <div className="row">
1431                 <div className="col-12">
1432                   {this.postTitleLine()}
1433                   {this.createdLine()}
1434                   {this.showBodyPreview()}
1435                   {this.commentsLine()}
1436                   {this.duplicatesLine()}
1437                   {this.userActionsLine()}
1438                   {this.removeAndBanDialogs()}
1439                 </div>
1440               </div>
1441             </div>
1442           </article>
1443         </div>
1444       </>
1445     );
1446   }
1447
1448   private get myPost(): boolean {
1449     return (
1450       this.postView.creator.id ==
1451       UserService.Instance.myUserInfo?.local_user_view.person.id
1452     );
1453   }
1454   handleEditClick(i: PostListing) {
1455     i.setState({ showEdit: true });
1456   }
1457
1458   handleEditCancel() {
1459     this.setState({ showEdit: false });
1460   }
1461
1462   // The actual editing is done in the receive for post
1463   handleEditPost(form: EditPost) {
1464     this.setState({ showEdit: false });
1465     this.props.onPostEdit(form);
1466   }
1467
1468   handleShare(i: PostListing) {
1469     const { name, body, id } = i.props.post_view.post;
1470     share({
1471       title: name,
1472       text: body?.slice(0, 50),
1473       url: `${getHttpBase()}/post/${id}`,
1474     });
1475   }
1476
1477   handleShowReportDialog(i: PostListing) {
1478     i.setState({ showReportDialog: !i.state.showReportDialog });
1479   }
1480
1481   handleReportReasonChange(i: PostListing, event: any) {
1482     i.setState({ reportReason: event.target.value });
1483   }
1484
1485   handleReportSubmit(i: PostListing, event: any) {
1486     event.preventDefault();
1487     i.setState({ reportLoading: true });
1488     i.props.onPostReport({
1489       post_id: i.postView.post.id,
1490       reason: i.state.reportReason ?? "",
1491       auth: myAuthRequired(),
1492     });
1493   }
1494
1495   handleBlockPersonClick(i: PostListing) {
1496     i.setState({ blockLoading: true });
1497     i.props.onBlockPerson({
1498       person_id: i.postView.creator.id,
1499       block: true,
1500       auth: myAuthRequired(),
1501     });
1502   }
1503
1504   handleDeleteClick(i: PostListing) {
1505     i.setState({ deleteLoading: true });
1506     i.props.onDeletePost({
1507       post_id: i.postView.post.id,
1508       deleted: !i.postView.post.deleted,
1509       auth: myAuthRequired(),
1510     });
1511   }
1512
1513   handleSavePostClick(i: PostListing) {
1514     i.setState({ saveLoading: true });
1515     i.props.onSavePost({
1516       post_id: i.postView.post.id,
1517       save: !i.postView.saved,
1518       auth: myAuthRequired(),
1519     });
1520   }
1521
1522   get crossPostParams(): PostFormParams {
1523     const queryParams: PostFormParams = {};
1524     const { name, url } = this.postView.post;
1525
1526     queryParams.name = name;
1527
1528     if (url) {
1529       queryParams.url = url;
1530     }
1531
1532     const crossPostBody = this.crossPostBody();
1533     if (crossPostBody) {
1534       queryParams.body = crossPostBody;
1535     }
1536
1537     return queryParams;
1538   }
1539
1540   crossPostBody(): string | undefined {
1541     const post = this.postView.post;
1542     const body = post.body;
1543
1544     return body
1545       ? `${I18NextService.i18n.t("cross_posted_from")} ${
1546           post.ap_id
1547         }\n\n${body.replace(/^/gm, "> ")}`
1548       : undefined;
1549   }
1550
1551   get showBody(): boolean {
1552     return this.props.showBody || this.state.showBody;
1553   }
1554
1555   handleModRemoveShow(i: PostListing) {
1556     i.setState({
1557       showRemoveDialog: !i.state.showRemoveDialog,
1558       showBanDialog: false,
1559     });
1560   }
1561
1562   handleModRemoveReasonChange(i: PostListing, event: any) {
1563     i.setState({ removeReason: event.target.value });
1564   }
1565
1566   handleModRemoveDataChange(i: PostListing, event: any) {
1567     i.setState({ removeData: event.target.checked });
1568   }
1569
1570   handleModRemoveSubmit(i: PostListing, event: any) {
1571     event.preventDefault();
1572     i.setState({ removeLoading: true });
1573     i.props.onRemovePost({
1574       post_id: i.postView.post.id,
1575       removed: !i.postView.post.removed,
1576       auth: myAuthRequired(),
1577     });
1578   }
1579
1580   handleModLock(i: PostListing) {
1581     i.setState({ lockLoading: true });
1582     i.props.onLockPost({
1583       post_id: i.postView.post.id,
1584       locked: !i.postView.post.locked,
1585       auth: myAuthRequired(),
1586     });
1587   }
1588
1589   handleModFeaturePostLocal(i: PostListing) {
1590     i.setState({ featureLocalLoading: true });
1591     i.props.onFeaturePost({
1592       post_id: i.postView.post.id,
1593       featured: !i.postView.post.featured_local,
1594       feature_type: "Local",
1595       auth: myAuthRequired(),
1596     });
1597   }
1598
1599   handleModFeaturePostCommunity(i: PostListing) {
1600     i.setState({ featureCommunityLoading: true });
1601     i.props.onFeaturePost({
1602       post_id: i.postView.post.id,
1603       featured: !i.postView.post.featured_community,
1604       feature_type: "Community",
1605       auth: myAuthRequired(),
1606     });
1607   }
1608
1609   handleModBanFromCommunityShow(i: PostListing) {
1610     i.setState({
1611       showBanDialog: true,
1612       banType: BanType.Community,
1613       showRemoveDialog: false,
1614     });
1615   }
1616
1617   handleModBanShow(i: PostListing) {
1618     i.setState({
1619       showBanDialog: true,
1620       banType: BanType.Site,
1621       showRemoveDialog: false,
1622     });
1623   }
1624
1625   handlePurgePersonShow(i: PostListing) {
1626     i.setState({
1627       showPurgeDialog: true,
1628       purgeType: PurgeType.Person,
1629       showRemoveDialog: false,
1630     });
1631   }
1632
1633   handlePurgePostShow(i: PostListing) {
1634     i.setState({
1635       showPurgeDialog: true,
1636       purgeType: PurgeType.Post,
1637       showRemoveDialog: false,
1638     });
1639   }
1640
1641   handlePurgeReasonChange(i: PostListing, event: any) {
1642     i.setState({ purgeReason: event.target.value });
1643   }
1644
1645   handlePurgeSubmit(i: PostListing, event: any) {
1646     event.preventDefault();
1647     i.setState({ purgeLoading: true });
1648     if (i.state.purgeType == PurgeType.Person) {
1649       i.props.onPurgePerson({
1650         person_id: i.postView.creator.id,
1651         reason: i.state.purgeReason,
1652         auth: myAuthRequired(),
1653       });
1654     } else if (i.state.purgeType == PurgeType.Post) {
1655       i.props.onPurgePost({
1656         post_id: i.postView.post.id,
1657         reason: i.state.purgeReason,
1658         auth: myAuthRequired(),
1659       });
1660     }
1661   }
1662
1663   handleModBanReasonChange(i: PostListing, event: any) {
1664     i.setState({ banReason: event.target.value });
1665   }
1666
1667   handleModBanExpireDaysChange(i: PostListing, event: any) {
1668     i.setState({ banExpireDays: event.target.value });
1669   }
1670
1671   handleModBanFromCommunitySubmit(i: PostListing, event: any) {
1672     i.setState({ banType: BanType.Community });
1673     i.handleModBanBothSubmit(i, event);
1674   }
1675
1676   handleModBanSubmit(i: PostListing, event: any) {
1677     i.setState({ banType: BanType.Site });
1678     i.handleModBanBothSubmit(i, event);
1679   }
1680
1681   handleModBanBothSubmit(i: PostListing, event: any) {
1682     event.preventDefault();
1683     i.setState({ banLoading: true });
1684
1685     const ban = !i.props.post_view.creator_banned_from_community;
1686     // If its an unban, restore all their data
1687     if (ban == false) {
1688       i.setState({ removeData: false });
1689     }
1690     const person_id = i.props.post_view.creator.id;
1691     const remove_data = i.state.removeData;
1692     const reason = i.state.banReason;
1693     const expires = futureDaysToUnixTime(i.state.banExpireDays);
1694
1695     if (i.state.banType == BanType.Community) {
1696       const community_id = i.postView.community.id;
1697       i.props.onBanPersonFromCommunity({
1698         community_id,
1699         person_id,
1700         ban,
1701         remove_data,
1702         reason,
1703         expires,
1704         auth: myAuthRequired(),
1705       });
1706     } else {
1707       i.props.onBanPerson({
1708         person_id,
1709         ban,
1710         remove_data,
1711         reason,
1712         expires,
1713         auth: myAuthRequired(),
1714       });
1715     }
1716   }
1717
1718   handleAddModToCommunity(i: PostListing) {
1719     i.setState({ addModLoading: true });
1720     i.props.onAddModToCommunity({
1721       community_id: i.postView.community.id,
1722       person_id: i.postView.creator.id,
1723       added: !i.creatorIsMod_,
1724       auth: myAuthRequired(),
1725     });
1726   }
1727
1728   handleAddAdmin(i: PostListing) {
1729     i.setState({ addAdminLoading: true });
1730     i.props.onAddAdmin({
1731       person_id: i.postView.creator.id,
1732       added: !i.creatorIsAdmin_,
1733       auth: myAuthRequired(),
1734     });
1735   }
1736
1737   handleShowConfirmTransferCommunity(i: PostListing) {
1738     i.setState({ showConfirmTransferCommunity: true });
1739   }
1740
1741   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1742     i.setState({ showConfirmTransferCommunity: false });
1743   }
1744
1745   handleTransferCommunity(i: PostListing) {
1746     i.setState({ transferLoading: true });
1747     i.props.onTransferCommunity({
1748       community_id: i.postView.community.id,
1749       person_id: i.postView.creator.id,
1750       auth: myAuthRequired(),
1751     });
1752   }
1753
1754   handleShowConfirmTransferSite(i: PostListing) {
1755     i.setState({ showConfirmTransferSite: true });
1756   }
1757
1758   handleCancelShowConfirmTransferSite(i: PostListing) {
1759     i.setState({ showConfirmTransferSite: false });
1760   }
1761
1762   handleImageExpandClick(i: PostListing, event: any) {
1763     event.preventDefault();
1764     i.setState({ imageExpanded: !i.state.imageExpanded });
1765     setupTippy();
1766   }
1767
1768   handleViewSource(i: PostListing) {
1769     i.setState({ viewSource: !i.state.viewSource });
1770   }
1771
1772   handleShowAdvanced(i: PostListing) {
1773     i.setState({ showAdvanced: !i.state.showAdvanced });
1774     setupTippy();
1775   }
1776
1777   handleShowMoreMobile(i: PostListing) {
1778     i.setState({
1779       showMoreMobile: !i.state.showMoreMobile,
1780       showAdvanced: !i.state.showAdvanced,
1781     });
1782     setupTippy();
1783   }
1784
1785   handleShowBody(i: PostListing) {
1786     i.setState({ showBody: !i.state.showBody });
1787     setupTippy();
1788   }
1789
1790   get pointsTippy(): string {
1791     const points = I18NextService.i18n.t("number_of_points", {
1792       count: Number(this.postView.counts.score),
1793       formattedCount: Number(this.postView.counts.score),
1794     });
1795
1796     const upvotes = I18NextService.i18n.t("number_of_upvotes", {
1797       count: Number(this.postView.counts.upvotes),
1798       formattedCount: Number(this.postView.counts.upvotes),
1799     });
1800
1801     const downvotes = I18NextService.i18n.t("number_of_downvotes", {
1802       count: Number(this.postView.counts.downvotes),
1803       formattedCount: Number(this.postView.counts.downvotes),
1804     });
1805
1806     return `${points} • ${upvotes} • ${downvotes}`;
1807   }
1808
1809   get canModOnSelf_(): boolean {
1810     return canMod(
1811       this.postView.creator.id,
1812       this.props.moderators,
1813       this.props.admins,
1814       undefined,
1815       true
1816     );
1817   }
1818
1819   get canMod_(): boolean {
1820     return canMod(
1821       this.postView.creator.id,
1822       this.props.moderators,
1823       this.props.admins
1824     );
1825   }
1826
1827   get canAdmin_(): boolean {
1828     return canAdmin(this.postView.creator.id, this.props.admins);
1829   }
1830
1831   get creatorIsMod_(): boolean {
1832     return isMod(this.postView.creator.id, this.props.moderators);
1833   }
1834
1835   get creatorIsAdmin_(): boolean {
1836     return isAdmin(this.postView.creator.id, this.props.admins);
1837   }
1838 }