]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post-listing.tsx
f4374c24e594f87fa745c9f57c9d2652334d269a
[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 { MetadataCard } from "./metadata-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             <MetadataCard 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 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}?scrollToComments=true`}
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
1000                     class="form-check-label"
1001                     htmlFor="mod-ban-remove-data"
1002                     title={i18n.t("remove_content_more")}
1003                   >
1004                     {i18n.t("remove_content")}
1005                   </label>
1006                 </div>
1007               </div>
1008             </div>
1009             {/* TODO hold off on expires until later */}
1010             {/* <div class="form-group row"> */}
1011             {/*   <label class="col-form-label">Expires</label> */}
1012             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1013             {/* </div> */}
1014             <div class="form-group row">
1015               <button
1016                 type="submit"
1017                 class="btn btn-secondary"
1018                 aria-label={i18n.t("ban")}
1019               >
1020                 {i18n.t("ban")} {post.creator.name}
1021               </button>
1022             </div>
1023           </form>
1024         )}
1025       </>
1026     );
1027   }
1028
1029   mobileThumbnail() {
1030     let post = this.props.post_view.post;
1031     return post.thumbnail_url || isImage(post.url) ? (
1032       <div class="row">
1033         <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
1034           {this.postTitleLine()}
1035         </div>
1036         <div class="col-4">
1037           {/* Post body prev or thumbnail */}
1038           {!this.state.imageExpanded && this.thumbnail()}
1039         </div>
1040       </div>
1041     ) : (
1042       this.postTitleLine()
1043     );
1044   }
1045
1046   showMobilePreview() {
1047     let post = this.props.post_view.post;
1048     return (
1049       post.body &&
1050       !this.showBody && (
1051         <div
1052           className="md-div mb-1"
1053           dangerouslySetInnerHTML={{
1054             __html: md.render(previewLines(post.body)),
1055           }}
1056         />
1057       )
1058     );
1059   }
1060
1061   listing() {
1062     return (
1063       <>
1064         {/* The mobile view*/}
1065         <div class="d-block d-sm-none">
1066           <div class="row">
1067             <div class="col-12">
1068               {this.createdLine()}
1069
1070               {/* If it has a thumbnail, do a right aligned thumbnail */}
1071               {this.mobileThumbnail()}
1072
1073               {/* Show a preview of the post body */}
1074               {this.showMobilePreview()}
1075
1076               {this.commentsLine(true)}
1077               {this.duplicatesLine()}
1078               {this.removeAndBanDialogs()}
1079             </div>
1080           </div>
1081         </div>
1082
1083         {/* The larger view*/}
1084         <div class="d-none d-sm-block">
1085           <div class="row">
1086             {this.voteBar()}
1087             {!this.state.imageExpanded && (
1088               <div class="col-sm-2 pr-0">
1089                 <div class="">{this.thumbnail()}</div>
1090               </div>
1091             )}
1092             <div
1093               class={`${
1094                 this.state.imageExpanded ? "col-12" : "col-12 col-sm-9"
1095               }`}
1096             >
1097               <div class="row">
1098                 <div className="col-12">
1099                   {this.postTitleLine()}
1100                   {this.createdLine()}
1101                   {this.commentsLine()}
1102                   {this.duplicatesLine()}
1103                   {this.postActions()}
1104                   {this.removeAndBanDialogs()}
1105                 </div>
1106               </div>
1107             </div>
1108           </div>
1109         </div>
1110       </>
1111     );
1112   }
1113
1114   private get myPost(): boolean {
1115     return (
1116       UserService.Instance.localUserView &&
1117       this.props.post_view.creator.id ==
1118         UserService.Instance.localUserView.person.id
1119     );
1120   }
1121
1122   get isMod(): boolean {
1123     return (
1124       this.props.moderators &&
1125       isMod(
1126         this.props.moderators.map(m => m.moderator.id),
1127         this.props.post_view.creator.id
1128       )
1129     );
1130   }
1131
1132   get isAdmin(): boolean {
1133     return (
1134       this.props.admins &&
1135       isMod(
1136         this.props.admins.map(a => a.person.id),
1137         this.props.post_view.creator.id
1138       )
1139     );
1140   }
1141
1142   get canMod(): boolean {
1143     if (this.props.admins && this.props.moderators) {
1144       let adminsThenMods = this.props.admins
1145         .map(a => a.person.id)
1146         .concat(this.props.moderators.map(m => m.moderator.id));
1147
1148       return canMod(
1149         UserService.Instance.localUserView,
1150         adminsThenMods,
1151         this.props.post_view.creator.id
1152       );
1153     } else {
1154       return false;
1155     }
1156   }
1157
1158   get canModOnSelf(): boolean {
1159     if (this.props.admins && this.props.moderators) {
1160       let adminsThenMods = this.props.admins
1161         .map(a => a.person.id)
1162         .concat(this.props.moderators.map(m => m.moderator.id));
1163
1164       return canMod(
1165         UserService.Instance.localUserView,
1166         adminsThenMods,
1167         this.props.post_view.creator.id,
1168         true
1169       );
1170     } else {
1171       return false;
1172     }
1173   }
1174
1175   get canAdmin(): boolean {
1176     return (
1177       this.props.admins &&
1178       canMod(
1179         UserService.Instance.localUserView,
1180         this.props.admins.map(a => a.person.id),
1181         this.props.post_view.creator.id
1182       )
1183     );
1184   }
1185
1186   get amCommunityCreator(): boolean {
1187     return (
1188       this.props.moderators &&
1189       UserService.Instance.localUserView &&
1190       this.props.post_view.creator.id !=
1191         UserService.Instance.localUserView.person.id &&
1192       UserService.Instance.localUserView.person.id ==
1193         this.props.moderators[0].moderator.id
1194     );
1195   }
1196
1197   get amSiteCreator(): boolean {
1198     return (
1199       this.props.admins &&
1200       UserService.Instance.localUserView &&
1201       this.props.post_view.creator.id !=
1202         UserService.Instance.localUserView.person.id &&
1203       UserService.Instance.localUserView.person.id ==
1204         this.props.admins[0].person.id
1205     );
1206   }
1207
1208   handlePostLike(i: PostListing, event: any) {
1209     event.preventDefault();
1210     if (!UserService.Instance.localUserView) {
1211       this.context.router.history.push(`/login`);
1212     }
1213
1214     let new_vote = i.state.my_vote == 1 ? 0 : 1;
1215
1216     if (i.state.my_vote == 1) {
1217       i.state.score--;
1218       i.state.upvotes--;
1219     } else if (i.state.my_vote == -1) {
1220       i.state.downvotes--;
1221       i.state.upvotes++;
1222       i.state.score += 2;
1223     } else {
1224       i.state.upvotes++;
1225       i.state.score++;
1226     }
1227
1228     i.state.my_vote = new_vote;
1229
1230     let form: CreatePostLike = {
1231       post_id: i.props.post_view.post.id,
1232       score: i.state.my_vote,
1233       auth: authField(),
1234     };
1235
1236     WebSocketService.Instance.send(wsClient.likePost(form));
1237     i.setState(i.state);
1238     setupTippy();
1239   }
1240
1241   handlePostDisLike(i: PostListing, event: any) {
1242     event.preventDefault();
1243     if (!UserService.Instance.localUserView) {
1244       this.context.router.history.push(`/login`);
1245     }
1246
1247     let new_vote = i.state.my_vote == -1 ? 0 : -1;
1248
1249     if (i.state.my_vote == 1) {
1250       i.state.score -= 2;
1251       i.state.upvotes--;
1252       i.state.downvotes++;
1253     } else if (i.state.my_vote == -1) {
1254       i.state.downvotes--;
1255       i.state.score++;
1256     } else {
1257       i.state.downvotes++;
1258       i.state.score--;
1259     }
1260
1261     i.state.my_vote = new_vote;
1262
1263     let form: CreatePostLike = {
1264       post_id: i.props.post_view.post.id,
1265       score: i.state.my_vote,
1266       auth: authField(),
1267     };
1268
1269     WebSocketService.Instance.send(wsClient.likePost(form));
1270     i.setState(i.state);
1271     setupTippy();
1272   }
1273
1274   handleEditClick(i: PostListing) {
1275     i.state.showEdit = true;
1276     i.setState(i.state);
1277   }
1278
1279   handleEditCancel() {
1280     this.state.showEdit = false;
1281     this.setState(this.state);
1282   }
1283
1284   // The actual editing is done in the recieve for post
1285   handleEditPost() {
1286     this.state.showEdit = false;
1287     this.setState(this.state);
1288   }
1289
1290   handleDeleteClick(i: PostListing) {
1291     let deleteForm: DeletePost = {
1292       post_id: i.props.post_view.post.id,
1293       deleted: !i.props.post_view.post.deleted,
1294       auth: authField(),
1295     };
1296     WebSocketService.Instance.send(wsClient.deletePost(deleteForm));
1297   }
1298
1299   handleSavePostClick(i: PostListing) {
1300     let saved =
1301       i.props.post_view.saved == undefined ? true : !i.props.post_view.saved;
1302     let form: SavePost = {
1303       post_id: i.props.post_view.post.id,
1304       save: saved,
1305       auth: authField(),
1306     };
1307
1308     WebSocketService.Instance.send(wsClient.savePost(form));
1309   }
1310
1311   get crossPostParams(): string {
1312     let post = this.props.post_view.post;
1313     let params = `?title=${encodeURIComponent(post.name)}`;
1314
1315     if (post.url) {
1316       params += `&url=${encodeURIComponent(post.url)}`;
1317     }
1318     if (post.body) {
1319       params += `&body=${encodeURIComponent(this.crossPostBody())}`;
1320     }
1321     return params;
1322   }
1323
1324   crossPostBody(): string {
1325     let post = this.props.post_view.post;
1326     let body = `${i18n.t("cross_posted_from")} ${
1327       post.ap_id
1328     }\n\n${post.body.replace(/^/gm, "> ")}`;
1329     return body;
1330   }
1331
1332   get showBody(): boolean {
1333     return this.props.showBody || this.state.showBody;
1334   }
1335
1336   handleModRemoveShow(i: PostListing) {
1337     i.state.showRemoveDialog = true;
1338     i.setState(i.state);
1339   }
1340
1341   handleModRemoveReasonChange(i: PostListing, event: any) {
1342     i.state.removeReason = event.target.value;
1343     i.setState(i.state);
1344   }
1345
1346   handleModRemoveDataChange(i: PostListing, event: any) {
1347     i.state.removeData = event.target.checked;
1348     i.setState(i.state);
1349   }
1350
1351   handleModRemoveSubmit(i: PostListing, event: any) {
1352     event.preventDefault();
1353     let form: RemovePost = {
1354       post_id: i.props.post_view.post.id,
1355       removed: !i.props.post_view.post.removed,
1356       reason: i.state.removeReason,
1357       auth: authField(),
1358     };
1359     WebSocketService.Instance.send(wsClient.removePost(form));
1360
1361     i.state.showRemoveDialog = false;
1362     i.setState(i.state);
1363   }
1364
1365   handleModLock(i: PostListing) {
1366     let form: LockPost = {
1367       post_id: i.props.post_view.post.id,
1368       locked: !i.props.post_view.post.locked,
1369       auth: authField(),
1370     };
1371     WebSocketService.Instance.send(wsClient.lockPost(form));
1372   }
1373
1374   handleModSticky(i: PostListing) {
1375     let form: StickyPost = {
1376       post_id: i.props.post_view.post.id,
1377       stickied: !i.props.post_view.post.stickied,
1378       auth: authField(),
1379     };
1380     WebSocketService.Instance.send(wsClient.stickyPost(form));
1381   }
1382
1383   handleModBanFromCommunityShow(i: PostListing) {
1384     i.state.showBanDialog = true;
1385     i.state.banType = BanType.Community;
1386     i.setState(i.state);
1387   }
1388
1389   handleModBanShow(i: PostListing) {
1390     i.state.showBanDialog = true;
1391     i.state.banType = BanType.Site;
1392     i.setState(i.state);
1393   }
1394
1395   handleModBanReasonChange(i: PostListing, event: any) {
1396     i.state.banReason = event.target.value;
1397     i.setState(i.state);
1398   }
1399
1400   handleModBanExpiresChange(i: PostListing, event: any) {
1401     i.state.banExpires = event.target.value;
1402     i.setState(i.state);
1403   }
1404
1405   handleModBanFromCommunitySubmit(i: PostListing) {
1406     i.state.banType = BanType.Community;
1407     i.setState(i.state);
1408     i.handleModBanBothSubmit(i);
1409   }
1410
1411   handleModBanSubmit(i: PostListing) {
1412     i.state.banType = BanType.Site;
1413     i.setState(i.state);
1414     i.handleModBanBothSubmit(i);
1415   }
1416
1417   handleModBanBothSubmit(i: PostListing, event?: any) {
1418     if (event) event.preventDefault();
1419
1420     if (i.state.banType == BanType.Community) {
1421       // If its an unban, restore all their data
1422       let ban = !i.props.post_view.creator_banned_from_community;
1423       if (ban == false) {
1424         i.state.removeData = false;
1425       }
1426       let form: BanFromCommunity = {
1427         person_id: i.props.post_view.creator.id,
1428         community_id: i.props.post_view.community.id,
1429         ban,
1430         remove_data: i.state.removeData,
1431         reason: i.state.banReason,
1432         expires: getUnixTime(i.state.banExpires),
1433         auth: authField(),
1434       };
1435       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1436     } else {
1437       // If its an unban, restore all their data
1438       let ban = !i.props.post_view.creator.banned;
1439       if (ban == false) {
1440         i.state.removeData = false;
1441       }
1442       let form: BanPerson = {
1443         person_id: i.props.post_view.creator.id,
1444         ban,
1445         remove_data: i.state.removeData,
1446         reason: i.state.banReason,
1447         expires: getUnixTime(i.state.banExpires),
1448         auth: authField(),
1449       };
1450       WebSocketService.Instance.send(wsClient.banPerson(form));
1451     }
1452
1453     i.state.showBanDialog = false;
1454     i.setState(i.state);
1455   }
1456
1457   handleAddModToCommunity(i: PostListing) {
1458     let form: AddModToCommunity = {
1459       person_id: i.props.post_view.creator.id,
1460       community_id: i.props.post_view.community.id,
1461       added: !i.isMod,
1462       auth: authField(),
1463     };
1464     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1465     i.setState(i.state);
1466   }
1467
1468   handleAddAdmin(i: PostListing) {
1469     let form: AddAdmin = {
1470       person_id: i.props.post_view.creator.id,
1471       added: !i.isAdmin,
1472       auth: authField(),
1473     };
1474     WebSocketService.Instance.send(wsClient.addAdmin(form));
1475     i.setState(i.state);
1476   }
1477
1478   handleShowConfirmTransferCommunity(i: PostListing) {
1479     i.state.showConfirmTransferCommunity = true;
1480     i.setState(i.state);
1481   }
1482
1483   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1484     i.state.showConfirmTransferCommunity = false;
1485     i.setState(i.state);
1486   }
1487
1488   handleTransferCommunity(i: PostListing) {
1489     let form: TransferCommunity = {
1490       community_id: i.props.post_view.community.id,
1491       person_id: i.props.post_view.creator.id,
1492       auth: authField(),
1493     };
1494     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1495     i.state.showConfirmTransferCommunity = false;
1496     i.setState(i.state);
1497   }
1498
1499   handleShowConfirmTransferSite(i: PostListing) {
1500     i.state.showConfirmTransferSite = true;
1501     i.setState(i.state);
1502   }
1503
1504   handleCancelShowConfirmTransferSite(i: PostListing) {
1505     i.state.showConfirmTransferSite = false;
1506     i.setState(i.state);
1507   }
1508
1509   handleTransferSite(i: PostListing) {
1510     let form: TransferSite = {
1511       person_id: i.props.post_view.creator.id,
1512       auth: authField(),
1513     };
1514     WebSocketService.Instance.send(wsClient.transferSite(form));
1515     i.state.showConfirmTransferSite = false;
1516     i.setState(i.state);
1517   }
1518
1519   handleImageExpandClick(i: PostListing) {
1520     i.state.imageExpanded = !i.state.imageExpanded;
1521     i.setState(i.state);
1522   }
1523
1524   handleViewSource(i: PostListing) {
1525     i.state.viewSource = !i.state.viewSource;
1526     i.setState(i.state);
1527   }
1528
1529   handleShowAdvanced(i: PostListing) {
1530     i.state.showAdvanced = !i.state.showAdvanced;
1531     i.setState(i.state);
1532     setupTippy();
1533   }
1534
1535   handleShowMoreMobile(i: PostListing) {
1536     i.state.showMoreMobile = !i.state.showMoreMobile;
1537     i.state.showAdvanced = !i.state.showAdvanced;
1538     i.setState(i.state);
1539     setupTippy();
1540   }
1541
1542   handleShowBody(i: PostListing) {
1543     i.state.showBody = !i.state.showBody;
1544     i.setState(i.state);
1545     setupTippy();
1546   }
1547
1548   get pointsTippy(): string {
1549     let points = i18n.t("number_of_points", {
1550       count: this.state.score,
1551     });
1552
1553     let upvotes = i18n.t("number_of_upvotes", {
1554       count: this.state.upvotes,
1555     });
1556
1557     let downvotes = i18n.t("number_of_downvotes", {
1558       count: this.state.downvotes,
1559     });
1560
1561     return `${points} • ${upvotes} • ${downvotes}`;
1562   }
1563 }