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