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