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