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