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