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