]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post-listing.tsx
2c08597d41f56d6ff5fce5ccce1293f05acfe804
[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                       <button
777                         class="btn btn-link btn-animate text-muted py-0"
778                         onClick={linkEvent(this, this.handleAddModToCommunity)}
779                         aria-label={
780                           this.isMod
781                             ? i18n.t("remove_as_mod")
782                             : i18n.t("appoint_as_mod")
783                         }
784                       >
785                         {this.isMod
786                           ? i18n.t("remove_as_mod")
787                           : i18n.t("appoint_as_mod")}
788                       </button>
789                     )}
790                 </>
791               )}
792               {/* Community creators and admins can transfer community to another mod */}
793               {(this.amCommunityCreator || this.canAdmin) &&
794                 this.isMod &&
795                 (!this.state.showConfirmTransferCommunity ? (
796                   <button
797                     class="btn btn-link btn-animate text-muted py-0"
798                     onClick={linkEvent(
799                       this,
800                       this.handleShowConfirmTransferCommunity
801                     )}
802                     aria-label={i18n.t("transfer_community")}
803                   >
804                     {i18n.t("transfer_community")}
805                   </button>
806                 ) : (
807                   <>
808                     <button
809                       class="d-inline-block mr-1 btn btn-link btn-animate text-muted py-0"
810                       aria-label={i18n.t("are_you_sure")}
811                     >
812                       {i18n.t("are_you_sure")}
813                     </button>
814                     <button
815                       class="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
816                       aria-label={i18n.t("yes")}
817                       onClick={linkEvent(this, this.handleTransferCommunity)}
818                     >
819                       {i18n.t("yes")}
820                     </button>
821                     <button
822                       class="btn btn-link btn-animate text-muted py-0 d-inline-block"
823                       onClick={linkEvent(
824                         this,
825                         this.handleCancelShowConfirmTransferCommunity
826                       )}
827                       aria-label={i18n.t("no")}
828                     >
829                       {i18n.t("no")}
830                     </button>
831                   </>
832                 ))}
833               {/* Admins can ban from all, and appoint other admins */}
834               {this.canAdmin && (
835                 <>
836                   {!this.isAdmin &&
837                     (!post_view.creator.banned ? (
838                       <button
839                         class="btn btn-link btn-animate text-muted py-0"
840                         onClick={linkEvent(this, this.handleModBanShow)}
841                         aria-label={i18n.t("ban_from_site")}
842                       >
843                         {i18n.t("ban_from_site")}
844                       </button>
845                     ) : (
846                       <button
847                         class="btn btn-link btn-animate text-muted py-0"
848                         onClick={linkEvent(this, this.handleModBanSubmit)}
849                         aria-label={i18n.t("unban_from_site")}
850                       >
851                         {i18n.t("unban_from_site")}
852                       </button>
853                     ))}
854                   {!post_view.creator.banned && post_view.creator.local && (
855                     <button
856                       class="btn btn-link btn-animate text-muted py-0"
857                       onClick={linkEvent(this, this.handleAddAdmin)}
858                       aria-label={
859                         this.isAdmin
860                           ? i18n.t("remove_as_admin")
861                           : i18n.t("appoint_as_admin")
862                       }
863                     >
864                       {this.isAdmin
865                         ? i18n.t("remove_as_admin")
866                         : i18n.t("appoint_as_admin")}
867                     </button>
868                   )}
869                 </>
870               )}
871               {/* Site Creator can transfer to another admin */}
872               {this.amSiteCreator &&
873                 this.isAdmin &&
874                 (!this.state.showConfirmTransferSite ? (
875                   <button
876                     class="btn btn-link btn-animate text-muted py-0"
877                     onClick={linkEvent(
878                       this,
879                       this.handleShowConfirmTransferSite
880                     )}
881                     aria-label={i18n.t("transfer_site")}
882                   >
883                     {i18n.t("transfer_site")}
884                   </button>
885                 ) : (
886                   <>
887                     <button
888                       class="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
889                       aria-label={i18n.t("are_you_sure")}
890                     >
891                       {i18n.t("are_you_sure")}
892                     </button>
893                     <button
894                       class="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
895                       onClick={linkEvent(this, this.handleTransferSite)}
896                       aria-label={i18n.t("yes")}
897                     >
898                       {i18n.t("yes")}
899                     </button>
900                     <button
901                       class="btn btn-link btn-animate text-muted py-0 d-inline-block"
902                       onClick={linkEvent(
903                         this,
904                         this.handleCancelShowConfirmTransferSite
905                       )}
906                       aria-label={i18n.t("no")}
907                     >
908                       {i18n.t("no")}
909                     </button>
910                   </>
911                 ))}
912             </>
913           )}
914         </>
915       )
916     );
917   }
918
919   removeAndBanDialogs() {
920     let post = this.props.post_view;
921     return (
922       <>
923         {this.state.showRemoveDialog && (
924           <form
925             class="form-inline"
926             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
927           >
928             <label class="sr-only" htmlFor="post-listing-remove-reason">
929               {i18n.t("reason")}
930             </label>
931             <input
932               type="text"
933               id="post-listing-remove-reason"
934               class="form-control mr-2"
935               placeholder={i18n.t("reason")}
936               value={this.state.removeReason}
937               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
938             />
939             <button
940               type="submit"
941               class="btn btn-secondary"
942               aria-label={i18n.t("remove_post")}
943             >
944               {i18n.t("remove_post")}
945             </button>
946           </form>
947         )}
948         {this.state.showBanDialog && (
949           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
950             <div class="form-group row">
951               <label class="col-form-label" htmlFor="post-listing-ban-reason">
952                 {i18n.t("reason")}
953               </label>
954               <input
955                 type="text"
956                 id="post-listing-ban-reason"
957                 class="form-control mr-2"
958                 placeholder={i18n.t("reason")}
959                 value={this.state.banReason}
960                 onInput={linkEvent(this, this.handleModBanReasonChange)}
961               />
962               <div class="form-group">
963                 <div class="form-check">
964                   <input
965                     class="form-check-input"
966                     id="mod-ban-remove-data"
967                     type="checkbox"
968                     checked={this.state.removeData}
969                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
970                   />
971                   <label class="form-check-label" htmlFor="mod-ban-remove-data">
972                     {i18n.t("remove_posts_comments")}
973                   </label>
974                 </div>
975               </div>
976             </div>
977             {/* TODO hold off on expires until later */}
978             {/* <div class="form-group row"> */}
979             {/*   <label class="col-form-label">Expires</label> */}
980             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
981             {/* </div> */}
982             <div class="form-group row">
983               <button
984                 type="submit"
985                 class="btn btn-secondary"
986                 aria-label={i18n.t("ban")}
987               >
988                 {i18n.t("ban")} {post.creator.name}
989               </button>
990             </div>
991           </form>
992         )}
993       </>
994     );
995   }
996
997   mobileThumbnail() {
998     let post = this.props.post_view.post;
999     return post.thumbnail_url || isImage(post.url) ? (
1000       <div class="row">
1001         <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
1002           {this.postTitleLine()}
1003         </div>
1004         <div class="col-4">
1005           {/* Post body prev or thumbnail */}
1006           {!this.state.imageExpanded && this.thumbnail()}
1007         </div>
1008       </div>
1009     ) : (
1010       this.postTitleLine()
1011     );
1012   }
1013
1014   showMobilePreview() {
1015     let post = this.props.post_view.post;
1016     return (
1017       post.body &&
1018       !this.props.showBody && (
1019         <div
1020           className="md-div mb-1"
1021           dangerouslySetInnerHTML={{
1022             __html: md.render(previewLines(post.body)),
1023           }}
1024         />
1025       )
1026     );
1027   }
1028
1029   listing() {
1030     return (
1031       <>
1032         {/* The mobile view*/}
1033         <div class="d-block d-sm-none">
1034           <div class="row">
1035             <div class="col-12">
1036               {this.createdLine()}
1037
1038               {/* If it has a thumbnail, do a right aligned thumbnail */}
1039               {this.mobileThumbnail()}
1040
1041               {/* Show a preview of the post body */}
1042               {this.showMobilePreview()}
1043
1044               {this.commentsLine(true)}
1045               {this.duplicatesLine()}
1046               {this.removeAndBanDialogs()}
1047             </div>
1048           </div>
1049         </div>
1050
1051         {/* The larger view*/}
1052         <div class="d-none d-sm-block">
1053           <div class="row">
1054             {this.voteBar()}
1055             {!this.state.imageExpanded && (
1056               <div class="col-sm-2 pr-0">
1057                 <div class="">{this.thumbnail()}</div>
1058               </div>
1059             )}
1060             <div
1061               class={`${
1062                 this.state.imageExpanded ? "col-12" : "col-12 col-sm-9"
1063               }`}
1064             >
1065               <div class="row">
1066                 <div className="col-12">
1067                   {this.postTitleLine()}
1068                   {this.createdLine()}
1069                   {this.commentsLine()}
1070                   {this.duplicatesLine()}
1071                   {this.postActions()}
1072                   {this.removeAndBanDialogs()}
1073                 </div>
1074               </div>
1075             </div>
1076           </div>
1077         </div>
1078       </>
1079     );
1080   }
1081
1082   private get myPost(): boolean {
1083     return (
1084       UserService.Instance.localUserView &&
1085       this.props.post_view.creator.id ==
1086         UserService.Instance.localUserView.person.id
1087     );
1088   }
1089
1090   get isMod(): boolean {
1091     return (
1092       this.props.moderators &&
1093       isMod(
1094         this.props.moderators.map(m => m.moderator.id),
1095         this.props.post_view.creator.id
1096       )
1097     );
1098   }
1099
1100   get isAdmin(): boolean {
1101     return (
1102       this.props.admins &&
1103       isMod(
1104         this.props.admins.map(a => a.person.id),
1105         this.props.post_view.creator.id
1106       )
1107     );
1108   }
1109
1110   get canMod(): boolean {
1111     if (this.props.admins && this.props.moderators) {
1112       let adminsThenMods = this.props.admins
1113         .map(a => a.person.id)
1114         .concat(this.props.moderators.map(m => m.moderator.id));
1115
1116       return canMod(
1117         UserService.Instance.localUserView,
1118         adminsThenMods,
1119         this.props.post_view.creator.id
1120       );
1121     } else {
1122       return false;
1123     }
1124   }
1125
1126   get canModOnSelf(): boolean {
1127     if (this.props.admins && this.props.moderators) {
1128       let adminsThenMods = this.props.admins
1129         .map(a => a.person.id)
1130         .concat(this.props.moderators.map(m => m.moderator.id));
1131
1132       return canMod(
1133         UserService.Instance.localUserView,
1134         adminsThenMods,
1135         this.props.post_view.creator.id,
1136         true
1137       );
1138     } else {
1139       return false;
1140     }
1141   }
1142
1143   get canAdmin(): boolean {
1144     return (
1145       this.props.admins &&
1146       canMod(
1147         UserService.Instance.localUserView,
1148         this.props.admins.map(a => a.person.id),
1149         this.props.post_view.creator.id
1150       )
1151     );
1152   }
1153
1154   get amCommunityCreator(): boolean {
1155     return (
1156       this.props.moderators &&
1157       UserService.Instance.localUserView &&
1158       this.props.post_view.creator.id !=
1159         UserService.Instance.localUserView.person.id &&
1160       UserService.Instance.localUserView.person.id ==
1161         this.props.moderators[0].moderator.id
1162     );
1163   }
1164
1165   get amSiteCreator(): boolean {
1166     return (
1167       this.props.admins &&
1168       UserService.Instance.localUserView &&
1169       this.props.post_view.creator.id !=
1170         UserService.Instance.localUserView.person.id &&
1171       UserService.Instance.localUserView.person.id ==
1172         this.props.admins[0].person.id
1173     );
1174   }
1175
1176   handlePostLike(i: PostListing, event: any) {
1177     event.preventDefault();
1178     if (!UserService.Instance.localUserView) {
1179       this.context.router.history.push(`/login`);
1180     }
1181
1182     let new_vote = i.state.my_vote == 1 ? 0 : 1;
1183
1184     if (i.state.my_vote == 1) {
1185       i.state.score--;
1186       i.state.upvotes--;
1187     } else if (i.state.my_vote == -1) {
1188       i.state.downvotes--;
1189       i.state.upvotes++;
1190       i.state.score += 2;
1191     } else {
1192       i.state.upvotes++;
1193       i.state.score++;
1194     }
1195
1196     i.state.my_vote = new_vote;
1197
1198     let form: CreatePostLike = {
1199       post_id: i.props.post_view.post.id,
1200       score: i.state.my_vote,
1201       auth: authField(),
1202     };
1203
1204     WebSocketService.Instance.send(wsClient.likePost(form));
1205     i.setState(i.state);
1206     setupTippy();
1207   }
1208
1209   handlePostDisLike(i: PostListing, event: any) {
1210     event.preventDefault();
1211     if (!UserService.Instance.localUserView) {
1212       this.context.router.history.push(`/login`);
1213     }
1214
1215     let new_vote = i.state.my_vote == -1 ? 0 : -1;
1216
1217     if (i.state.my_vote == 1) {
1218       i.state.score -= 2;
1219       i.state.upvotes--;
1220       i.state.downvotes++;
1221     } else if (i.state.my_vote == -1) {
1222       i.state.downvotes--;
1223       i.state.score++;
1224     } else {
1225       i.state.downvotes++;
1226       i.state.score--;
1227     }
1228
1229     i.state.my_vote = new_vote;
1230
1231     let form: CreatePostLike = {
1232       post_id: i.props.post_view.post.id,
1233       score: i.state.my_vote,
1234       auth: authField(),
1235     };
1236
1237     WebSocketService.Instance.send(wsClient.likePost(form));
1238     i.setState(i.state);
1239     setupTippy();
1240   }
1241
1242   handleEditClick(i: PostListing) {
1243     i.state.showEdit = true;
1244     i.setState(i.state);
1245   }
1246
1247   handleEditCancel() {
1248     this.state.showEdit = false;
1249     this.setState(this.state);
1250   }
1251
1252   // The actual editing is done in the recieve for post
1253   handleEditPost() {
1254     this.state.showEdit = false;
1255     this.setState(this.state);
1256   }
1257
1258   handleDeleteClick(i: PostListing) {
1259     let deleteForm: DeletePost = {
1260       post_id: i.props.post_view.post.id,
1261       deleted: !i.props.post_view.post.deleted,
1262       auth: authField(),
1263     };
1264     WebSocketService.Instance.send(wsClient.deletePost(deleteForm));
1265   }
1266
1267   handleSavePostClick(i: PostListing) {
1268     let saved =
1269       i.props.post_view.saved == undefined ? true : !i.props.post_view.saved;
1270     let form: SavePost = {
1271       post_id: i.props.post_view.post.id,
1272       save: saved,
1273       auth: authField(),
1274     };
1275
1276     WebSocketService.Instance.send(wsClient.savePost(form));
1277   }
1278
1279   get crossPostParams(): string {
1280     let post = this.props.post_view.post;
1281     let params = `?title=${encodeURIComponent(post.name)}`;
1282
1283     if (post.url) {
1284       params += `&url=${encodeURIComponent(post.url)}`;
1285     }
1286     if (post.body) {
1287       params += `&body=${encodeURIComponent(post.body)}`;
1288     }
1289     return params;
1290   }
1291
1292   handleModRemoveShow(i: PostListing) {
1293     i.state.showRemoveDialog = true;
1294     i.setState(i.state);
1295   }
1296
1297   handleModRemoveReasonChange(i: PostListing, event: any) {
1298     i.state.removeReason = event.target.value;
1299     i.setState(i.state);
1300   }
1301
1302   handleModRemoveDataChange(i: PostListing, event: any) {
1303     i.state.removeData = event.target.checked;
1304     i.setState(i.state);
1305   }
1306
1307   handleModRemoveSubmit(i: PostListing, event: any) {
1308     event.preventDefault();
1309     let form: RemovePost = {
1310       post_id: i.props.post_view.post.id,
1311       removed: !i.props.post_view.post.removed,
1312       reason: i.state.removeReason,
1313       auth: authField(),
1314     };
1315     WebSocketService.Instance.send(wsClient.removePost(form));
1316
1317     i.state.showRemoveDialog = false;
1318     i.setState(i.state);
1319   }
1320
1321   handleModLock(i: PostListing) {
1322     let form: LockPost = {
1323       post_id: i.props.post_view.post.id,
1324       locked: !i.props.post_view.post.locked,
1325       auth: authField(),
1326     };
1327     WebSocketService.Instance.send(wsClient.lockPost(form));
1328   }
1329
1330   handleModSticky(i: PostListing) {
1331     let form: StickyPost = {
1332       post_id: i.props.post_view.post.id,
1333       stickied: !i.props.post_view.post.stickied,
1334       auth: authField(),
1335     };
1336     WebSocketService.Instance.send(wsClient.stickyPost(form));
1337   }
1338
1339   handleModBanFromCommunityShow(i: PostListing) {
1340     i.state.showBanDialog = true;
1341     i.state.banType = BanType.Community;
1342     i.setState(i.state);
1343   }
1344
1345   handleModBanShow(i: PostListing) {
1346     i.state.showBanDialog = true;
1347     i.state.banType = BanType.Site;
1348     i.setState(i.state);
1349   }
1350
1351   handleModBanReasonChange(i: PostListing, event: any) {
1352     i.state.banReason = event.target.value;
1353     i.setState(i.state);
1354   }
1355
1356   handleModBanExpiresChange(i: PostListing, event: any) {
1357     i.state.banExpires = event.target.value;
1358     i.setState(i.state);
1359   }
1360
1361   handleModBanFromCommunitySubmit(i: PostListing) {
1362     i.state.banType = BanType.Community;
1363     i.setState(i.state);
1364     i.handleModBanBothSubmit(i);
1365   }
1366
1367   handleModBanSubmit(i: PostListing) {
1368     i.state.banType = BanType.Site;
1369     i.setState(i.state);
1370     i.handleModBanBothSubmit(i);
1371   }
1372
1373   handleModBanBothSubmit(i: PostListing, event?: any) {
1374     event.preventDefault();
1375
1376     if (i.state.banType == BanType.Community) {
1377       // If its an unban, restore all their data
1378       let ban = !i.props.post_view.creator_banned_from_community;
1379       if (ban == false) {
1380         i.state.removeData = false;
1381       }
1382       let form: BanFromCommunity = {
1383         person_id: i.props.post_view.creator.id,
1384         community_id: i.props.post_view.community.id,
1385         ban,
1386         remove_data: i.state.removeData,
1387         reason: i.state.banReason,
1388         expires: getUnixTime(i.state.banExpires),
1389         auth: authField(),
1390       };
1391       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1392     } else {
1393       // If its an unban, restore all their data
1394       let ban = !i.props.post_view.creator.banned;
1395       if (ban == false) {
1396         i.state.removeData = false;
1397       }
1398       let form: BanPerson = {
1399         person_id: i.props.post_view.creator.id,
1400         ban,
1401         remove_data: i.state.removeData,
1402         reason: i.state.banReason,
1403         expires: getUnixTime(i.state.banExpires),
1404         auth: authField(),
1405       };
1406       WebSocketService.Instance.send(wsClient.banPerson(form));
1407     }
1408
1409     i.state.showBanDialog = false;
1410     i.setState(i.state);
1411   }
1412
1413   handleAddModToCommunity(i: PostListing) {
1414     let form: AddModToCommunity = {
1415       person_id: i.props.post_view.creator.id,
1416       community_id: i.props.post_view.community.id,
1417       added: !i.isMod,
1418       auth: authField(),
1419     };
1420     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1421     i.setState(i.state);
1422   }
1423
1424   handleAddAdmin(i: PostListing) {
1425     let form: AddAdmin = {
1426       person_id: i.props.post_view.creator.id,
1427       added: !i.isAdmin,
1428       auth: authField(),
1429     };
1430     WebSocketService.Instance.send(wsClient.addAdmin(form));
1431     i.setState(i.state);
1432   }
1433
1434   handleShowConfirmTransferCommunity(i: PostListing) {
1435     i.state.showConfirmTransferCommunity = true;
1436     i.setState(i.state);
1437   }
1438
1439   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1440     i.state.showConfirmTransferCommunity = false;
1441     i.setState(i.state);
1442   }
1443
1444   handleTransferCommunity(i: PostListing) {
1445     let form: TransferCommunity = {
1446       community_id: i.props.post_view.community.id,
1447       person_id: i.props.post_view.creator.id,
1448       auth: authField(),
1449     };
1450     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1451     i.state.showConfirmTransferCommunity = false;
1452     i.setState(i.state);
1453   }
1454
1455   handleShowConfirmTransferSite(i: PostListing) {
1456     i.state.showConfirmTransferSite = true;
1457     i.setState(i.state);
1458   }
1459
1460   handleCancelShowConfirmTransferSite(i: PostListing) {
1461     i.state.showConfirmTransferSite = false;
1462     i.setState(i.state);
1463   }
1464
1465   handleTransferSite(i: PostListing) {
1466     let form: TransferSite = {
1467       person_id: i.props.post_view.creator.id,
1468       auth: authField(),
1469     };
1470     WebSocketService.Instance.send(wsClient.transferSite(form));
1471     i.state.showConfirmTransferSite = false;
1472     i.setState(i.state);
1473   }
1474
1475   handleImageExpandClick(i: PostListing) {
1476     i.state.imageExpanded = !i.state.imageExpanded;
1477     i.setState(i.state);
1478   }
1479
1480   handleViewSource(i: PostListing) {
1481     i.state.viewSource = !i.state.viewSource;
1482     i.setState(i.state);
1483   }
1484
1485   handleShowAdvanced(i: PostListing) {
1486     i.state.showAdvanced = !i.state.showAdvanced;
1487     i.setState(i.state);
1488     setupTippy();
1489   }
1490
1491   handleShowMoreMobile(i: PostListing) {
1492     i.state.showMoreMobile = !i.state.showMoreMobile;
1493     i.state.showAdvanced = !i.state.showAdvanced;
1494     i.setState(i.state);
1495     setupTippy();
1496   }
1497
1498   get pointsTippy(): string {
1499     let points = i18n.t("number_of_points", {
1500       count: this.state.score,
1501     });
1502
1503     let upvotes = i18n.t("number_of_upvotes", {
1504       count: this.state.upvotes,
1505     });
1506
1507     let downvotes = i18n.t("number_of_downvotes", {
1508       count: this.state.downvotes,
1509     });
1510
1511     return `${points} • ${upvotes} • ${downvotes}`;
1512   }
1513 }