]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post-listing.tsx
1e969611e5f8b2e91da911af70d15bb9247759cf
[lemmy-ui.git] / src / shared / components / post-listing.tsx
1 import { Component, linkEvent } from "inferno";
2 import { Link } from "inferno-router";
3 import { WebSocketService, UserService } from "../services";
4 import {
5   PostView,
6   CreatePostLike,
7   DeletePost,
8   RemovePost,
9   LockPost,
10   StickyPost,
11   SavePost,
12   PersonViewSafe,
13   BanFromCommunity,
14   BanPerson,
15   AddModToCommunity,
16   AddAdmin,
17   TransferSite,
18   TransferCommunity,
19   CommunityModeratorView,
20 } from "lemmy-js-client";
21 import { BanType } from "../interfaces";
22 import { MomentTime } from "./moment-time";
23 import { PostForm } from "./post-form";
24 import { IFramelyCard } from "./iframely-card";
25 import { PersonListing } from "./person-listing";
26 import { CommunityLink } from "./community-link";
27 import { PictrsImage } from "./pictrs-image";
28 import { Icon } from "./icon";
29 import {
30   md,
31   mdToHtml,
32   canMod,
33   isMod,
34   isImage,
35   isVideo,
36   getUnixTime,
37   setupTippy,
38   hostname,
39   previewLines,
40   wsClient,
41   authField,
42 } 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?: PersonViewSafe[];
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           <PersonListing person={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.localUserView && (
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.localUserView &&
1087       this.props.post_view.creator.id ==
1088         UserService.Instance.localUserView.person.id
1089     );
1090   }
1091
1092   get isMod(): boolean {
1093     return (
1094       this.props.moderators &&
1095       isMod(
1096         this.props.moderators.map(m => m.moderator.id),
1097         this.props.post_view.creator.id
1098       )
1099     );
1100   }
1101
1102   get isAdmin(): boolean {
1103     return (
1104       this.props.admins &&
1105       isMod(
1106         this.props.admins.map(a => a.person.id),
1107         this.props.post_view.creator.id
1108       )
1109     );
1110   }
1111
1112   get canMod(): boolean {
1113     if (this.props.admins && this.props.moderators) {
1114       let adminsThenMods = this.props.admins
1115         .map(a => a.person.id)
1116         .concat(this.props.moderators.map(m => m.moderator.id));
1117
1118       return canMod(
1119         UserService.Instance.localUserView,
1120         adminsThenMods,
1121         this.props.post_view.creator.id
1122       );
1123     } else {
1124       return false;
1125     }
1126   }
1127
1128   get canModOnSelf(): boolean {
1129     if (this.props.admins && this.props.moderators) {
1130       let adminsThenMods = this.props.admins
1131         .map(a => a.person.id)
1132         .concat(this.props.moderators.map(m => m.moderator.id));
1133
1134       return canMod(
1135         UserService.Instance.localUserView,
1136         adminsThenMods,
1137         this.props.post_view.creator.id,
1138         true
1139       );
1140     } else {
1141       return false;
1142     }
1143   }
1144
1145   get canAdmin(): boolean {
1146     return (
1147       this.props.admins &&
1148       canMod(
1149         UserService.Instance.localUserView,
1150         this.props.admins.map(a => a.person.id),
1151         this.props.post_view.creator.id
1152       )
1153     );
1154   }
1155
1156   get amCommunityCreator(): boolean {
1157     return (
1158       this.props.moderators &&
1159       UserService.Instance.localUserView &&
1160       this.props.post_view.creator.id !=
1161         UserService.Instance.localUserView.person.id &&
1162       UserService.Instance.localUserView.person.id ==
1163         this.props.moderators[0].moderator.id
1164     );
1165   }
1166
1167   get amSiteCreator(): boolean {
1168     return (
1169       this.props.admins &&
1170       UserService.Instance.localUserView &&
1171       this.props.post_view.creator.id !=
1172         UserService.Instance.localUserView.person.id &&
1173       UserService.Instance.localUserView.person.id ==
1174         this.props.admins[0].person.id
1175     );
1176   }
1177
1178   handlePostLike(i: PostListing, event: any) {
1179     event.preventDefault();
1180     if (!UserService.Instance.localUserView) {
1181       this.context.router.history.push(`/login`);
1182     }
1183
1184     let new_vote = i.state.my_vote == 1 ? 0 : 1;
1185
1186     if (i.state.my_vote == 1) {
1187       i.state.score--;
1188       i.state.upvotes--;
1189     } else if (i.state.my_vote == -1) {
1190       i.state.downvotes--;
1191       i.state.upvotes++;
1192       i.state.score += 2;
1193     } else {
1194       i.state.upvotes++;
1195       i.state.score++;
1196     }
1197
1198     i.state.my_vote = new_vote;
1199
1200     let form: CreatePostLike = {
1201       post_id: i.props.post_view.post.id,
1202       score: i.state.my_vote,
1203       auth: authField(),
1204     };
1205
1206     WebSocketService.Instance.send(wsClient.likePost(form));
1207     i.setState(i.state);
1208     setupTippy();
1209   }
1210
1211   handlePostDisLike(i: PostListing, event: any) {
1212     event.preventDefault();
1213     if (!UserService.Instance.localUserView) {
1214       this.context.router.history.push(`/login`);
1215     }
1216
1217     let new_vote = i.state.my_vote == -1 ? 0 : -1;
1218
1219     if (i.state.my_vote == 1) {
1220       i.state.score -= 2;
1221       i.state.upvotes--;
1222       i.state.downvotes++;
1223     } else if (i.state.my_vote == -1) {
1224       i.state.downvotes--;
1225       i.state.score++;
1226     } else {
1227       i.state.downvotes++;
1228       i.state.score--;
1229     }
1230
1231     i.state.my_vote = new_vote;
1232
1233     let form: CreatePostLike = {
1234       post_id: i.props.post_view.post.id,
1235       score: i.state.my_vote,
1236       auth: authField(),
1237     };
1238
1239     WebSocketService.Instance.send(wsClient.likePost(form));
1240     i.setState(i.state);
1241     setupTippy();
1242   }
1243
1244   handleEditClick(i: PostListing) {
1245     i.state.showEdit = true;
1246     i.setState(i.state);
1247   }
1248
1249   handleEditCancel() {
1250     this.state.showEdit = false;
1251     this.setState(this.state);
1252   }
1253
1254   // The actual editing is done in the recieve for post
1255   handleEditPost() {
1256     this.state.showEdit = false;
1257     this.setState(this.state);
1258   }
1259
1260   handleDeleteClick(i: PostListing) {
1261     let deleteForm: DeletePost = {
1262       post_id: i.props.post_view.post.id,
1263       deleted: !i.props.post_view.post.deleted,
1264       auth: authField(),
1265     };
1266     WebSocketService.Instance.send(wsClient.deletePost(deleteForm));
1267   }
1268
1269   handleSavePostClick(i: PostListing) {
1270     let saved =
1271       i.props.post_view.saved == undefined ? true : !i.props.post_view.saved;
1272     let form: SavePost = {
1273       post_id: i.props.post_view.post.id,
1274       save: saved,
1275       auth: authField(),
1276     };
1277
1278     WebSocketService.Instance.send(wsClient.savePost(form));
1279   }
1280
1281   get crossPostParams(): string {
1282     let post = this.props.post_view.post;
1283     let params = `?title=${encodeURIComponent(post.name)}`;
1284
1285     if (post.url) {
1286       params += `&url=${encodeURIComponent(post.url)}`;
1287     }
1288     if (post.body) {
1289       params += `&body=${encodeURIComponent(post.body)}`;
1290     }
1291     return params;
1292   }
1293
1294   handleModRemoveShow(i: PostListing) {
1295     i.state.showRemoveDialog = true;
1296     i.setState(i.state);
1297   }
1298
1299   handleModRemoveReasonChange(i: PostListing, event: any) {
1300     i.state.removeReason = event.target.value;
1301     i.setState(i.state);
1302   }
1303
1304   handleModRemoveDataChange(i: PostListing, event: any) {
1305     i.state.removeData = event.target.checked;
1306     i.setState(i.state);
1307   }
1308
1309   handleModRemoveSubmit(i: PostListing, event: any) {
1310     event.preventDefault();
1311     let form: RemovePost = {
1312       post_id: i.props.post_view.post.id,
1313       removed: !i.props.post_view.post.removed,
1314       reason: i.state.removeReason,
1315       auth: authField(),
1316     };
1317     WebSocketService.Instance.send(wsClient.removePost(form));
1318
1319     i.state.showRemoveDialog = false;
1320     i.setState(i.state);
1321   }
1322
1323   handleModLock(i: PostListing) {
1324     let form: LockPost = {
1325       post_id: i.props.post_view.post.id,
1326       locked: !i.props.post_view.post.locked,
1327       auth: authField(),
1328     };
1329     WebSocketService.Instance.send(wsClient.lockPost(form));
1330   }
1331
1332   handleModSticky(i: PostListing) {
1333     let form: StickyPost = {
1334       post_id: i.props.post_view.post.id,
1335       stickied: !i.props.post_view.post.stickied,
1336       auth: authField(),
1337     };
1338     WebSocketService.Instance.send(wsClient.stickyPost(form));
1339   }
1340
1341   handleModBanFromCommunityShow(i: PostListing) {
1342     i.state.showBanDialog = true;
1343     i.state.banType = BanType.Community;
1344     i.setState(i.state);
1345   }
1346
1347   handleModBanShow(i: PostListing) {
1348     i.state.showBanDialog = true;
1349     i.state.banType = BanType.Site;
1350     i.setState(i.state);
1351   }
1352
1353   handleModBanReasonChange(i: PostListing, event: any) {
1354     i.state.banReason = event.target.value;
1355     i.setState(i.state);
1356   }
1357
1358   handleModBanExpiresChange(i: PostListing, event: any) {
1359     i.state.banExpires = event.target.value;
1360     i.setState(i.state);
1361   }
1362
1363   handleModBanFromCommunitySubmit(i: PostListing) {
1364     i.state.banType = BanType.Community;
1365     i.setState(i.state);
1366     i.handleModBanBothSubmit(i);
1367   }
1368
1369   handleModBanSubmit(i: PostListing) {
1370     i.state.banType = BanType.Site;
1371     i.setState(i.state);
1372     i.handleModBanBothSubmit(i);
1373   }
1374
1375   handleModBanBothSubmit(i: PostListing, event?: any) {
1376     event.preventDefault();
1377
1378     if (i.state.banType == BanType.Community) {
1379       // If its an unban, restore all their data
1380       let ban = !i.props.post_view.creator_banned_from_community;
1381       if (ban == false) {
1382         i.state.removeData = false;
1383       }
1384       let form: BanFromCommunity = {
1385         person_id: i.props.post_view.creator.id,
1386         community_id: i.props.post_view.community.id,
1387         ban,
1388         remove_data: i.state.removeData,
1389         reason: i.state.banReason,
1390         expires: getUnixTime(i.state.banExpires),
1391         auth: authField(),
1392       };
1393       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1394     } else {
1395       // If its an unban, restore all their data
1396       let ban = !i.props.post_view.creator.banned;
1397       if (ban == false) {
1398         i.state.removeData = false;
1399       }
1400       let form: BanPerson = {
1401         person_id: i.props.post_view.creator.id,
1402         ban,
1403         remove_data: i.state.removeData,
1404         reason: i.state.banReason,
1405         expires: getUnixTime(i.state.banExpires),
1406         auth: authField(),
1407       };
1408       WebSocketService.Instance.send(wsClient.banPerson(form));
1409     }
1410
1411     i.state.showBanDialog = false;
1412     i.setState(i.state);
1413   }
1414
1415   handleAddModToCommunity(i: PostListing) {
1416     let form: AddModToCommunity = {
1417       person_id: i.props.post_view.creator.id,
1418       community_id: i.props.post_view.community.id,
1419       added: !i.isMod,
1420       auth: authField(),
1421     };
1422     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1423     i.setState(i.state);
1424   }
1425
1426   handleAddAdmin(i: PostListing) {
1427     let form: AddAdmin = {
1428       person_id: i.props.post_view.creator.id,
1429       added: !i.isAdmin,
1430       auth: authField(),
1431     };
1432     WebSocketService.Instance.send(wsClient.addAdmin(form));
1433     i.setState(i.state);
1434   }
1435
1436   handleShowConfirmTransferCommunity(i: PostListing) {
1437     i.state.showConfirmTransferCommunity = true;
1438     i.setState(i.state);
1439   }
1440
1441   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1442     i.state.showConfirmTransferCommunity = false;
1443     i.setState(i.state);
1444   }
1445
1446   handleTransferCommunity(i: PostListing) {
1447     let form: TransferCommunity = {
1448       community_id: i.props.post_view.community.id,
1449       person_id: i.props.post_view.creator.id,
1450       auth: authField(),
1451     };
1452     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1453     i.state.showConfirmTransferCommunity = false;
1454     i.setState(i.state);
1455   }
1456
1457   handleShowConfirmTransferSite(i: PostListing) {
1458     i.state.showConfirmTransferSite = true;
1459     i.setState(i.state);
1460   }
1461
1462   handleCancelShowConfirmTransferSite(i: PostListing) {
1463     i.state.showConfirmTransferSite = false;
1464     i.setState(i.state);
1465   }
1466
1467   handleTransferSite(i: PostListing) {
1468     let form: TransferSite = {
1469       person_id: i.props.post_view.creator.id,
1470       auth: authField(),
1471     };
1472     WebSocketService.Instance.send(wsClient.transferSite(form));
1473     i.state.showConfirmTransferSite = false;
1474     i.setState(i.state);
1475   }
1476
1477   handleImageExpandClick(i: PostListing) {
1478     i.state.imageExpanded = !i.state.imageExpanded;
1479     i.setState(i.state);
1480   }
1481
1482   handleViewSource(i: PostListing) {
1483     i.state.viewSource = !i.state.viewSource;
1484     i.setState(i.state);
1485   }
1486
1487   handleShowAdvanced(i: PostListing) {
1488     i.state.showAdvanced = !i.state.showAdvanced;
1489     i.setState(i.state);
1490     setupTippy();
1491   }
1492
1493   handleShowMoreMobile(i: PostListing) {
1494     i.state.showMoreMobile = !i.state.showMoreMobile;
1495     i.state.showAdvanced = !i.state.showAdvanced;
1496     i.setState(i.state);
1497     setupTippy();
1498   }
1499
1500   get pointsTippy(): string {
1501     let points = i18n.t("number_of_points", {
1502       count: this.state.score,
1503     });
1504
1505     let upvotes = i18n.t("number_of_upvotes", {
1506       count: this.state.upvotes,
1507     });
1508
1509     let downvotes = i18n.t("number_of_downvotes", {
1510       count: this.state.downvotes,
1511     });
1512
1513     return `${points} • ${upvotes} • ${downvotes}`;
1514   }
1515 }