]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post-listing.tsx
fix: Remove unused prop
[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             enableDownvotes={this.props.enableDownvotes}
604             counts={this.postView.counts}
605             my_vote={this.postView.my_vote}
606           />
607         )}
608         {UserService.Instance.myUserInfo &&
609           !this.props.viewOnly &&
610           this.postActions()}
611       </div>
612     );
613   }
614
615   showPreviewButton() {
616     const post_view = this.postView;
617     const body = post_view.post.body;
618
619     return (
620       <button
621         className="btn btn-sm btn-animate text-muted py-0"
622         data-tippy-content={body && mdNoImages.render(body)}
623         data-tippy-allowHtml={true}
624         onClick={linkEvent(this, this.handleShowBody)}
625       >
626         <Icon
627           icon="book-open"
628           classes={classNames("icon-inline me-1", {
629             "text-success": this.state.showBody,
630           })}
631         />
632       </button>
633     );
634   }
635
636   postActions() {
637     // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
638     // Possible enhancement: Make each button a component.
639     const post_view = this.postView;
640     const post = post_view.post;
641
642     return (
643       <>
644         {this.saveButton}
645         {this.crossPostButton}
646
647         {/**
648          * If there is a URL, or if the post has a body and we were told not to
649          * show the body, show the MetadataCard/body toggle.
650          */}
651         {(post.url || (post.body && !this.props.showBody)) &&
652           this.showPreviewButton()}
653
654         {this.showBody && post_view.post.body && this.viewSourceButton}
655
656         <div className="dropdown">
657           <button
658             className="btn btn-sm btn-animate text-muted py-0 dropdown-toggle"
659             onClick={linkEvent(this, this.handleShowAdvanced)}
660             data-tippy-content={I18NextService.i18n.t("more")}
661             data-bs-toggle="dropdown"
662             aria-expanded="false"
663             aria-controls="advancedButtonsDropdown"
664             aria-label={I18NextService.i18n.t("more")}
665           >
666             <Icon icon="more-vertical" inline />
667           </button>
668
669           <ul className="dropdown-menu" id="advancedButtonsDropdown">
670             {!this.myPost ? (
671               <>
672                 <li>{this.reportButton}</li>
673                 <li>{this.blockButton}</li>
674               </>
675             ) : (
676               <>
677                 <li>{this.editButton}</li>
678                 <li>{this.deleteButton}</li>
679               </>
680             )}
681
682             {/* Any mod can do these, not limited to hierarchy*/}
683             {(amMod(this.props.moderators) || amAdmin()) && (
684               <>
685                 <li>
686                   <hr className="dropdown-divider" />
687                 </li>
688                 <li>{this.lockButton}</li>
689                 {this.featureButtons}
690               </>
691             )}
692
693             {(this.canMod_ || this.canAdmin_) && (
694               <li>{this.modRemoveButton}</li>
695             )}
696           </ul>
697         </div>
698       </>
699     );
700   }
701
702   get commentsButton() {
703     const post_view = this.postView;
704     const title = I18NextService.i18n.t("number_of_comments", {
705       count: Number(post_view.counts.comments),
706       formattedCount: Number(post_view.counts.comments),
707     });
708
709     return (
710       <Link
711         className="btn btn-link btn-sm text-muted ps-0"
712         title={title}
713         to={`/post/${post_view.post.id}?scrollToComments=true`}
714         data-tippy-content={title}
715       >
716         <Icon icon="message-square" classes="me-1" inline />
717         {post_view.counts.comments}
718         {this.unreadCount && (
719           <span className="text-muted fst-italic">
720             ({this.unreadCount} {I18NextService.i18n.t("new")})
721           </span>
722         )}
723       </Link>
724     );
725   }
726
727   get unreadCount(): number | undefined {
728     const pv = this.postView;
729     return pv.unread_comments == pv.counts.comments || pv.unread_comments == 0
730       ? undefined
731       : pv.unread_comments;
732   }
733
734   get saveButton() {
735     const saved = this.postView.saved;
736     const label = saved
737       ? I18NextService.i18n.t("unsave")
738       : I18NextService.i18n.t("save");
739     return (
740       <button
741         className="btn btn-sm btn-animate text-muted py-0"
742         onClick={linkEvent(this, this.handleSavePostClick)}
743         data-tippy-content={label}
744         aria-label={label}
745       >
746         {this.state.saveLoading ? (
747           <Spinner />
748         ) : (
749           <Icon
750             icon="star"
751             classes={classNames({ "text-warning": saved })}
752             inline
753           />
754         )}
755       </button>
756     );
757   }
758
759   get crossPostButton() {
760     return (
761       <Link
762         className="btn btn-sm btn-animate text-muted py-0"
763         to={{
764           /* Empty string properties are required to satisfy type*/
765           pathname: "/create_post",
766           state: { ...this.crossPostParams },
767           hash: "",
768           key: "",
769           search: "",
770         }}
771         title={I18NextService.i18n.t("cross_post")}
772         data-tippy-content={I18NextService.i18n.t("cross_post")}
773         aria-label={I18NextService.i18n.t("cross_post")}
774       >
775         <Icon icon="copy" inline />
776       </Link>
777     );
778   }
779
780   get reportButton() {
781     return (
782       <button
783         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
784         onClick={linkEvent(this, this.handleShowReportDialog)}
785         aria-label={I18NextService.i18n.t("show_report_dialog")}
786       >
787         <Icon classes="me-1" icon="flag" inline />
788         {I18NextService.i18n.t("create_report")}
789       </button>
790     );
791   }
792
793   get blockButton() {
794     return (
795       <button
796         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
797         onClick={linkEvent(this, this.handleBlockPersonClick)}
798         aria-label={I18NextService.i18n.t("block_user")}
799       >
800         {this.state.blockLoading ? (
801           <Spinner />
802         ) : (
803           <Icon classes="me-1" icon="slash" inline />
804         )}
805         {I18NextService.i18n.t("block_user")}
806       </button>
807     );
808   }
809
810   get editButton() {
811     return (
812       <button
813         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
814         onClick={linkEvent(this, this.handleEditClick)}
815         aria-label={I18NextService.i18n.t("edit")}
816       >
817         <Icon classes="me-1" icon="edit" inline />
818         {I18NextService.i18n.t("edit")}
819       </button>
820     );
821   }
822
823   get deleteButton() {
824     const deleted = this.postView.post.deleted;
825     const label = !deleted
826       ? I18NextService.i18n.t("delete")
827       : I18NextService.i18n.t("restore");
828     return (
829       <button
830         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
831         onClick={linkEvent(this, this.handleDeleteClick)}
832       >
833         {this.state.deleteLoading ? (
834           <Spinner />
835         ) : (
836           <>
837             <Icon
838               icon="trash"
839               classes={classNames("me-1", { "text-danger": deleted })}
840               inline
841             />
842             {label}
843           </>
844         )}
845       </button>
846     );
847   }
848
849   get viewSourceButton() {
850     return (
851       <button
852         className="btn btn-sm btn-animate text-muted py-0"
853         onClick={linkEvent(this, this.handleViewSource)}
854         data-tippy-content={I18NextService.i18n.t("view_source")}
855         aria-label={I18NextService.i18n.t("view_source")}
856       >
857         <Icon
858           icon="file-text"
859           classes={classNames({ "text-success": this.state.viewSource })}
860           inline
861         />
862       </button>
863     );
864   }
865
866   get lockButton() {
867     const locked = this.postView.post.locked;
868     const label = locked
869       ? I18NextService.i18n.t("unlock")
870       : I18NextService.i18n.t("lock");
871     return (
872       <button
873         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
874         onClick={linkEvent(this, this.handleModLock)}
875         aria-label={label}
876       >
877         {this.state.lockLoading ? (
878           <Spinner />
879         ) : (
880           <>
881             <Icon
882               icon="lock"
883               classes={classNames("me-1", { "text-danger": locked })}
884               inline
885             />
886             {capitalizeFirstLetter(label)}
887           </>
888         )}
889       </button>
890     );
891   }
892
893   get featureButtons() {
894     const featuredCommunity = this.postView.post.featured_community;
895     const labelCommunity = featuredCommunity
896       ? I18NextService.i18n.t("unfeature_from_community")
897       : I18NextService.i18n.t("feature_in_community");
898
899     const featuredLocal = this.postView.post.featured_local;
900     const labelLocal = featuredLocal
901       ? I18NextService.i18n.t("unfeature_from_local")
902       : I18NextService.i18n.t("feature_in_local");
903     return (
904       <>
905         <li>
906           <button
907             className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
908             onClick={linkEvent(this, this.handleModFeaturePostCommunity)}
909             data-tippy-content={labelCommunity}
910             aria-label={labelCommunity}
911           >
912             {this.state.featureCommunityLoading ? (
913               <Spinner />
914             ) : (
915               <>
916                 <Icon
917                   icon="pin"
918                   classes={classNames("me-1", {
919                     "text-success": featuredCommunity,
920                   })}
921                   inline
922                 />
923                 {I18NextService.i18n.t("community")}
924               </>
925             )}
926           </button>
927         </li>
928         <li>
929           {amAdmin() && (
930             <button
931               className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
932               onClick={linkEvent(this, this.handleModFeaturePostLocal)}
933               data-tippy-content={labelLocal}
934               aria-label={labelLocal}
935             >
936               {this.state.featureLocalLoading ? (
937                 <Spinner />
938               ) : (
939                 <>
940                   <Icon
941                     icon="pin"
942                     classes={classNames("me-1", {
943                       "text-success": featuredLocal,
944                     })}
945                     inline
946                   />
947                   {I18NextService.i18n.t("local")}
948                 </>
949               )}
950             </button>
951           )}
952         </li>
953       </>
954     );
955   }
956
957   get modRemoveButton() {
958     const removed = this.postView.post.removed;
959     return (
960       <button
961         className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
962         onClick={linkEvent(
963           this,
964           !removed ? this.handleModRemoveShow : this.handleModRemoveSubmit
965         )}
966       >
967         {/* TODO: Find an icon for this. */}
968         {this.state.removeLoading ? (
969           <Spinner />
970         ) : !removed ? (
971           I18NextService.i18n.t("remove")
972         ) : (
973           I18NextService.i18n.t("restore")
974         )}
975       </button>
976     );
977   }
978
979   /**
980    * Mod/Admin actions to be taken against the author.
981    */
982   userActionsLine() {
983     // TODO: make nicer
984     const post_view = this.postView;
985     return (
986       this.state.showAdvanced && (
987         <div className="mt-3">
988           {this.canMod_ && (
989             <>
990               {!this.creatorIsMod_ &&
991                 (!post_view.creator_banned_from_community ? (
992                   <button
993                     className="btn btn-link btn-animate text-muted py-0"
994                     onClick={linkEvent(
995                       this,
996                       this.handleModBanFromCommunityShow
997                     )}
998                     aria-label={I18NextService.i18n.t("ban_from_community")}
999                   >
1000                     {I18NextService.i18n.t("ban_from_community")}
1001                   </button>
1002                 ) : (
1003                   <button
1004                     className="btn btn-link btn-animate text-muted py-0"
1005                     onClick={linkEvent(
1006                       this,
1007                       this.handleModBanFromCommunitySubmit
1008                     )}
1009                     aria-label={I18NextService.i18n.t("unban")}
1010                   >
1011                     {this.state.banLoading ? (
1012                       <Spinner />
1013                     ) : (
1014                       I18NextService.i18n.t("unban")
1015                     )}
1016                   </button>
1017                 ))}
1018               {!post_view.creator_banned_from_community && (
1019                 <button
1020                   className="btn btn-link btn-animate text-muted py-0"
1021                   onClick={linkEvent(this, this.handleAddModToCommunity)}
1022                   aria-label={
1023                     this.creatorIsMod_
1024                       ? I18NextService.i18n.t("remove_as_mod")
1025                       : I18NextService.i18n.t("appoint_as_mod")
1026                   }
1027                 >
1028                   {this.state.addModLoading ? (
1029                     <Spinner />
1030                   ) : this.creatorIsMod_ ? (
1031                     I18NextService.i18n.t("remove_as_mod")
1032                   ) : (
1033                     I18NextService.i18n.t("appoint_as_mod")
1034                   )}
1035                 </button>
1036               )}
1037             </>
1038           )}
1039           {/* Community creators and admins can transfer community to another mod */}
1040           {(amCommunityCreator(post_view.creator.id, this.props.moderators) ||
1041             this.canAdmin_) &&
1042             this.creatorIsMod_ &&
1043             (!this.state.showConfirmTransferCommunity ? (
1044               <button
1045                 className="btn btn-link btn-animate text-muted py-0"
1046                 onClick={linkEvent(
1047                   this,
1048                   this.handleShowConfirmTransferCommunity
1049                 )}
1050                 aria-label={I18NextService.i18n.t("transfer_community")}
1051               >
1052                 {I18NextService.i18n.t("transfer_community")}
1053               </button>
1054             ) : (
1055               <>
1056                 <button
1057                   className="d-inline-block me-1 btn btn-link btn-animate text-muted py-0"
1058                   aria-label={I18NextService.i18n.t("are_you_sure")}
1059                 >
1060                   {I18NextService.i18n.t("are_you_sure")}
1061                 </button>
1062                 <button
1063                   className="btn btn-link btn-animate text-muted py-0 d-inline-block me-1"
1064                   aria-label={I18NextService.i18n.t("yes")}
1065                   onClick={linkEvent(this, this.handleTransferCommunity)}
1066                 >
1067                   {this.state.transferLoading ? (
1068                     <Spinner />
1069                   ) : (
1070                     I18NextService.i18n.t("yes")
1071                   )}
1072                 </button>
1073                 <button
1074                   className="btn btn-link btn-animate text-muted py-0 d-inline-block"
1075                   onClick={linkEvent(
1076                     this,
1077                     this.handleCancelShowConfirmTransferCommunity
1078                   )}
1079                   aria-label={I18NextService.i18n.t("no")}
1080                 >
1081                   {I18NextService.i18n.t("no")}
1082                 </button>
1083               </>
1084             ))}
1085           {/* Admins can ban from all, and appoint other admins */}
1086           {this.canAdmin_ && (
1087             <>
1088               {!this.creatorIsAdmin_ && (
1089                 <>
1090                   {!isBanned(post_view.creator) ? (
1091                     <button
1092                       className="btn btn-link btn-animate text-muted py-0"
1093                       onClick={linkEvent(this, this.handleModBanShow)}
1094                       aria-label={I18NextService.i18n.t("ban_from_site")}
1095                     >
1096                       {I18NextService.i18n.t("ban_from_site")}
1097                     </button>
1098                   ) : (
1099                     <button
1100                       className="btn btn-link btn-animate text-muted py-0"
1101                       onClick={linkEvent(this, this.handleModBanSubmit)}
1102                       aria-label={I18NextService.i18n.t("unban_from_site")}
1103                     >
1104                       {this.state.banLoading ? (
1105                         <Spinner />
1106                       ) : (
1107                         I18NextService.i18n.t("unban_from_site")
1108                       )}
1109                     </button>
1110                   )}
1111                   <button
1112                     className="btn btn-link btn-animate text-muted py-0"
1113                     onClick={linkEvent(this, this.handlePurgePersonShow)}
1114                     aria-label={I18NextService.i18n.t("purge_user")}
1115                   >
1116                     {I18NextService.i18n.t("purge_user")}
1117                   </button>
1118                   <button
1119                     className="btn btn-link btn-animate text-muted py-0"
1120                     onClick={linkEvent(this, this.handlePurgePostShow)}
1121                     aria-label={I18NextService.i18n.t("purge_post")}
1122                   >
1123                     {I18NextService.i18n.t("purge_post")}
1124                   </button>
1125                 </>
1126               )}
1127               {!isBanned(post_view.creator) && post_view.creator.local && (
1128                 <button
1129                   className="btn btn-link btn-animate text-muted py-0"
1130                   onClick={linkEvent(this, this.handleAddAdmin)}
1131                   aria-label={
1132                     this.creatorIsAdmin_
1133                       ? I18NextService.i18n.t("remove_as_admin")
1134                       : I18NextService.i18n.t("appoint_as_admin")
1135                   }
1136                 >
1137                   {this.state.addAdminLoading ? (
1138                     <Spinner />
1139                   ) : this.creatorIsAdmin_ ? (
1140                     I18NextService.i18n.t("remove_as_admin")
1141                   ) : (
1142                     I18NextService.i18n.t("appoint_as_admin")
1143                   )}
1144                 </button>
1145               )}
1146             </>
1147           )}
1148         </div>
1149       )
1150     );
1151   }
1152
1153   removeAndBanDialogs() {
1154     const post = this.postView;
1155     const purgeTypeText =
1156       this.state.purgeType == PurgeType.Post
1157         ? I18NextService.i18n.t("purge_post")
1158         : `${I18NextService.i18n.t("purge")} ${post.creator.name}`;
1159     return (
1160       <>
1161         {this.state.showRemoveDialog && (
1162           <form
1163             className="form-inline"
1164             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
1165           >
1166             <label
1167               className="visually-hidden"
1168               htmlFor="post-listing-remove-reason"
1169             >
1170               {I18NextService.i18n.t("reason")}
1171             </label>
1172             <input
1173               type="text"
1174               id="post-listing-remove-reason"
1175               className="form-control me-2"
1176               placeholder={I18NextService.i18n.t("reason")}
1177               value={this.state.removeReason}
1178               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
1179             />
1180             <button
1181               type="submit"
1182               className="btn btn-secondary"
1183               aria-label={I18NextService.i18n.t("remove_post")}
1184             >
1185               {this.state.removeLoading ? (
1186                 <Spinner />
1187               ) : (
1188                 I18NextService.i18n.t("remove_post")
1189               )}
1190             </button>
1191           </form>
1192         )}
1193         {this.state.showBanDialog && (
1194           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
1195             <div className="mb-3 row col-12">
1196               <label
1197                 className="col-form-label"
1198                 htmlFor="post-listing-ban-reason"
1199               >
1200                 {I18NextService.i18n.t("reason")}
1201               </label>
1202               <input
1203                 type="text"
1204                 id="post-listing-ban-reason"
1205                 className="form-control me-2"
1206                 placeholder={I18NextService.i18n.t("reason")}
1207                 value={this.state.banReason}
1208                 onInput={linkEvent(this, this.handleModBanReasonChange)}
1209               />
1210               <label className="col-form-label" htmlFor={`mod-ban-expires`}>
1211                 {I18NextService.i18n.t("expires")}
1212               </label>
1213               <input
1214                 type="number"
1215                 id={`mod-ban-expires`}
1216                 className="form-control me-2"
1217                 placeholder={I18NextService.i18n.t("number_of_days")}
1218                 value={this.state.banExpireDays}
1219                 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
1220               />
1221               <div className="input-group mb-3">
1222                 <div className="form-check">
1223                   <input
1224                     className="form-check-input"
1225                     id="mod-ban-remove-data"
1226                     type="checkbox"
1227                     checked={this.state.removeData}
1228                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
1229                   />
1230                   <label
1231                     className="form-check-label"
1232                     htmlFor="mod-ban-remove-data"
1233                     title={I18NextService.i18n.t("remove_content_more")}
1234                   >
1235                     {I18NextService.i18n.t("remove_content")}
1236                   </label>
1237                 </div>
1238               </div>
1239             </div>
1240             {/* TODO hold off on expires until later */}
1241             {/* <div class="mb-3 row"> */}
1242             {/*   <label class="col-form-label">Expires</label> */}
1243             {/*   <input type="date" class="form-control me-2" placeholder={I18NextService.i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1244             {/* </div> */}
1245             <div className="mb-3 row">
1246               <button
1247                 type="submit"
1248                 className="btn btn-secondary"
1249                 aria-label={I18NextService.i18n.t("ban")}
1250               >
1251                 {this.state.banLoading ? (
1252                   <Spinner />
1253                 ) : (
1254                   <span>
1255                     {I18NextService.i18n.t("ban")} {post.creator.name}
1256                   </span>
1257                 )}
1258               </button>
1259             </div>
1260           </form>
1261         )}
1262         {this.state.showReportDialog && (
1263           <form
1264             className="form-inline"
1265             onSubmit={linkEvent(this, this.handleReportSubmit)}
1266           >
1267             <label className="visually-hidden" htmlFor="post-report-reason">
1268               {I18NextService.i18n.t("reason")}
1269             </label>
1270             <input
1271               type="text"
1272               id="post-report-reason"
1273               className="form-control me-2"
1274               placeholder={I18NextService.i18n.t("reason")}
1275               required
1276               value={this.state.reportReason}
1277               onInput={linkEvent(this, this.handleReportReasonChange)}
1278             />
1279             <button
1280               type="submit"
1281               className="btn btn-secondary"
1282               aria-label={I18NextService.i18n.t("create_report")}
1283             >
1284               {this.state.reportLoading ? (
1285                 <Spinner />
1286               ) : (
1287                 I18NextService.i18n.t("create_report")
1288               )}
1289             </button>
1290           </form>
1291         )}
1292         {this.state.showPurgeDialog && (
1293           <form
1294             className="form-inline"
1295             onSubmit={linkEvent(this, this.handlePurgeSubmit)}
1296           >
1297             <PurgeWarning />
1298             <label className="visually-hidden" htmlFor="purge-reason">
1299               {I18NextService.i18n.t("reason")}
1300             </label>
1301             <input
1302               type="text"
1303               id="purge-reason"
1304               className="form-control me-2"
1305               placeholder={I18NextService.i18n.t("reason")}
1306               value={this.state.purgeReason}
1307               onInput={linkEvent(this, this.handlePurgeReasonChange)}
1308             />
1309             {this.state.purgeLoading ? (
1310               <Spinner />
1311             ) : (
1312               <button
1313                 type="submit"
1314                 className="btn btn-secondary"
1315                 aria-label={purgeTypeText}
1316               >
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-8"}`}>
1331           {this.postTitleLine()}
1332         </div>
1333         <div className="col-4">
1334           {/* Post thumbnail */}
1335           {!this.state.imageExpanded && this.thumbnail()}
1336         </div>
1337       </div>
1338     ) : (
1339       this.postTitleLine()
1340     );
1341   }
1342
1343   showBodyPreview() {
1344     const { body, id } = this.postView.post;
1345
1346     return !this.showBody && body ? (
1347       <Link className="text-body mt-2 d-block" to={`/post/${id}`}>
1348         <div className="md-div mb-1 preview-lines">{body}</div>
1349       </Link>
1350     ) : (
1351       <></>
1352     );
1353   }
1354
1355   listing() {
1356     return (
1357       <>
1358         {/* The mobile view*/}
1359         <div className="d-block d-sm-none">
1360           <article className="row post-container">
1361             <div className="col-12">
1362               {this.createdLine()}
1363
1364               {/* If it has a thumbnail, do a right aligned thumbnail */}
1365               {this.mobileThumbnail()}
1366
1367               {/* Show a preview of the post body */}
1368               {this.showBodyPreview()}
1369
1370               {this.commentsLine(true)}
1371               {this.userActionsLine()}
1372               {this.duplicatesLine()}
1373               {this.removeAndBanDialogs()}
1374             </div>
1375           </article>
1376         </div>
1377
1378         {/* The larger view*/}
1379         <div className="d-none d-sm-block">
1380           <article className="row post-container">
1381             {!this.props.viewOnly && (
1382               <VoteButtons
1383                 id={this.postView.post.id}
1384                 onVote={this.props.onPostVote}
1385                 enableDownvotes={this.props.enableDownvotes}
1386                 counts={this.postView.counts}
1387                 my_vote={this.postView.my_vote}
1388               />
1389             )}
1390             <div className="col-sm-2 pe-0 post-media">
1391               <div className="">{this.thumbnail()}</div>
1392             </div>
1393             <div className="col-12 col-sm-9">
1394               <div className="row">
1395                 <div className="col-12">
1396                   {this.postTitleLine()}
1397                   {this.createdLine()}
1398                   {this.showBodyPreview()}
1399                   {this.commentsLine()}
1400                   {this.duplicatesLine()}
1401                   {this.userActionsLine()}
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   handleEditClick(i: PostListing) {
1419     i.setState({ showEdit: true });
1420   }
1421
1422   handleEditCancel() {
1423     this.setState({ showEdit: false });
1424   }
1425
1426   // The actual editing is done in the receive for post
1427   handleEditPost(form: EditPost) {
1428     this.setState({ showEdit: false });
1429     this.props.onPostEdit(form);
1430   }
1431
1432   handleShare(i: PostListing) {
1433     const { name, body, id } = i.props.post_view.post;
1434     share({
1435       title: name,
1436       text: body?.slice(0, 50),
1437       url: `${getHttpBase()}/post/${id}`,
1438     });
1439   }
1440
1441   handleShowReportDialog(i: PostListing) {
1442     i.setState({ showReportDialog: !i.state.showReportDialog });
1443   }
1444
1445   handleReportReasonChange(i: PostListing, event: any) {
1446     i.setState({ reportReason: event.target.value });
1447   }
1448
1449   handleReportSubmit(i: PostListing, event: any) {
1450     event.preventDefault();
1451     i.setState({ reportLoading: true });
1452     i.props.onPostReport({
1453       post_id: i.postView.post.id,
1454       reason: i.state.reportReason ?? "",
1455       auth: myAuthRequired(),
1456     });
1457   }
1458
1459   handleBlockPersonClick(i: PostListing) {
1460     i.setState({ blockLoading: true });
1461     i.props.onBlockPerson({
1462       person_id: i.postView.creator.id,
1463       block: true,
1464       auth: myAuthRequired(),
1465     });
1466   }
1467
1468   handleDeleteClick(i: PostListing) {
1469     i.setState({ deleteLoading: true });
1470     i.props.onDeletePost({
1471       post_id: i.postView.post.id,
1472       deleted: !i.postView.post.deleted,
1473       auth: myAuthRequired(),
1474     });
1475   }
1476
1477   handleSavePostClick(i: PostListing) {
1478     i.setState({ saveLoading: true });
1479     i.props.onSavePost({
1480       post_id: i.postView.post.id,
1481       save: !i.postView.saved,
1482       auth: myAuthRequired(),
1483     });
1484   }
1485
1486   get crossPostParams(): PostFormParams {
1487     const queryParams: PostFormParams = {};
1488     const { name, url } = this.postView.post;
1489
1490     queryParams.name = name;
1491
1492     if (url) {
1493       queryParams.url = url;
1494     }
1495
1496     const crossPostBody = this.crossPostBody();
1497     if (crossPostBody) {
1498       queryParams.body = crossPostBody;
1499     }
1500
1501     return queryParams;
1502   }
1503
1504   crossPostBody(): string | undefined {
1505     const post = this.postView.post;
1506     const body = post.body;
1507
1508     return body
1509       ? `${I18NextService.i18n.t("cross_posted_from")} ${
1510           post.ap_id
1511         }\n\n${body.replace(/^/gm, "> ")}`
1512       : undefined;
1513   }
1514
1515   get showBody(): boolean {
1516     return this.props.showBody || this.state.showBody;
1517   }
1518
1519   handleModRemoveShow(i: PostListing) {
1520     i.setState({
1521       showRemoveDialog: !i.state.showRemoveDialog,
1522       showBanDialog: false,
1523     });
1524   }
1525
1526   handleModRemoveReasonChange(i: PostListing, event: any) {
1527     i.setState({ removeReason: event.target.value });
1528   }
1529
1530   handleModRemoveDataChange(i: PostListing, event: any) {
1531     i.setState({ removeData: event.target.checked });
1532   }
1533
1534   handleModRemoveSubmit(i: PostListing, event: any) {
1535     event.preventDefault();
1536     i.setState({ removeLoading: true });
1537     i.props.onRemovePost({
1538       post_id: i.postView.post.id,
1539       removed: !i.postView.post.removed,
1540       auth: myAuthRequired(),
1541     });
1542   }
1543
1544   handleModLock(i: PostListing) {
1545     i.setState({ lockLoading: true });
1546     i.props.onLockPost({
1547       post_id: i.postView.post.id,
1548       locked: !i.postView.post.locked,
1549       auth: myAuthRequired(),
1550     });
1551   }
1552
1553   handleModFeaturePostLocal(i: PostListing) {
1554     i.setState({ featureLocalLoading: true });
1555     i.props.onFeaturePost({
1556       post_id: i.postView.post.id,
1557       featured: !i.postView.post.featured_local,
1558       feature_type: "Local",
1559       auth: myAuthRequired(),
1560     });
1561   }
1562
1563   handleModFeaturePostCommunity(i: PostListing) {
1564     i.setState({ featureCommunityLoading: true });
1565     i.props.onFeaturePost({
1566       post_id: i.postView.post.id,
1567       featured: !i.postView.post.featured_community,
1568       feature_type: "Community",
1569       auth: myAuthRequired(),
1570     });
1571   }
1572
1573   handleModBanFromCommunityShow(i: PostListing) {
1574     i.setState({
1575       showBanDialog: true,
1576       banType: BanType.Community,
1577       showRemoveDialog: false,
1578     });
1579   }
1580
1581   handleModBanShow(i: PostListing) {
1582     i.setState({
1583       showBanDialog: true,
1584       banType: BanType.Site,
1585       showRemoveDialog: false,
1586     });
1587   }
1588
1589   handlePurgePersonShow(i: PostListing) {
1590     i.setState({
1591       showPurgeDialog: true,
1592       purgeType: PurgeType.Person,
1593       showRemoveDialog: false,
1594     });
1595   }
1596
1597   handlePurgePostShow(i: PostListing) {
1598     i.setState({
1599       showPurgeDialog: true,
1600       purgeType: PurgeType.Post,
1601       showRemoveDialog: false,
1602     });
1603   }
1604
1605   handlePurgeReasonChange(i: PostListing, event: any) {
1606     i.setState({ purgeReason: event.target.value });
1607   }
1608
1609   handlePurgeSubmit(i: PostListing, event: any) {
1610     event.preventDefault();
1611     i.setState({ purgeLoading: true });
1612     if (i.state.purgeType == PurgeType.Person) {
1613       i.props.onPurgePerson({
1614         person_id: i.postView.creator.id,
1615         reason: i.state.purgeReason,
1616         auth: myAuthRequired(),
1617       });
1618     } else if (i.state.purgeType == PurgeType.Post) {
1619       i.props.onPurgePost({
1620         post_id: i.postView.post.id,
1621         reason: i.state.purgeReason,
1622         auth: myAuthRequired(),
1623       });
1624     }
1625   }
1626
1627   handleModBanReasonChange(i: PostListing, event: any) {
1628     i.setState({ banReason: event.target.value });
1629   }
1630
1631   handleModBanExpireDaysChange(i: PostListing, event: any) {
1632     i.setState({ banExpireDays: event.target.value });
1633   }
1634
1635   handleModBanFromCommunitySubmit(i: PostListing, event: any) {
1636     i.setState({ banType: BanType.Community });
1637     i.handleModBanBothSubmit(i, event);
1638   }
1639
1640   handleModBanSubmit(i: PostListing, event: any) {
1641     i.setState({ banType: BanType.Site });
1642     i.handleModBanBothSubmit(i, event);
1643   }
1644
1645   handleModBanBothSubmit(i: PostListing, event: any) {
1646     event.preventDefault();
1647     i.setState({ banLoading: true });
1648
1649     const ban = !i.props.post_view.creator_banned_from_community;
1650     // If its an unban, restore all their data
1651     if (ban == false) {
1652       i.setState({ removeData: false });
1653     }
1654     const person_id = i.props.post_view.creator.id;
1655     const remove_data = i.state.removeData;
1656     const reason = i.state.banReason;
1657     const expires = futureDaysToUnixTime(i.state.banExpireDays);
1658
1659     if (i.state.banType == BanType.Community) {
1660       const community_id = i.postView.community.id;
1661       i.props.onBanPersonFromCommunity({
1662         community_id,
1663         person_id,
1664         ban,
1665         remove_data,
1666         reason,
1667         expires,
1668         auth: myAuthRequired(),
1669       });
1670     } else {
1671       i.props.onBanPerson({
1672         person_id,
1673         ban,
1674         remove_data,
1675         reason,
1676         expires,
1677         auth: myAuthRequired(),
1678       });
1679     }
1680   }
1681
1682   handleAddModToCommunity(i: PostListing) {
1683     i.setState({ addModLoading: true });
1684     i.props.onAddModToCommunity({
1685       community_id: i.postView.community.id,
1686       person_id: i.postView.creator.id,
1687       added: !i.creatorIsMod_,
1688       auth: myAuthRequired(),
1689     });
1690   }
1691
1692   handleAddAdmin(i: PostListing) {
1693     i.setState({ addAdminLoading: true });
1694     i.props.onAddAdmin({
1695       person_id: i.postView.creator.id,
1696       added: !i.creatorIsAdmin_,
1697       auth: myAuthRequired(),
1698     });
1699   }
1700
1701   handleShowConfirmTransferCommunity(i: PostListing) {
1702     i.setState({ showConfirmTransferCommunity: true });
1703   }
1704
1705   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1706     i.setState({ showConfirmTransferCommunity: false });
1707   }
1708
1709   handleTransferCommunity(i: PostListing) {
1710     i.setState({ transferLoading: true });
1711     i.props.onTransferCommunity({
1712       community_id: i.postView.community.id,
1713       person_id: i.postView.creator.id,
1714       auth: myAuthRequired(),
1715     });
1716   }
1717
1718   handleShowConfirmTransferSite(i: PostListing) {
1719     i.setState({ showConfirmTransferSite: true });
1720   }
1721
1722   handleCancelShowConfirmTransferSite(i: PostListing) {
1723     i.setState({ showConfirmTransferSite: false });
1724   }
1725
1726   handleImageExpandClick(i: PostListing, event: any) {
1727     event.preventDefault();
1728     i.setState({ imageExpanded: !i.state.imageExpanded });
1729     setupTippy();
1730   }
1731
1732   handleViewSource(i: PostListing) {
1733     i.setState({ viewSource: !i.state.viewSource });
1734   }
1735
1736   handleShowAdvanced(i: PostListing) {
1737     i.setState({ showAdvanced: !i.state.showAdvanced });
1738     setupTippy();
1739   }
1740
1741   handleShowMoreMobile(i: PostListing) {
1742     i.setState({
1743       showMoreMobile: !i.state.showMoreMobile,
1744       showAdvanced: !i.state.showAdvanced,
1745     });
1746     setupTippy();
1747   }
1748
1749   handleShowBody(i: PostListing) {
1750     i.setState({ showBody: !i.state.showBody });
1751     setupTippy();
1752   }
1753
1754   get pointsTippy(): string {
1755     const points = I18NextService.i18n.t("number_of_points", {
1756       count: Number(this.postView.counts.score),
1757       formattedCount: Number(this.postView.counts.score),
1758     });
1759
1760     const upvotes = I18NextService.i18n.t("number_of_upvotes", {
1761       count: Number(this.postView.counts.upvotes),
1762       formattedCount: Number(this.postView.counts.upvotes),
1763     });
1764
1765     const downvotes = I18NextService.i18n.t("number_of_downvotes", {
1766       count: Number(this.postView.counts.downvotes),
1767       formattedCount: Number(this.postView.counts.downvotes),
1768     });
1769
1770     return `${points} • ${upvotes} • ${downvotes}`;
1771   }
1772
1773   get canModOnSelf_(): boolean {
1774     return canMod(
1775       this.postView.creator.id,
1776       this.props.moderators,
1777       this.props.admins,
1778       undefined,
1779       true
1780     );
1781   }
1782
1783   get canMod_(): boolean {
1784     return canMod(
1785       this.postView.creator.id,
1786       this.props.moderators,
1787       this.props.admins
1788     );
1789   }
1790
1791   get canAdmin_(): boolean {
1792     return canAdmin(this.postView.creator.id, this.props.admins);
1793   }
1794
1795   get creatorIsMod_(): boolean {
1796     return isMod(this.postView.creator.id, this.props.moderators);
1797   }
1798
1799   get creatorIsAdmin_(): boolean {
1800     return isAdmin(this.postView.creator.id, this.props.admins);
1801   }
1802 }