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