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