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