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