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