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