]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post-listing.tsx
Add federated post and comment links. Fixes #569 (#575)
[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 } from "lemmy-js-client";
22 import { externalHost } from "../../env";
23 import { i18n } from "../../i18next";
24 import { BanType } from "../../interfaces";
25 import { UserService, WebSocketService } from "../../services";
26 import {
27   authField,
28   canMod,
29   futureDaysToUnixTime,
30   hostname,
31   isBanned,
32   isImage,
33   isMod,
34   isVideo,
35   md,
36   mdToHtml,
37   numToSI,
38   previewLines,
39   setupTippy,
40   showScores,
41   wsClient,
42 } from "../../utils";
43 import { Icon } from "../common/icon";
44 import { MomentTime } from "../common/moment-time";
45 import { PictrsImage } from "../common/pictrs-image";
46 import { CommunityLink } from "../community/community-link";
47 import { PersonListing } from "../person/person-listing";
48 import { MetadataCard } from "./metadata-card";
49 import { PostForm } from "./post-form";
50
51 interface PostListingState {
52   showEdit: boolean;
53   showRemoveDialog: boolean;
54   removeReason: string;
55   showBanDialog: boolean;
56   removeData: boolean;
57   banReason: string;
58   banExpireDays: number;
59   banType: BanType;
60   showConfirmTransferSite: boolean;
61   showConfirmTransferCommunity: boolean;
62   imageExpanded: boolean;
63   viewSource: boolean;
64   showAdvanced: boolean;
65   showMoreMobile: boolean;
66   showBody: boolean;
67   showReportDialog: boolean;
68   reportReason: string;
69   my_vote: number;
70   score: number;
71   upvotes: number;
72   downvotes: number;
73 }
74
75 interface PostListingProps {
76   post_view: PostView;
77   duplicates?: PostView[];
78   showCommunity?: boolean;
79   showBody?: boolean;
80   moderators?: CommunityModeratorView[];
81   admins?: PersonViewSafe[];
82   enableDownvotes: boolean;
83   enableNsfw: boolean;
84   viewOnly?: 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 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.local && (
449             <a className="ml-2" title={i18n.t("link")} href={post.ap_id}>
450               <Icon icon="fedilink" />
451             </a>
452           )}
453           {post.removed && (
454             <small className="ml-2 text-muted font-italic">
455               {i18n.t("removed")}
456             </small>
457           )}
458           {post.deleted && (
459             <small
460               className="unselectable pointer ml-2 text-muted font-italic"
461               data-tippy-content={i18n.t("deleted")}
462             >
463               <Icon icon="trash" classes="icon-inline text-danger" />
464             </small>
465           )}
466           {post.locked && (
467             <small
468               className="unselectable pointer ml-2 text-muted font-italic"
469               data-tippy-content={i18n.t("locked")}
470             >
471               <Icon icon="lock" classes="icon-inline text-danger" />
472             </small>
473           )}
474           {post.stickied && (
475             <small
476               className="unselectable pointer ml-2 text-muted font-italic"
477               data-tippy-content={i18n.t("stickied")}
478             >
479               <Icon icon="pin" classes="icon-inline text-primary" />
480             </small>
481           )}
482           {post.nsfw && (
483             <small className="ml-2 text-muted font-italic">
484               {i18n.t("nsfw")}
485             </small>
486           )}
487         </h5>
488       </div>
489     );
490   }
491
492   duplicatesLine() {
493     let dupes = this.props.duplicates;
494     return (
495       dupes &&
496       dupes.length > 0 && (
497         <ul class="list-inline mb-1 small text-muted">
498           <>
499             <li className="list-inline-item mr-2">
500               {i18n.t("cross_posted_to")}
501             </li>
502             {dupes.map(pv => (
503               <li className="list-inline-item mr-2">
504                 <Link to={`/post/${pv.post.id}`}>
505                   {pv.community.local
506                     ? pv.community.name
507                     : `${pv.community.name}@${hostname(pv.community.actor_id)}`}
508                 </Link>
509               </li>
510             ))}
511           </>
512         </ul>
513       )
514     );
515   }
516
517   commentsLine(mobile = false) {
518     return (
519       <div class="d-flex justify-content-start flex-wrap text-muted font-weight-bold mb-1">
520         {this.commentsButton}
521         {mobile && !this.props.viewOnly && this.mobileVotes}
522         {UserService.Instance.myUserInfo &&
523           !this.props.viewOnly &&
524           this.postActions(mobile)}
525       </div>
526     );
527   }
528
529   postActions(mobile = false) {
530     // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
531     // Possible enhancement: Make each button a component.
532     let post_view = this.props.post_view;
533     return (
534       <>
535         {this.saveButton}
536         {this.crossPostButton}
537         {mobile && this.showMoreButton}
538         {(!mobile || this.state.showAdvanced) && (
539           <>
540             {!this.myPost && (
541               <>
542                 {this.reportButton}
543                 {this.blockButton}
544               </>
545             )}
546             {this.myPost && (this.showBody || this.state.showAdvanced) && (
547               <>
548                 {this.editButton}
549                 {this.deleteButton}
550               </>
551             )}
552           </>
553         )}
554         {this.state.showAdvanced && (
555           <>
556             {this.showBody && post_view.post.body && this.viewSourceButton}
557             {this.canModOnSelf && (
558               <>
559                 {this.lockButton}
560                 {this.stickyButton}
561               </>
562             )}
563             {(this.canMod || this.canAdmin || true) && (
564               <>{this.modRemoveButton}</>
565             )}
566           </>
567         )}
568         {!mobile && this.showMoreButton}
569       </>
570     );
571   }
572
573   get commentsButton() {
574     let post_view = this.props.post_view;
575     return (
576       <button class="btn btn-link text-muted py-0 pl-0">
577         <Link
578           className="text-muted"
579           title={i18n.t("number_of_comments", {
580             count: post_view.counts.comments,
581             formattedCount: post_view.counts.comments,
582           })}
583           to={`/post/${post_view.post.id}?scrollToComments=true`}
584         >
585           <Icon icon="message-square" classes="mr-1" inline />
586           {i18n.t("number_of_comments", {
587             count: post_view.counts.comments,
588             formattedCount: numToSI(post_view.counts.comments),
589           })}
590         </Link>
591       </button>
592     );
593   }
594
595   get mobileVotes() {
596     // TODO: make nicer
597     let tippy = showScores() ? { "data-tippy-content": this.pointsTippy } : {};
598     return (
599       <>
600         <div>
601           <button
602             className={`btn-animate btn py-0 px-1 ${
603               this.state.my_vote == 1 ? "text-info" : "text-muted"
604             }`}
605             {...tippy}
606             onClick={linkEvent(this, this.handlePostLike)}
607             aria-label={i18n.t("upvote")}
608           >
609             <Icon icon="arrow-up1" classes="icon-inline small" />
610             {showScores() && (
611               <span class="ml-2">{numToSI(this.state.upvotes)}</span>
612             )}
613           </button>
614           {this.props.enableDownvotes && (
615             <button
616               className={`ml-2 btn-animate btn py-0 px-1 ${
617                 this.state.my_vote == -1 ? "text-danger" : "text-muted"
618               }`}
619               onClick={linkEvent(this, this.handlePostDisLike)}
620               {...tippy}
621               aria-label={i18n.t("downvote")}
622             >
623               <Icon icon="arrow-down1" classes="icon-inline small" />
624               {showScores() && (
625                 <span
626                   class={classNames("ml-2", {
627                     invisible: this.state.downvotes === 0,
628                   })}
629                 >
630                   {numToSI(this.state.downvotes)}
631                 </span>
632               )}
633             </button>
634           )}
635         </div>
636       </>
637     );
638   }
639
640   get saveButton() {
641     let saved = this.props.post_view.saved;
642     let label = saved ? i18n.t("unsave") : i18n.t("save");
643     return (
644       <button
645         class="btn btn-link btn-animate text-muted py-0"
646         onClick={linkEvent(this, this.handleSavePostClick)}
647         data-tippy-content={label}
648         aria-label={label}
649       >
650         <Icon
651           icon="star"
652           classes={classNames({ "text-warning": saved })}
653           inline
654         />
655       </button>
656     );
657   }
658
659   get crossPostButton() {
660     return (
661       <Link
662         className="btn btn-link btn-animate text-muted py-0"
663         to={`/create_post${this.crossPostParams}`}
664         title={i18n.t("cross_post")}
665       >
666         <Icon icon="copy" inline />
667       </Link>
668     );
669   }
670
671   get reportButton() {
672     return (
673       <button
674         class="btn btn-link btn-animate text-muted py-0"
675         onClick={linkEvent(this, this.handleShowReportDialog)}
676         data-tippy-content={i18n.t("show_report_dialog")}
677         aria-label={i18n.t("show_report_dialog")}
678       >
679         <Icon icon="flag" inline />
680       </button>
681     );
682   }
683
684   get blockButton() {
685     return (
686       <button
687         class="btn btn-link btn-animate text-muted py-0"
688         onClick={linkEvent(this, this.handleBlockUserClick)}
689         data-tippy-content={i18n.t("block_user")}
690         aria-label={i18n.t("block_user")}
691       >
692         <Icon icon="slash" inline />
693       </button>
694     );
695   }
696
697   get editButton() {
698     return (
699       <button
700         class="btn btn-link btn-animate text-muted py-0"
701         onClick={linkEvent(this, this.handleEditClick)}
702         data-tippy-content={i18n.t("edit")}
703         aria-label={i18n.t("edit")}
704       >
705         <Icon icon="edit" inline />
706       </button>
707     );
708   }
709
710   get deleteButton() {
711     let deleted = this.props.post_view.post.deleted;
712     let label = !deleted ? i18n.t("delete") : i18n.t("restore");
713     return (
714       <button
715         class="btn btn-link btn-animate text-muted py-0"
716         onClick={linkEvent(this, this.handleDeleteClick)}
717         data-tippy-content={label}
718         aria-label={label}
719       >
720         <Icon
721           icon="trash"
722           classes={classNames({ "text-danger": deleted })}
723           inline
724         />
725       </button>
726     );
727   }
728
729   get showMoreButton() {
730     return (
731       <button
732         class="btn btn-link btn-animate text-muted py-0"
733         onClick={linkEvent(this, this.handleShowAdvanced)}
734         data-tippy-content={i18n.t("more")}
735         aria-label={i18n.t("more")}
736       >
737         <Icon icon="more-vertical" inline />
738       </button>
739     );
740   }
741
742   get viewSourceButton() {
743     return (
744       <button
745         class="btn btn-link btn-animate text-muted py-0"
746         onClick={linkEvent(this, this.handleViewSource)}
747         data-tippy-content={i18n.t("view_source")}
748         aria-label={i18n.t("view_source")}
749       >
750         <Icon
751           icon="file-text"
752           classes={classNames({ "text-success": this.state.viewSource })}
753           inline
754         />
755       </button>
756     );
757   }
758
759   get lockButton() {
760     let locked = this.props.post_view.post.locked;
761     let label = locked ? i18n.t("unlock") : i18n.t("lock");
762     return (
763       <button
764         class="btn btn-link btn-animate text-muted py-0"
765         onClick={linkEvent(this, this.handleModLock)}
766         data-tippy-content={label}
767         aria-label={label}
768       >
769         <Icon
770           icon="lock"
771           classes={classNames({ "text-danger": locked })}
772           inline
773         />
774       </button>
775     );
776   }
777
778   get stickyButton() {
779     let stickied = this.props.post_view.post.stickied;
780     let label = stickied ? i18n.t("unsticky") : i18n.t("sticky");
781     return (
782       <button
783         class="btn btn-link btn-animate text-muted py-0"
784         onClick={linkEvent(this, this.handleModSticky)}
785         data-tippy-content={label}
786         aria-label={label}
787       >
788         <Icon
789           icon="pin"
790           classes={classNames({ "text-success": stickied })}
791           inline
792         />
793       </button>
794     );
795   }
796
797   get modRemoveButton() {
798     let removed = this.props.post_view.post.removed;
799     return (
800       <button
801         class="btn btn-link btn-animate text-muted py-0"
802         onClick={linkEvent(
803           this,
804           !removed ? this.handleModRemoveShow : this.handleModRemoveSubmit
805         )}
806       >
807         {/* TODO: Find an icon for this. */}
808         {!removed ? i18n.t("remove") : i18n.t("restore")}
809       </button>
810     );
811   }
812
813   /**
814    * Mod/Admin actions to be taken against the author.
815    */
816   userActionsLine() {
817     // TODO: make nicer
818     let post_view = this.props.post_view;
819     return (
820       this.state.showAdvanced && (
821         <>
822           {this.canMod && (
823             <>
824               {!this.creatorIsMod &&
825                 (!post_view.creator_banned_from_community ? (
826                   <button
827                     class="btn btn-link btn-animate text-muted py-0"
828                     onClick={linkEvent(
829                       this,
830                       this.handleModBanFromCommunityShow
831                     )}
832                     aria-label={i18n.t("ban")}
833                   >
834                     {i18n.t("ban")}
835                   </button>
836                 ) : (
837                   <button
838                     class="btn btn-link btn-animate text-muted py-0"
839                     onClick={linkEvent(
840                       this,
841                       this.handleModBanFromCommunitySubmit
842                     )}
843                     aria-label={i18n.t("unban")}
844                   >
845                     {i18n.t("unban")}
846                   </button>
847                 ))}
848               {!post_view.creator_banned_from_community && (
849                 <button
850                   class="btn btn-link btn-animate text-muted py-0"
851                   onClick={linkEvent(this, this.handleAddModToCommunity)}
852                   aria-label={
853                     this.creatorIsMod
854                       ? i18n.t("remove_as_mod")
855                       : i18n.t("appoint_as_mod")
856                   }
857                 >
858                   {this.creatorIsMod
859                     ? i18n.t("remove_as_mod")
860                     : i18n.t("appoint_as_mod")}
861                 </button>
862               )}
863             </>
864           )}
865           {/* Community creators and admins can transfer community to another mod */}
866           {(this.amCommunityCreator || this.canAdmin) &&
867             this.creatorIsMod &&
868             (!this.state.showConfirmTransferCommunity ? (
869               <button
870                 class="btn btn-link btn-animate text-muted py-0"
871                 onClick={linkEvent(
872                   this,
873                   this.handleShowConfirmTransferCommunity
874                 )}
875                 aria-label={i18n.t("transfer_community")}
876               >
877                 {i18n.t("transfer_community")}
878               </button>
879             ) : (
880               <>
881                 <button
882                   class="d-inline-block mr-1 btn btn-link btn-animate text-muted py-0"
883                   aria-label={i18n.t("are_you_sure")}
884                 >
885                   {i18n.t("are_you_sure")}
886                 </button>
887                 <button
888                   class="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
889                   aria-label={i18n.t("yes")}
890                   onClick={linkEvent(this, this.handleTransferCommunity)}
891                 >
892                   {i18n.t("yes")}
893                 </button>
894                 <button
895                   class="btn btn-link btn-animate text-muted py-0 d-inline-block"
896                   onClick={linkEvent(
897                     this,
898                     this.handleCancelShowConfirmTransferCommunity
899                   )}
900                   aria-label={i18n.t("no")}
901                 >
902                   {i18n.t("no")}
903                 </button>
904               </>
905             ))}
906           {/* Admins can ban from all, and appoint other admins */}
907           {this.canAdmin && (
908             <>
909               {!this.creatorIsAdmin &&
910                 (!isBanned(post_view.creator) ? (
911                   <button
912                     class="btn btn-link btn-animate text-muted py-0"
913                     onClick={linkEvent(this, this.handleModBanShow)}
914                     aria-label={i18n.t("ban_from_site")}
915                   >
916                     {i18n.t("ban_from_site")}
917                   </button>
918                 ) : (
919                   <button
920                     class="btn btn-link btn-animate text-muted py-0"
921                     onClick={linkEvent(this, this.handleModBanSubmit)}
922                     aria-label={i18n.t("unban_from_site")}
923                   >
924                     {i18n.t("unban_from_site")}
925                   </button>
926                 ))}
927               {!isBanned(post_view.creator) && post_view.creator.local && (
928                 <button
929                   class="btn btn-link btn-animate text-muted py-0"
930                   onClick={linkEvent(this, this.handleAddAdmin)}
931                   aria-label={
932                     this.creatorIsAdmin
933                       ? i18n.t("remove_as_admin")
934                       : i18n.t("appoint_as_admin")
935                   }
936                 >
937                   {this.creatorIsAdmin
938                     ? i18n.t("remove_as_admin")
939                     : i18n.t("appoint_as_admin")}
940                 </button>
941               )}
942             </>
943           )}
944         </>
945       )
946     );
947   }
948
949   removeAndBanDialogs() {
950     let post = this.props.post_view;
951     return (
952       <>
953         {this.state.showRemoveDialog && (
954           <form
955             class="form-inline"
956             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
957           >
958             <label class="sr-only" htmlFor="post-listing-remove-reason">
959               {i18n.t("reason")}
960             </label>
961             <input
962               type="text"
963               id="post-listing-remove-reason"
964               class="form-control mr-2"
965               placeholder={i18n.t("reason")}
966               value={this.state.removeReason}
967               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
968             />
969             <button
970               type="submit"
971               class="btn btn-secondary"
972               aria-label={i18n.t("remove_post")}
973             >
974               {i18n.t("remove_post")}
975             </button>
976           </form>
977         )}
978         {this.state.showBanDialog && (
979           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
980             <div class="form-group row col-12">
981               <label class="col-form-label" htmlFor="post-listing-ban-reason">
982                 {i18n.t("reason")}
983               </label>
984               <input
985                 type="text"
986                 id="post-listing-ban-reason"
987                 class="form-control mr-2"
988                 placeholder={i18n.t("reason")}
989                 value={this.state.banReason}
990                 onInput={linkEvent(this, this.handleModBanReasonChange)}
991               />
992               <label class="col-form-label" htmlFor={`mod-ban-expires`}>
993                 {i18n.t("expires")}
994               </label>
995               <input
996                 type="number"
997                 id={`mod-ban-expires`}
998                 class="form-control mr-2"
999                 placeholder={i18n.t("number_of_days")}
1000                 value={this.state.banExpireDays}
1001                 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
1002               />
1003               <div class="form-group">
1004                 <div class="form-check">
1005                   <input
1006                     class="form-check-input"
1007                     id="mod-ban-remove-data"
1008                     type="checkbox"
1009                     checked={this.state.removeData}
1010                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
1011                   />
1012                   <label
1013                     class="form-check-label"
1014                     htmlFor="mod-ban-remove-data"
1015                     title={i18n.t("remove_content_more")}
1016                   >
1017                     {i18n.t("remove_content")}
1018                   </label>
1019                 </div>
1020               </div>
1021             </div>
1022             {/* TODO hold off on expires until later */}
1023             {/* <div class="form-group row"> */}
1024             {/*   <label class="col-form-label">Expires</label> */}
1025             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1026             {/* </div> */}
1027             <div class="form-group row">
1028               <button
1029                 type="submit"
1030                 class="btn btn-secondary"
1031                 aria-label={i18n.t("ban")}
1032               >
1033                 {i18n.t("ban")} {post.creator.name}
1034               </button>
1035             </div>
1036           </form>
1037         )}
1038         {this.state.showReportDialog && (
1039           <form
1040             class="form-inline"
1041             onSubmit={linkEvent(this, this.handleReportSubmit)}
1042           >
1043             <label class="sr-only" htmlFor="post-report-reason">
1044               {i18n.t("reason")}
1045             </label>
1046             <input
1047               type="text"
1048               id="post-report-reason"
1049               class="form-control mr-2"
1050               placeholder={i18n.t("reason")}
1051               required
1052               value={this.state.reportReason}
1053               onInput={linkEvent(this, this.handleReportReasonChange)}
1054             />
1055             <button
1056               type="submit"
1057               class="btn btn-secondary"
1058               aria-label={i18n.t("create_report")}
1059             >
1060               {i18n.t("create_report")}
1061             </button>
1062           </form>
1063         )}
1064       </>
1065     );
1066   }
1067
1068   mobileThumbnail() {
1069     let post = this.props.post_view.post;
1070     return post.thumbnail_url || isImage(post.url) ? (
1071       <div class="row">
1072         <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
1073           {this.postTitleLine()}
1074         </div>
1075         <div class="col-4">
1076           {/* Post body prev or thumbnail */}
1077           {!this.state.imageExpanded && this.thumbnail()}
1078         </div>
1079       </div>
1080     ) : (
1081       this.postTitleLine()
1082     );
1083   }
1084
1085   showMobilePreview() {
1086     let post = this.props.post_view.post;
1087     return (
1088       post.body &&
1089       !this.showBody && (
1090         <div
1091           className="md-div mb-1"
1092           dangerouslySetInnerHTML={{
1093             __html: md.render(previewLines(post.body)),
1094           }}
1095         />
1096       )
1097     );
1098   }
1099
1100   listing() {
1101     return (
1102       <>
1103         {/* The mobile view*/}
1104         <div class="d-block d-sm-none">
1105           <div class="row">
1106             <div class="col-12">
1107               {this.createdLine()}
1108
1109               {/* If it has a thumbnail, do a right aligned thumbnail */}
1110               {this.mobileThumbnail()}
1111
1112               {/* Show a preview of the post body */}
1113               {this.showMobilePreview()}
1114
1115               {this.commentsLine(true)}
1116               {this.userActionsLine()}
1117               {this.duplicatesLine()}
1118               {this.removeAndBanDialogs()}
1119             </div>
1120           </div>
1121         </div>
1122
1123         {/* The larger view*/}
1124         <div class="d-none d-sm-block">
1125           <div class="row">
1126             {!this.props.viewOnly && this.voteBar()}
1127             <div class="col-sm-2 pr-0">
1128               <div class="">{this.thumbnail()}</div>
1129             </div>
1130             <div class="col-12 col-sm-9">
1131               <div class="row">
1132                 <div className="col-12">
1133                   {this.postTitleLine()}
1134                   {this.createdLine()}
1135                   {this.commentsLine()}
1136                   {this.duplicatesLine()}
1137                   {this.userActionsLine()}
1138                   {this.removeAndBanDialogs()}
1139                 </div>
1140               </div>
1141             </div>
1142           </div>
1143         </div>
1144       </>
1145     );
1146   }
1147
1148   private get myPost(): boolean {
1149     return (
1150       UserService.Instance.myUserInfo &&
1151       this.props.post_view.creator.id ==
1152         UserService.Instance.myUserInfo.local_user_view.person.id
1153     );
1154   }
1155
1156   get creatorIsMod(): boolean {
1157     return (
1158       this.props.moderators &&
1159       isMod(
1160         this.props.moderators.map(m => m.moderator.id),
1161         this.props.post_view.creator.id
1162       )
1163     );
1164   }
1165
1166   get creatorIsAdmin(): boolean {
1167     return (
1168       this.props.admins &&
1169       isMod(
1170         this.props.admins.map(a => a.person.id),
1171         this.props.post_view.creator.id
1172       )
1173     );
1174   }
1175
1176   /**
1177    * If the current user is allowed to mod this post.
1178    * The creator of this post is not allowed even if they are a mod.
1179    */
1180   get canMod(): boolean {
1181     if (this.props.admins && this.props.moderators) {
1182       let adminsThenMods = this.props.admins
1183         .map(a => a.person.id)
1184         .concat(this.props.moderators.map(m => m.moderator.id));
1185
1186       return canMod(
1187         UserService.Instance.myUserInfo,
1188         adminsThenMods,
1189         this.props.post_view.creator.id
1190       );
1191     } else {
1192       return false;
1193     }
1194   }
1195
1196   /**
1197    * If the current user is allowed to mod this post.
1198    * The creator of this post is allowed if they are a mod.
1199    */
1200   get canModOnSelf(): boolean {
1201     if (this.props.admins && this.props.moderators) {
1202       let adminsThenMods = this.props.admins
1203         .map(a => a.person.id)
1204         .concat(this.props.moderators.map(m => m.moderator.id));
1205
1206       return canMod(
1207         UserService.Instance.myUserInfo,
1208         adminsThenMods,
1209         this.props.post_view.creator.id,
1210         true
1211       );
1212     } else {
1213       return false;
1214     }
1215   }
1216
1217   get canAdmin(): boolean {
1218     return (
1219       this.props.admins &&
1220       canMod(
1221         UserService.Instance.myUserInfo,
1222         this.props.admins.map(a => a.person.id),
1223         this.props.post_view.creator.id
1224       )
1225     );
1226   }
1227
1228   get amCommunityCreator(): boolean {
1229     return (
1230       this.props.moderators &&
1231       UserService.Instance.myUserInfo &&
1232       this.props.post_view.creator.id !=
1233         UserService.Instance.myUserInfo.local_user_view.person.id &&
1234       UserService.Instance.myUserInfo.local_user_view.person.id ==
1235         this.props.moderators[0].moderator.id
1236     );
1237   }
1238
1239   get amSiteCreator(): boolean {
1240     return (
1241       this.props.admins &&
1242       UserService.Instance.myUserInfo &&
1243       this.props.post_view.creator.id !=
1244         UserService.Instance.myUserInfo.local_user_view.person.id &&
1245       UserService.Instance.myUserInfo.local_user_view.person.id ==
1246         this.props.admins[0].person.id
1247     );
1248   }
1249
1250   handlePostLike(i: PostListing, event: any) {
1251     event.preventDefault();
1252     if (!UserService.Instance.myUserInfo) {
1253       this.context.router.history.push(`/login`);
1254     }
1255
1256     let new_vote = i.state.my_vote == 1 ? 0 : 1;
1257
1258     if (i.state.my_vote == 1) {
1259       i.state.score--;
1260       i.state.upvotes--;
1261     } else if (i.state.my_vote == -1) {
1262       i.state.downvotes--;
1263       i.state.upvotes++;
1264       i.state.score += 2;
1265     } else {
1266       i.state.upvotes++;
1267       i.state.score++;
1268     }
1269
1270     i.state.my_vote = new_vote;
1271
1272     let form: CreatePostLike = {
1273       post_id: i.props.post_view.post.id,
1274       score: i.state.my_vote,
1275       auth: authField(),
1276     };
1277
1278     WebSocketService.Instance.send(wsClient.likePost(form));
1279     i.setState(i.state);
1280     setupTippy();
1281   }
1282
1283   handlePostDisLike(i: PostListing, event: any) {
1284     event.preventDefault();
1285     if (!UserService.Instance.myUserInfo) {
1286       this.context.router.history.push(`/login`);
1287     }
1288
1289     let new_vote = i.state.my_vote == -1 ? 0 : -1;
1290
1291     if (i.state.my_vote == 1) {
1292       i.state.score -= 2;
1293       i.state.upvotes--;
1294       i.state.downvotes++;
1295     } else if (i.state.my_vote == -1) {
1296       i.state.downvotes--;
1297       i.state.score++;
1298     } else {
1299       i.state.downvotes++;
1300       i.state.score--;
1301     }
1302
1303     i.state.my_vote = new_vote;
1304
1305     let form: CreatePostLike = {
1306       post_id: i.props.post_view.post.id,
1307       score: i.state.my_vote,
1308       auth: authField(),
1309     };
1310
1311     WebSocketService.Instance.send(wsClient.likePost(form));
1312     i.setState(i.state);
1313     setupTippy();
1314   }
1315
1316   handleEditClick(i: PostListing) {
1317     i.state.showEdit = true;
1318     i.setState(i.state);
1319   }
1320
1321   handleEditCancel() {
1322     this.state.showEdit = false;
1323     this.setState(this.state);
1324   }
1325
1326   // The actual editing is done in the recieve for post
1327   handleEditPost() {
1328     this.state.showEdit = false;
1329     this.setState(this.state);
1330   }
1331
1332   handleShowReportDialog(i: PostListing) {
1333     i.state.showReportDialog = !i.state.showReportDialog;
1334     i.setState(this.state);
1335   }
1336
1337   handleReportReasonChange(i: PostListing, event: any) {
1338     i.state.reportReason = event.target.value;
1339     i.setState(i.state);
1340   }
1341
1342   handleReportSubmit(i: PostListing, event: any) {
1343     event.preventDefault();
1344     let form: CreatePostReport = {
1345       post_id: i.props.post_view.post.id,
1346       reason: i.state.reportReason,
1347       auth: authField(),
1348     };
1349     WebSocketService.Instance.send(wsClient.createPostReport(form));
1350
1351     i.state.showReportDialog = false;
1352     i.setState(i.state);
1353   }
1354
1355   handleBlockUserClick(i: PostListing) {
1356     let blockUserForm: BlockPerson = {
1357       person_id: i.props.post_view.creator.id,
1358       block: true,
1359       auth: authField(),
1360     };
1361     WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
1362   }
1363
1364   handleDeleteClick(i: PostListing) {
1365     let deleteForm: DeletePost = {
1366       post_id: i.props.post_view.post.id,
1367       deleted: !i.props.post_view.post.deleted,
1368       auth: authField(),
1369     };
1370     WebSocketService.Instance.send(wsClient.deletePost(deleteForm));
1371   }
1372
1373   handleSavePostClick(i: PostListing) {
1374     let saved =
1375       i.props.post_view.saved == undefined ? true : !i.props.post_view.saved;
1376     let form: SavePost = {
1377       post_id: i.props.post_view.post.id,
1378       save: saved,
1379       auth: authField(),
1380     };
1381
1382     WebSocketService.Instance.send(wsClient.savePost(form));
1383   }
1384
1385   get crossPostParams(): string {
1386     let post = this.props.post_view.post;
1387     let params = `?title=${encodeURIComponent(post.name)}`;
1388
1389     if (post.url) {
1390       params += `&url=${encodeURIComponent(post.url)}`;
1391     }
1392     if (post.body) {
1393       params += `&body=${encodeURIComponent(this.crossPostBody())}`;
1394     }
1395     return params;
1396   }
1397
1398   crossPostBody(): string {
1399     let post = this.props.post_view.post;
1400     let body = `${i18n.t("cross_posted_from")} ${
1401       post.ap_id
1402     }\n\n${post.body.replace(/^/gm, "> ")}`;
1403     return body;
1404   }
1405
1406   get showBody(): boolean {
1407     return this.props.showBody || this.state.showBody;
1408   }
1409
1410   handleModRemoveShow(i: PostListing) {
1411     i.state.showRemoveDialog = !i.state.showRemoveDialog;
1412     i.state.showBanDialog = false;
1413     i.setState(i.state);
1414   }
1415
1416   handleModRemoveReasonChange(i: PostListing, event: any) {
1417     i.state.removeReason = event.target.value;
1418     i.setState(i.state);
1419   }
1420
1421   handleModRemoveDataChange(i: PostListing, event: any) {
1422     i.state.removeData = event.target.checked;
1423     i.setState(i.state);
1424   }
1425
1426   handleModRemoveSubmit(i: PostListing, event: any) {
1427     event.preventDefault();
1428     let form: RemovePost = {
1429       post_id: i.props.post_view.post.id,
1430       removed: !i.props.post_view.post.removed,
1431       reason: i.state.removeReason,
1432       auth: authField(),
1433     };
1434     WebSocketService.Instance.send(wsClient.removePost(form));
1435
1436     i.state.showRemoveDialog = false;
1437     i.setState(i.state);
1438   }
1439
1440   handleModLock(i: PostListing) {
1441     let form: LockPost = {
1442       post_id: i.props.post_view.post.id,
1443       locked: !i.props.post_view.post.locked,
1444       auth: authField(),
1445     };
1446     WebSocketService.Instance.send(wsClient.lockPost(form));
1447   }
1448
1449   handleModSticky(i: PostListing) {
1450     let form: StickyPost = {
1451       post_id: i.props.post_view.post.id,
1452       stickied: !i.props.post_view.post.stickied,
1453       auth: authField(),
1454     };
1455     WebSocketService.Instance.send(wsClient.stickyPost(form));
1456   }
1457
1458   handleModBanFromCommunityShow(i: PostListing) {
1459     i.state.showBanDialog = true;
1460     i.state.banType = BanType.Community;
1461     i.state.showRemoveDialog = false;
1462     i.setState(i.state);
1463   }
1464
1465   handleModBanShow(i: PostListing) {
1466     i.state.showBanDialog = true;
1467     i.state.banType = BanType.Site;
1468     i.state.showRemoveDialog = false;
1469     i.setState(i.state);
1470   }
1471
1472   handleModBanReasonChange(i: PostListing, event: any) {
1473     i.state.banReason = event.target.value;
1474     i.setState(i.state);
1475   }
1476
1477   handleModBanExpireDaysChange(i: PostListing, event: any) {
1478     i.state.banExpireDays = event.target.value;
1479     i.setState(i.state);
1480   }
1481
1482   handleModBanFromCommunitySubmit(i: PostListing) {
1483     i.state.banType = BanType.Community;
1484     i.setState(i.state);
1485     i.handleModBanBothSubmit(i);
1486   }
1487
1488   handleModBanSubmit(i: PostListing) {
1489     i.state.banType = BanType.Site;
1490     i.setState(i.state);
1491     i.handleModBanBothSubmit(i);
1492   }
1493
1494   handleModBanBothSubmit(i: PostListing, event?: any) {
1495     if (event) event.preventDefault();
1496
1497     if (i.state.banType == BanType.Community) {
1498       // If its an unban, restore all their data
1499       let ban = !i.props.post_view.creator_banned_from_community;
1500       if (ban == false) {
1501         i.state.removeData = false;
1502       }
1503       let form: BanFromCommunity = {
1504         person_id: i.props.post_view.creator.id,
1505         community_id: i.props.post_view.community.id,
1506         ban,
1507         remove_data: i.state.removeData,
1508         reason: i.state.banReason,
1509         expires: futureDaysToUnixTime(i.state.banExpireDays),
1510         auth: authField(),
1511       };
1512       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1513     } else {
1514       // If its an unban, restore all their data
1515       let ban = !i.props.post_view.creator.banned;
1516       if (ban == false) {
1517         i.state.removeData = false;
1518       }
1519       let form: BanPerson = {
1520         person_id: i.props.post_view.creator.id,
1521         ban,
1522         remove_data: i.state.removeData,
1523         reason: i.state.banReason,
1524         expires: futureDaysToUnixTime(i.state.banExpireDays),
1525         auth: authField(),
1526       };
1527       WebSocketService.Instance.send(wsClient.banPerson(form));
1528     }
1529
1530     i.state.showBanDialog = false;
1531     i.setState(i.state);
1532   }
1533
1534   handleAddModToCommunity(i: PostListing) {
1535     let form: AddModToCommunity = {
1536       person_id: i.props.post_view.creator.id,
1537       community_id: i.props.post_view.community.id,
1538       added: !i.creatorIsMod,
1539       auth: authField(),
1540     };
1541     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1542     i.setState(i.state);
1543   }
1544
1545   handleAddAdmin(i: PostListing) {
1546     let form: AddAdmin = {
1547       person_id: i.props.post_view.creator.id,
1548       added: !i.creatorIsAdmin,
1549       auth: authField(),
1550     };
1551     WebSocketService.Instance.send(wsClient.addAdmin(form));
1552     i.setState(i.state);
1553   }
1554
1555   handleShowConfirmTransferCommunity(i: PostListing) {
1556     i.state.showConfirmTransferCommunity = true;
1557     i.setState(i.state);
1558   }
1559
1560   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1561     i.state.showConfirmTransferCommunity = false;
1562     i.setState(i.state);
1563   }
1564
1565   handleTransferCommunity(i: PostListing) {
1566     let form: TransferCommunity = {
1567       community_id: i.props.post_view.community.id,
1568       person_id: i.props.post_view.creator.id,
1569       auth: authField(),
1570     };
1571     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1572     i.state.showConfirmTransferCommunity = false;
1573     i.setState(i.state);
1574   }
1575
1576   handleShowConfirmTransferSite(i: PostListing) {
1577     i.state.showConfirmTransferSite = true;
1578     i.setState(i.state);
1579   }
1580
1581   handleCancelShowConfirmTransferSite(i: PostListing) {
1582     i.state.showConfirmTransferSite = false;
1583     i.setState(i.state);
1584   }
1585
1586   handleImageExpandClick(i: PostListing, event: any) {
1587     event.preventDefault();
1588     i.state.imageExpanded = !i.state.imageExpanded;
1589     i.setState(i.state);
1590     setupTippy();
1591   }
1592
1593   handleViewSource(i: PostListing) {
1594     i.state.viewSource = !i.state.viewSource;
1595     i.setState(i.state);
1596   }
1597
1598   handleShowAdvanced(i: PostListing) {
1599     i.state.showAdvanced = !i.state.showAdvanced;
1600     i.setState(i.state);
1601     setupTippy();
1602   }
1603
1604   handleShowMoreMobile(i: PostListing) {
1605     i.state.showMoreMobile = !i.state.showMoreMobile;
1606     i.state.showAdvanced = !i.state.showAdvanced;
1607     i.setState(i.state);
1608     setupTippy();
1609   }
1610
1611   handleShowBody(i: PostListing) {
1612     i.state.showBody = !i.state.showBody;
1613     i.setState(i.state);
1614     setupTippy();
1615   }
1616
1617   get pointsTippy(): string {
1618     let points = i18n.t("number_of_points", {
1619       count: this.state.score,
1620       formattedCount: this.state.score,
1621     });
1622
1623     let upvotes = i18n.t("number_of_upvotes", {
1624       count: this.state.upvotes,
1625       formattedCount: this.state.upvotes,
1626     });
1627
1628     let downvotes = i18n.t("number_of_downvotes", {
1629       count: this.state.downvotes,
1630       formattedCount: this.state.downvotes,
1631     });
1632
1633     return `${points} • ${upvotes} • ${downvotes}`;
1634   }
1635 }