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