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