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