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