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