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