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