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