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