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