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