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