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