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