]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post-listing.tsx
Merge branch 'LemmyNet:main' into multiple-images-upload
[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           <span className="mr-2">
635             {i18n.t("number_of_comments", {
636               count: post_view.counts.comments,
637               formattedCount: numToSI(post_view.counts.comments),
638             })}
639           </span>
640           {this.unreadCount.match({
641             some: unreadCount => (
642               <span className="small text-warning">
643                 ({unreadCount} {i18n.t("new")})
644               </span>
645             ),
646             none: <></>,
647           })}
648         </Link>
649       </button>
650     );
651   }
652
653   get unreadCount(): Option<number> {
654     let pv = this.props.post_view;
655     if (pv.unread_comments == pv.counts.comments || pv.unread_comments == 0) {
656       return None;
657     } else {
658       return Some(pv.unread_comments);
659     }
660   }
661
662   get mobileVotes() {
663     // TODO: make nicer
664     let tippy = showScores() ? { "data-tippy-content": this.pointsTippy } : {};
665     return (
666       <>
667         <div>
668           <button
669             className={`btn-animate btn py-0 px-1 ${
670               this.state.my_vote.unwrapOr(0) == 1 ? "text-info" : "text-muted"
671             }`}
672             {...tippy}
673             onClick={this.handlePostLike}
674             aria-label={i18n.t("upvote")}
675           >
676             <Icon icon="arrow-up1" classes="icon-inline small" />
677             {showScores() && (
678               <span className="ml-2">{numToSI(this.state.upvotes)}</span>
679             )}
680           </button>
681           {this.props.enableDownvotes && (
682             <button
683               className={`ml-2 btn-animate btn py-0 px-1 ${
684                 this.state.my_vote.unwrapOr(0) == -1
685                   ? "text-danger"
686                   : "text-muted"
687               }`}
688               onClick={this.handlePostDisLike}
689               {...tippy}
690               aria-label={i18n.t("downvote")}
691             >
692               <Icon icon="arrow-down1" classes="icon-inline small" />
693               {showScores() && (
694                 <span
695                   className={classNames("ml-2", {
696                     invisible: this.state.downvotes === 0,
697                   })}
698                 >
699                   {numToSI(this.state.downvotes)}
700                 </span>
701               )}
702             </button>
703           )}
704         </div>
705       </>
706     );
707   }
708
709   get saveButton() {
710     let saved = this.props.post_view.saved;
711     let label = saved ? i18n.t("unsave") : i18n.t("save");
712     return (
713       <button
714         className="btn btn-link btn-animate text-muted py-0"
715         onClick={linkEvent(this, this.handleSavePostClick)}
716         data-tippy-content={label}
717         aria-label={label}
718       >
719         <Icon
720           icon="star"
721           classes={classNames({ "text-warning": saved })}
722           inline
723         />
724       </button>
725     );
726   }
727
728   get crossPostButton() {
729     return (
730       <Link
731         className="btn btn-link btn-animate text-muted py-0"
732         to={`/create_post${this.crossPostParams}`}
733         title={i18n.t("cross_post")}
734       >
735         <Icon icon="copy" inline />
736       </Link>
737     );
738   }
739
740   get reportButton() {
741     return (
742       <button
743         className="btn btn-link btn-animate text-muted py-0"
744         onClick={linkEvent(this, this.handleShowReportDialog)}
745         data-tippy-content={i18n.t("show_report_dialog")}
746         aria-label={i18n.t("show_report_dialog")}
747       >
748         <Icon icon="flag" inline />
749       </button>
750     );
751   }
752
753   get blockButton() {
754     return (
755       <button
756         className="btn btn-link btn-animate text-muted py-0"
757         onClick={linkEvent(this, this.handleBlockUserClick)}
758         data-tippy-content={i18n.t("block_user")}
759         aria-label={i18n.t("block_user")}
760       >
761         <Icon icon="slash" inline />
762       </button>
763     );
764   }
765
766   get editButton() {
767     return (
768       <button
769         className="btn btn-link btn-animate text-muted py-0"
770         onClick={linkEvent(this, this.handleEditClick)}
771         data-tippy-content={i18n.t("edit")}
772         aria-label={i18n.t("edit")}
773       >
774         <Icon icon="edit" inline />
775       </button>
776     );
777   }
778
779   get deleteButton() {
780     let deleted = this.props.post_view.post.deleted;
781     let label = !deleted ? i18n.t("delete") : i18n.t("restore");
782     return (
783       <button
784         className="btn btn-link btn-animate text-muted py-0"
785         onClick={linkEvent(this, this.handleDeleteClick)}
786         data-tippy-content={label}
787         aria-label={label}
788       >
789         <Icon
790           icon="trash"
791           classes={classNames({ "text-danger": deleted })}
792           inline
793         />
794       </button>
795     );
796   }
797
798   get showMoreButton() {
799     return (
800       <button
801         className="btn btn-link btn-animate text-muted py-0"
802         onClick={linkEvent(this, this.handleShowAdvanced)}
803         data-tippy-content={i18n.t("more")}
804         aria-label={i18n.t("more")}
805       >
806         <Icon icon="more-vertical" inline />
807       </button>
808     );
809   }
810
811   get viewSourceButton() {
812     return (
813       <button
814         className="btn btn-link btn-animate text-muted py-0"
815         onClick={linkEvent(this, this.handleViewSource)}
816         data-tippy-content={i18n.t("view_source")}
817         aria-label={i18n.t("view_source")}
818       >
819         <Icon
820           icon="file-text"
821           classes={classNames({ "text-success": this.state.viewSource })}
822           inline
823         />
824       </button>
825     );
826   }
827
828   get lockButton() {
829     let locked = this.props.post_view.post.locked;
830     let label = locked ? i18n.t("unlock") : i18n.t("lock");
831     return (
832       <button
833         className="btn btn-link btn-animate text-muted py-0"
834         onClick={linkEvent(this, this.handleModLock)}
835         data-tippy-content={label}
836         aria-label={label}
837       >
838         <Icon
839           icon="lock"
840           classes={classNames({ "text-danger": locked })}
841           inline
842         />
843       </button>
844     );
845   }
846
847   get stickyButton() {
848     let stickied = this.props.post_view.post.stickied;
849     let label = stickied ? i18n.t("unsticky") : i18n.t("sticky");
850     return (
851       <button
852         className="btn btn-link btn-animate text-muted py-0"
853         onClick={linkEvent(this, this.handleModSticky)}
854         data-tippy-content={label}
855         aria-label={label}
856       >
857         <Icon
858           icon="pin"
859           classes={classNames({ "text-success": stickied })}
860           inline
861         />
862       </button>
863     );
864   }
865
866   get modRemoveButton() {
867     let removed = this.props.post_view.post.removed;
868     return (
869       <button
870         className="btn btn-link btn-animate text-muted py-0"
871         onClick={linkEvent(
872           this,
873           !removed ? this.handleModRemoveShow : this.handleModRemoveSubmit
874         )}
875       >
876         {/* TODO: Find an icon for this. */}
877         {!removed ? i18n.t("remove") : i18n.t("restore")}
878       </button>
879     );
880   }
881
882   /**
883    * Mod/Admin actions to be taken against the author.
884    */
885   userActionsLine() {
886     // TODO: make nicer
887     let post_view = this.props.post_view;
888     return (
889       this.state.showAdvanced && (
890         <>
891           {this.canMod_ && (
892             <>
893               {!this.creatorIsMod_ &&
894                 (!post_view.creator_banned_from_community ? (
895                   <button
896                     className="btn btn-link btn-animate text-muted py-0"
897                     onClick={linkEvent(
898                       this,
899                       this.handleModBanFromCommunityShow
900                     )}
901                     aria-label={i18n.t("ban")}
902                   >
903                     {i18n.t("ban")}
904                   </button>
905                 ) : (
906                   <button
907                     className="btn btn-link btn-animate text-muted py-0"
908                     onClick={linkEvent(
909                       this,
910                       this.handleModBanFromCommunitySubmit
911                     )}
912                     aria-label={i18n.t("unban")}
913                   >
914                     {i18n.t("unban")}
915                   </button>
916                 ))}
917               {!post_view.creator_banned_from_community && (
918                 <button
919                   className="btn btn-link btn-animate text-muted py-0"
920                   onClick={linkEvent(this, this.handleAddModToCommunity)}
921                   aria-label={
922                     this.creatorIsMod_
923                       ? i18n.t("remove_as_mod")
924                       : i18n.t("appoint_as_mod")
925                   }
926                 >
927                   {this.creatorIsMod_
928                     ? i18n.t("remove_as_mod")
929                     : i18n.t("appoint_as_mod")}
930                 </button>
931               )}
932             </>
933           )}
934           {/* Community creators and admins can transfer community to another mod */}
935           {(amCommunityCreator(this.props.moderators, post_view.creator.id) ||
936             this.canAdmin_) &&
937             this.creatorIsMod_ &&
938             (!this.state.showConfirmTransferCommunity ? (
939               <button
940                 className="btn btn-link btn-animate text-muted py-0"
941                 onClick={linkEvent(
942                   this,
943                   this.handleShowConfirmTransferCommunity
944                 )}
945                 aria-label={i18n.t("transfer_community")}
946               >
947                 {i18n.t("transfer_community")}
948               </button>
949             ) : (
950               <>
951                 <button
952                   className="d-inline-block mr-1 btn btn-link btn-animate text-muted py-0"
953                   aria-label={i18n.t("are_you_sure")}
954                 >
955                   {i18n.t("are_you_sure")}
956                 </button>
957                 <button
958                   className="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
959                   aria-label={i18n.t("yes")}
960                   onClick={linkEvent(this, this.handleTransferCommunity)}
961                 >
962                   {i18n.t("yes")}
963                 </button>
964                 <button
965                   className="btn btn-link btn-animate text-muted py-0 d-inline-block"
966                   onClick={linkEvent(
967                     this,
968                     this.handleCancelShowConfirmTransferCommunity
969                   )}
970                   aria-label={i18n.t("no")}
971                 >
972                   {i18n.t("no")}
973                 </button>
974               </>
975             ))}
976           {/* Admins can ban from all, and appoint other admins */}
977           {this.canAdmin_ && (
978             <>
979               {!this.creatorIsAdmin_ && (
980                 <>
981                   {!isBanned(post_view.creator) ? (
982                     <button
983                       className="btn btn-link btn-animate text-muted py-0"
984                       onClick={linkEvent(this, this.handleModBanShow)}
985                       aria-label={i18n.t("ban_from_site")}
986                     >
987                       {i18n.t("ban_from_site")}
988                     </button>
989                   ) : (
990                     <button
991                       className="btn btn-link btn-animate text-muted py-0"
992                       onClick={linkEvent(this, this.handleModBanSubmit)}
993                       aria-label={i18n.t("unban_from_site")}
994                     >
995                       {i18n.t("unban_from_site")}
996                     </button>
997                   )}
998                   <button
999                     className="btn btn-link btn-animate text-muted py-0"
1000                     onClick={linkEvent(this, this.handlePurgePersonShow)}
1001                     aria-label={i18n.t("purge_user")}
1002                   >
1003                     {i18n.t("purge_user")}
1004                   </button>
1005                   <button
1006                     className="btn btn-link btn-animate text-muted py-0"
1007                     onClick={linkEvent(this, this.handlePurgePostShow)}
1008                     aria-label={i18n.t("purge_post")}
1009                   >
1010                     {i18n.t("purge_post")}
1011                   </button>
1012                 </>
1013               )}
1014               {!isBanned(post_view.creator) && post_view.creator.local && (
1015                 <button
1016                   className="btn btn-link btn-animate text-muted py-0"
1017                   onClick={linkEvent(this, this.handleAddAdmin)}
1018                   aria-label={
1019                     this.creatorIsAdmin_
1020                       ? i18n.t("remove_as_admin")
1021                       : i18n.t("appoint_as_admin")
1022                   }
1023                 >
1024                   {this.creatorIsAdmin_
1025                     ? i18n.t("remove_as_admin")
1026                     : i18n.t("appoint_as_admin")}
1027                 </button>
1028               )}
1029             </>
1030           )}
1031         </>
1032       )
1033     );
1034   }
1035
1036   removeAndBanDialogs() {
1037     let post = this.props.post_view;
1038     let purgeTypeText: string;
1039     if (this.state.purgeType == PurgeType.Post) {
1040       purgeTypeText = i18n.t("purge_post");
1041     } else if (this.state.purgeType == PurgeType.Person) {
1042       purgeTypeText = `${i18n.t("purge")} ${post.creator.name}`;
1043     }
1044     return (
1045       <>
1046         {this.state.showRemoveDialog && (
1047           <form
1048             className="form-inline"
1049             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
1050           >
1051             <label className="sr-only" htmlFor="post-listing-remove-reason">
1052               {i18n.t("reason")}
1053             </label>
1054             <input
1055               type="text"
1056               id="post-listing-remove-reason"
1057               className="form-control mr-2"
1058               placeholder={i18n.t("reason")}
1059               value={toUndefined(this.state.removeReason)}
1060               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
1061             />
1062             <button
1063               type="submit"
1064               className="btn btn-secondary"
1065               aria-label={i18n.t("remove_post")}
1066             >
1067               {i18n.t("remove_post")}
1068             </button>
1069           </form>
1070         )}
1071         {this.state.showBanDialog && (
1072           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
1073             <div className="form-group row col-12">
1074               <label
1075                 className="col-form-label"
1076                 htmlFor="post-listing-ban-reason"
1077               >
1078                 {i18n.t("reason")}
1079               </label>
1080               <input
1081                 type="text"
1082                 id="post-listing-ban-reason"
1083                 className="form-control mr-2"
1084                 placeholder={i18n.t("reason")}
1085                 value={toUndefined(this.state.banReason)}
1086                 onInput={linkEvent(this, this.handleModBanReasonChange)}
1087               />
1088               <label className="col-form-label" htmlFor={`mod-ban-expires`}>
1089                 {i18n.t("expires")}
1090               </label>
1091               <input
1092                 type="number"
1093                 id={`mod-ban-expires`}
1094                 className="form-control mr-2"
1095                 placeholder={i18n.t("number_of_days")}
1096                 value={toUndefined(this.state.banExpireDays)}
1097                 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
1098               />
1099               <div className="form-group">
1100                 <div className="form-check">
1101                   <input
1102                     className="form-check-input"
1103                     id="mod-ban-remove-data"
1104                     type="checkbox"
1105                     checked={this.state.removeData}
1106                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
1107                   />
1108                   <label
1109                     className="form-check-label"
1110                     htmlFor="mod-ban-remove-data"
1111                     title={i18n.t("remove_content_more")}
1112                   >
1113                     {i18n.t("remove_content")}
1114                   </label>
1115                 </div>
1116               </div>
1117             </div>
1118             {/* TODO hold off on expires until later */}
1119             {/* <div class="form-group row"> */}
1120             {/*   <label class="col-form-label">Expires</label> */}
1121             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1122             {/* </div> */}
1123             <div className="form-group row">
1124               <button
1125                 type="submit"
1126                 className="btn btn-secondary"
1127                 aria-label={i18n.t("ban")}
1128               >
1129                 {i18n.t("ban")} {post.creator.name}
1130               </button>
1131             </div>
1132           </form>
1133         )}
1134         {this.state.showReportDialog && (
1135           <form
1136             className="form-inline"
1137             onSubmit={linkEvent(this, this.handleReportSubmit)}
1138           >
1139             <label className="sr-only" htmlFor="post-report-reason">
1140               {i18n.t("reason")}
1141             </label>
1142             <input
1143               type="text"
1144               id="post-report-reason"
1145               className="form-control mr-2"
1146               placeholder={i18n.t("reason")}
1147               required
1148               value={toUndefined(this.state.reportReason)}
1149               onInput={linkEvent(this, this.handleReportReasonChange)}
1150             />
1151             <button
1152               type="submit"
1153               className="btn btn-secondary"
1154               aria-label={i18n.t("create_report")}
1155             >
1156               {i18n.t("create_report")}
1157             </button>
1158           </form>
1159         )}
1160         {this.state.showPurgeDialog && (
1161           <form
1162             className="form-inline"
1163             onSubmit={linkEvent(this, this.handlePurgeSubmit)}
1164           >
1165             <PurgeWarning />
1166             <label className="sr-only" htmlFor="purge-reason">
1167               {i18n.t("reason")}
1168             </label>
1169             <input
1170               type="text"
1171               id="purge-reason"
1172               className="form-control mr-2"
1173               placeholder={i18n.t("reason")}
1174               value={toUndefined(this.state.purgeReason)}
1175               onInput={linkEvent(this, this.handlePurgeReasonChange)}
1176             />
1177             {this.state.purgeLoading ? (
1178               <Spinner />
1179             ) : (
1180               <button
1181                 type="submit"
1182                 className="btn btn-secondary"
1183                 aria-label={purgeTypeText}
1184               >
1185                 {purgeTypeText}
1186               </button>
1187             )}
1188           </form>
1189         )}
1190       </>
1191     );
1192   }
1193
1194   mobileThumbnail() {
1195     let post = this.props.post_view.post;
1196     return post.thumbnail_url.isSome() ||
1197       post.url.map(isImage).unwrapOr(false) ? (
1198       <div className="row">
1199         <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
1200           {this.postTitleLine()}
1201         </div>
1202         <div className="col-4">
1203           {/* Post body prev or thumbnail */}
1204           {!this.state.imageExpanded && this.thumbnail()}
1205         </div>
1206       </div>
1207     ) : (
1208       this.postTitleLine()
1209     );
1210   }
1211
1212   showMobilePreview() {
1213     let post = this.props.post_view.post;
1214     return (
1215       !this.showBody &&
1216       post.body.match({
1217         some: body => <div className="md-div mb-1 preview-lines">{body}</div>,
1218         none: <></>,
1219       })
1220     );
1221   }
1222
1223   listing() {
1224     return (
1225       <>
1226         {/* The mobile view*/}
1227         <div className="d-block d-sm-none">
1228           <div className="row">
1229             <div className="col-12">
1230               {this.createdLine()}
1231
1232               {/* If it has a thumbnail, do a right aligned thumbnail */}
1233               {this.mobileThumbnail()}
1234
1235               {/* Show a preview of the post body */}
1236               {this.showMobilePreview()}
1237
1238               {this.commentsLine(true)}
1239               {this.userActionsLine()}
1240               {this.duplicatesLine()}
1241               {this.removeAndBanDialogs()}
1242             </div>
1243           </div>
1244         </div>
1245
1246         {/* The larger view*/}
1247         <div className="d-none d-sm-block">
1248           <div className="row">
1249             {!this.props.viewOnly && this.voteBar()}
1250             <div className="col-sm-2 pr-0">
1251               <div className="">{this.thumbnail()}</div>
1252             </div>
1253             <div className="col-12 col-sm-9">
1254               <div className="row">
1255                 <div className="col-12">
1256                   {this.postTitleLine()}
1257                   {this.createdLine()}
1258                   {this.commentsLine()}
1259                   {this.duplicatesLine()}
1260                   {this.userActionsLine()}
1261                   {this.removeAndBanDialogs()}
1262                 </div>
1263               </div>
1264             </div>
1265           </div>
1266         </div>
1267       </>
1268     );
1269   }
1270
1271   private get myPost(): boolean {
1272     return UserService.Instance.myUserInfo.match({
1273       some: mui =>
1274         this.props.post_view.creator.id == mui.local_user_view.person.id,
1275       none: false,
1276     });
1277   }
1278
1279   handlePostLike(event: any) {
1280     event.preventDefault();
1281     if (UserService.Instance.myUserInfo.isNone()) {
1282       this.context.router.history.push(`/login`);
1283     }
1284
1285     let myVote = this.state.my_vote.unwrapOr(0);
1286     let newVote = myVote == 1 ? 0 : 1;
1287
1288     if (myVote == 1) {
1289       this.setState({
1290         score: this.state.score - 1,
1291         upvotes: this.state.upvotes - 1,
1292       });
1293     } else if (myVote == -1) {
1294       this.setState({
1295         score: this.state.score + 2,
1296         upvotes: this.state.upvotes + 1,
1297         downvotes: this.state.downvotes - 1,
1298       });
1299     } else {
1300       this.setState({
1301         score: this.state.score + 1,
1302         upvotes: this.state.upvotes + 1,
1303       });
1304     }
1305
1306     this.setState({ my_vote: Some(newVote) });
1307
1308     let form = new CreatePostLike({
1309       post_id: this.props.post_view.post.id,
1310       score: newVote,
1311       auth: auth().unwrap(),
1312     });
1313
1314     WebSocketService.Instance.send(wsClient.likePost(form));
1315     this.setState(this.state);
1316     setupTippy();
1317   }
1318
1319   handlePostDisLike(event: any) {
1320     event.preventDefault();
1321     if (UserService.Instance.myUserInfo.isNone()) {
1322       this.context.router.history.push(`/login`);
1323     }
1324
1325     let myVote = this.state.my_vote.unwrapOr(0);
1326     let newVote = myVote == -1 ? 0 : -1;
1327
1328     if (myVote == 1) {
1329       this.setState({
1330         score: this.state.score - 2,
1331         upvotes: this.state.upvotes - 1,
1332         downvotes: this.state.downvotes + 1,
1333       });
1334     } else if (myVote == -1) {
1335       this.setState({
1336         score: this.state.score + 1,
1337         downvotes: this.state.downvotes - 1,
1338       });
1339     } else {
1340       this.setState({
1341         score: this.state.score - 1,
1342         downvotes: this.state.downvotes + 1,
1343       });
1344     }
1345
1346     this.setState({ my_vote: Some(newVote) });
1347
1348     let form = new CreatePostLike({
1349       post_id: this.props.post_view.post.id,
1350       score: newVote,
1351       auth: auth().unwrap(),
1352     });
1353
1354     WebSocketService.Instance.send(wsClient.likePost(form));
1355     this.setState(this.state);
1356     setupTippy();
1357   }
1358
1359   handleEditClick(i: PostListing) {
1360     i.setState({ showEdit: true });
1361   }
1362
1363   handleEditCancel() {
1364     this.setState({ showEdit: false });
1365   }
1366
1367   // The actual editing is done in the recieve for post
1368   handleEditPost() {
1369     this.setState({ showEdit: false });
1370   }
1371
1372   handleShowReportDialog(i: PostListing) {
1373     i.setState({ showReportDialog: !i.state.showReportDialog });
1374   }
1375
1376   handleReportReasonChange(i: PostListing, event: any) {
1377     i.setState({ reportReason: Some(event.target.value) });
1378   }
1379
1380   handleReportSubmit(i: PostListing, event: any) {
1381     event.preventDefault();
1382     let form = new CreatePostReport({
1383       post_id: i.props.post_view.post.id,
1384       reason: toUndefined(i.state.reportReason),
1385       auth: auth().unwrap(),
1386     });
1387     WebSocketService.Instance.send(wsClient.createPostReport(form));
1388
1389     i.setState({ showReportDialog: false });
1390   }
1391
1392   handleBlockUserClick(i: PostListing) {
1393     let blockUserForm = new BlockPerson({
1394       person_id: i.props.post_view.creator.id,
1395       block: true,
1396       auth: auth().unwrap(),
1397     });
1398     WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
1399   }
1400
1401   handleDeleteClick(i: PostListing) {
1402     let deleteForm = new DeletePost({
1403       post_id: i.props.post_view.post.id,
1404       deleted: !i.props.post_view.post.deleted,
1405       auth: auth().unwrap(),
1406     });
1407     WebSocketService.Instance.send(wsClient.deletePost(deleteForm));
1408   }
1409
1410   handleSavePostClick(i: PostListing) {
1411     let saved =
1412       i.props.post_view.saved == undefined ? true : !i.props.post_view.saved;
1413     let form = new SavePost({
1414       post_id: i.props.post_view.post.id,
1415       save: saved,
1416       auth: auth().unwrap(),
1417     });
1418
1419     WebSocketService.Instance.send(wsClient.savePost(form));
1420   }
1421
1422   get crossPostParams(): string {
1423     let post = this.props.post_view.post;
1424     let params = `?title=${encodeURIComponent(post.name)}`;
1425
1426     if (post.url.isSome()) {
1427       params += `&url=${encodeURIComponent(post.url.unwrap())}`;
1428     }
1429     if (post.body.isSome()) {
1430       params += `&body=${encodeURIComponent(this.crossPostBody())}`;
1431     }
1432     return params;
1433   }
1434
1435   crossPostBody(): string {
1436     let post = this.props.post_view.post;
1437     let body = `${i18n.t("cross_posted_from")} ${post.ap_id}\n\n${post.body
1438       .unwrap()
1439       .replace(/^/gm, "> ")}`;
1440     return body;
1441   }
1442
1443   get showBody(): boolean {
1444     return this.props.showBody || this.state.showBody;
1445   }
1446
1447   handleModRemoveShow(i: PostListing) {
1448     i.setState({
1449       showRemoveDialog: !i.state.showRemoveDialog,
1450       showBanDialog: false,
1451     });
1452   }
1453
1454   handleModRemoveReasonChange(i: PostListing, event: any) {
1455     i.setState({ removeReason: Some(event.target.value) });
1456   }
1457
1458   handleModRemoveDataChange(i: PostListing, event: any) {
1459     i.setState({ removeData: event.target.checked });
1460   }
1461
1462   handleModRemoveSubmit(i: PostListing, event: any) {
1463     event.preventDefault();
1464     let form = new RemovePost({
1465       post_id: i.props.post_view.post.id,
1466       removed: !i.props.post_view.post.removed,
1467       reason: i.state.removeReason,
1468       auth: auth().unwrap(),
1469     });
1470     WebSocketService.Instance.send(wsClient.removePost(form));
1471
1472     i.setState({ showRemoveDialog: false });
1473   }
1474
1475   handleModLock(i: PostListing) {
1476     let form = new LockPost({
1477       post_id: i.props.post_view.post.id,
1478       locked: !i.props.post_view.post.locked,
1479       auth: auth().unwrap(),
1480     });
1481     WebSocketService.Instance.send(wsClient.lockPost(form));
1482   }
1483
1484   handleModSticky(i: PostListing) {
1485     let form = new StickyPost({
1486       post_id: i.props.post_view.post.id,
1487       stickied: !i.props.post_view.post.stickied,
1488       auth: auth().unwrap(),
1489     });
1490     WebSocketService.Instance.send(wsClient.stickyPost(form));
1491   }
1492
1493   handleModBanFromCommunityShow(i: PostListing) {
1494     i.setState({
1495       showBanDialog: true,
1496       banType: BanType.Community,
1497       showRemoveDialog: false,
1498     });
1499   }
1500
1501   handleModBanShow(i: PostListing) {
1502     i.setState({
1503       showBanDialog: true,
1504       banType: BanType.Site,
1505       showRemoveDialog: false,
1506     });
1507   }
1508
1509   handlePurgePersonShow(i: PostListing) {
1510     i.setState({
1511       showPurgeDialog: true,
1512       purgeType: PurgeType.Person,
1513       showRemoveDialog: false,
1514     });
1515   }
1516
1517   handlePurgePostShow(i: PostListing) {
1518     i.setState({
1519       showPurgeDialog: true,
1520       purgeType: PurgeType.Post,
1521       showRemoveDialog: false,
1522     });
1523   }
1524
1525   handlePurgeReasonChange(i: PostListing, event: any) {
1526     i.setState({ purgeReason: Some(event.target.value) });
1527   }
1528
1529   handlePurgeSubmit(i: PostListing, event: any) {
1530     event.preventDefault();
1531
1532     if (i.state.purgeType == PurgeType.Person) {
1533       let form = new PurgePerson({
1534         person_id: i.props.post_view.creator.id,
1535         reason: i.state.purgeReason,
1536         auth: auth().unwrap(),
1537       });
1538       WebSocketService.Instance.send(wsClient.purgePerson(form));
1539     } else if (i.state.purgeType == PurgeType.Post) {
1540       let form = new PurgePost({
1541         post_id: i.props.post_view.post.id,
1542         reason: i.state.purgeReason,
1543         auth: auth().unwrap(),
1544       });
1545       WebSocketService.Instance.send(wsClient.purgePost(form));
1546     }
1547
1548     i.setState({ purgeLoading: true });
1549   }
1550
1551   handleModBanReasonChange(i: PostListing, event: any) {
1552     i.setState({ banReason: Some(event.target.value) });
1553   }
1554
1555   handleModBanExpireDaysChange(i: PostListing, event: any) {
1556     i.setState({ banExpireDays: Some(event.target.value) });
1557   }
1558
1559   handleModBanFromCommunitySubmit(i: PostListing) {
1560     i.setState({ banType: BanType.Community });
1561     i.handleModBanBothSubmit(i);
1562   }
1563
1564   handleModBanSubmit(i: PostListing) {
1565     i.setState({ banType: BanType.Site });
1566     i.handleModBanBothSubmit(i);
1567   }
1568
1569   handleModBanBothSubmit(i: PostListing, event?: any) {
1570     if (event) event.preventDefault();
1571
1572     if (i.state.banType == BanType.Community) {
1573       // If its an unban, restore all their data
1574       let ban = !i.props.post_view.creator_banned_from_community;
1575       if (ban == false) {
1576         i.setState({ removeData: false });
1577       }
1578       let form = new BanFromCommunity({
1579         person_id: i.props.post_view.creator.id,
1580         community_id: i.props.post_view.community.id,
1581         ban,
1582         remove_data: Some(i.state.removeData),
1583         reason: i.state.banReason,
1584         expires: i.state.banExpireDays.map(futureDaysToUnixTime),
1585         auth: auth().unwrap(),
1586       });
1587       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1588     } else {
1589       // If its an unban, restore all their data
1590       let ban = !i.props.post_view.creator.banned;
1591       if (ban == false) {
1592         i.setState({ removeData: false });
1593       }
1594       let form = new BanPerson({
1595         person_id: i.props.post_view.creator.id,
1596         ban,
1597         remove_data: Some(i.state.removeData),
1598         reason: i.state.banReason,
1599         expires: i.state.banExpireDays.map(futureDaysToUnixTime),
1600         auth: auth().unwrap(),
1601       });
1602       WebSocketService.Instance.send(wsClient.banPerson(form));
1603     }
1604
1605     i.setState({ showBanDialog: false });
1606   }
1607
1608   handleAddModToCommunity(i: PostListing) {
1609     let form = new AddModToCommunity({
1610       person_id: i.props.post_view.creator.id,
1611       community_id: i.props.post_view.community.id,
1612       added: !i.creatorIsMod_,
1613       auth: auth().unwrap(),
1614     });
1615     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1616     i.setState(i.state);
1617   }
1618
1619   handleAddAdmin(i: PostListing) {
1620     let form = new AddAdmin({
1621       person_id: i.props.post_view.creator.id,
1622       added: !i.creatorIsAdmin_,
1623       auth: auth().unwrap(),
1624     });
1625     WebSocketService.Instance.send(wsClient.addAdmin(form));
1626     i.setState(i.state);
1627   }
1628
1629   handleShowConfirmTransferCommunity(i: PostListing) {
1630     i.setState({ showConfirmTransferCommunity: true });
1631   }
1632
1633   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1634     i.setState({ showConfirmTransferCommunity: false });
1635   }
1636
1637   handleTransferCommunity(i: PostListing) {
1638     let form = new TransferCommunity({
1639       community_id: i.props.post_view.community.id,
1640       person_id: i.props.post_view.creator.id,
1641       auth: auth().unwrap(),
1642     });
1643     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1644     i.setState({ showConfirmTransferCommunity: false });
1645   }
1646
1647   handleShowConfirmTransferSite(i: PostListing) {
1648     i.setState({ showConfirmTransferSite: true });
1649   }
1650
1651   handleCancelShowConfirmTransferSite(i: PostListing) {
1652     i.setState({ showConfirmTransferSite: false });
1653   }
1654
1655   handleImageExpandClick(i: PostListing, event: any) {
1656     event.preventDefault();
1657     i.setState({ imageExpanded: !i.state.imageExpanded });
1658     setupTippy();
1659   }
1660
1661   handleViewSource(i: PostListing) {
1662     i.setState({ viewSource: !i.state.viewSource });
1663   }
1664
1665   handleShowAdvanced(i: PostListing) {
1666     i.setState({ showAdvanced: !i.state.showAdvanced });
1667     setupTippy();
1668   }
1669
1670   handleShowMoreMobile(i: PostListing) {
1671     i.setState({
1672       showMoreMobile: !i.state.showMoreMobile,
1673       showAdvanced: !i.state.showAdvanced,
1674     });
1675     setupTippy();
1676   }
1677
1678   handleShowBody(i: PostListing) {
1679     i.setState({ showBody: !i.state.showBody });
1680     setupTippy();
1681   }
1682
1683   get pointsTippy(): string {
1684     let points = i18n.t("number_of_points", {
1685       count: this.state.score,
1686       formattedCount: this.state.score,
1687     });
1688
1689     let upvotes = i18n.t("number_of_upvotes", {
1690       count: this.state.upvotes,
1691       formattedCount: this.state.upvotes,
1692     });
1693
1694     let downvotes = i18n.t("number_of_downvotes", {
1695       count: this.state.downvotes,
1696       formattedCount: this.state.downvotes,
1697     });
1698
1699     return `${points} • ${upvotes} • ${downvotes}`;
1700   }
1701
1702   get canModOnSelf_(): boolean {
1703     return canMod(
1704       this.props.moderators,
1705       this.props.admins,
1706       this.props.post_view.creator.id,
1707       undefined,
1708       true
1709     );
1710   }
1711
1712   get canMod_(): boolean {
1713     return canMod(
1714       this.props.moderators,
1715       this.props.admins,
1716       this.props.post_view.creator.id
1717     );
1718   }
1719
1720   get canAdmin_(): boolean {
1721     return canAdmin(this.props.admins, this.props.post_view.creator.id);
1722   }
1723
1724   get creatorIsMod_(): boolean {
1725     return isMod(this.props.moderators, this.props.post_view.creator.id);
1726   }
1727
1728   get creatorIsAdmin_(): boolean {
1729     return isAdmin(this.props.admins, this.props.post_view.creator.id);
1730   }
1731 }