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