]> Untitled Git - lemmy.git/blob - ui/src/components/post-listing.tsx
Some UI fixes.
[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             <span
519               class="unselectable pointer ml-3"
520               data-tippy-content={this.pointsTippy}
521             >
522               <li className="list-inline-item">
523                 <a
524                   className={`btn-animate btn btn-link p-0 ${
525                     this.state.my_vote == 1 ? 'text-info' : 'text-muted'
526                   }`}
527                   onClick={linkEvent(this, this.handlePostLike)}
528                 >
529                   <svg class="small icon icon-inline mx-1">
530                     <use xlinkHref="#icon-arrow-up1"></use>
531                   </svg>
532                   {this.state.upvotes}
533                 </a>
534               </li>
535               <li className="list-inline-item">
536                 <a
537                   className={`btn-animate btn btn-link p-0 ${
538                     this.state.my_vote == -1 ? 'text-danger' : 'text-muted'
539                   }`}
540                   onClick={linkEvent(this, this.handlePostDisLike)}
541                 >
542                   <svg class="small icon icon-inline mx-1">
543                     <use xlinkHref="#icon-arrow-down1"></use>
544                   </svg>
545                   {this.state.downvotes}
546                 </a>
547               </li>
548             </span>
549           </>
550         )}
551       </ul>
552     );
553   }
554
555   duplicatesLine() {
556     return (
557       this.props.post.duplicates && (
558         <ul class="list-inline mb-1 small text-muted">
559           <>
560             <li className="list-inline-item mr-2">
561               {i18n.t('cross_posted_to')}
562             </li>
563             {this.props.post.duplicates.map(post => (
564               <li className="list-inline-item mr-2">
565                 <Link to={`/post/${post.id}`}>{post.community_name}</Link>
566               </li>
567             ))}
568           </>
569         </ul>
570       )
571     );
572   }
573
574   postActions() {
575     let post = this.props.post;
576     return (
577       <ul class="list-inline mb-1 text-muted font-weight-bold">
578         {UserService.Instance.user && (
579           <>
580             {this.props.showBody && (
581               <>
582                 <li className="list-inline-item">
583                   <button
584                     class="btn btn-link btn-animate text-muted"
585                     onClick={linkEvent(this, this.handleSavePostClick)}
586                     data-tippy-content={
587                       post.saved ? i18n.t('unsave') : i18n.t('save')
588                     }
589                   >
590                     <svg
591                       class={`icon icon-inline ${post.saved && 'text-warning'}`}
592                     >
593                       <use xlinkHref="#icon-star"></use>
594                     </svg>
595                   </button>
596                 </li>
597                 <li className="list-inline-item">
598                   <Link
599                     class="btn btn-link btn-animate text-muted"
600                     to={`/create_post${this.crossPostParams}`}
601                     title={i18n.t('cross_post')}
602                   >
603                     <svg class="icon icon-inline">
604                       <use xlinkHref="#icon-copy"></use>
605                     </svg>
606                   </Link>
607                 </li>
608               </>
609             )}
610             {this.myPost && this.props.showBody && (
611               <>
612                 <li className="list-inline-item">
613                   <button
614                     class="btn btn-link btn-animate text-muted"
615                     onClick={linkEvent(this, this.handleEditClick)}
616                     data-tippy-content={i18n.t('edit')}
617                   >
618                     <svg class="icon icon-inline">
619                       <use xlinkHref="#icon-edit"></use>
620                     </svg>
621                   </button>
622                 </li>
623                 <li className="list-inline-item">
624                   <button
625                     class="btn btn-link btn-animate text-muted"
626                     onClick={linkEvent(this, this.handleDeleteClick)}
627                     data-tippy-content={
628                       !post.deleted ? i18n.t('delete') : i18n.t('restore')
629                     }
630                   >
631                     <svg
632                       class={`icon icon-inline ${
633                         post.deleted && 'text-danger'
634                       }`}
635                     >
636                       <use xlinkHref="#icon-trash"></use>
637                     </svg>
638                   </button>
639                 </li>
640               </>
641             )}
642
643             {!this.state.showAdvanced && this.props.showBody ? (
644               <li className="list-inline-item">
645                 <button
646                   class="btn btn-link btn-animate text-muted"
647                   onClick={linkEvent(this, this.handleShowAdvanced)}
648                   data-tippy-content={i18n.t('more')}
649                 >
650                   <svg class="icon icon-inline">
651                     <use xlinkHref="#icon-more-vertical"></use>
652                   </svg>
653                 </button>
654               </li>
655             ) : (
656               <>
657                 {this.props.showBody && post.body && (
658                   <li className="list-inline-item">
659                     <button
660                       class="btn btn-link btn-animate text-muted"
661                       onClick={linkEvent(this, this.handleViewSource)}
662                       data-tippy-content={i18n.t('view_source')}
663                     >
664                       <svg
665                         class={`icon icon-inline ${
666                           this.state.viewSource && 'text-success'
667                         }`}
668                       >
669                         <use xlinkHref="#icon-file-text"></use>
670                       </svg>
671                     </button>
672                   </li>
673                 )}
674                 {this.canModOnSelf && (
675                   <>
676                     <li className="list-inline-item">
677                       <button
678                         class="btn btn-link btn-animate text-muted"
679                         onClick={linkEvent(this, this.handleModLock)}
680                         data-tippy-content={
681                           post.locked ? i18n.t('unlock') : i18n.t('lock')
682                         }
683                       >
684                         <svg
685                           class={`icon icon-inline ${
686                             post.locked && 'text-danger'
687                           }`}
688                         >
689                           <use xlinkHref="#icon-lock"></use>
690                         </svg>
691                       </button>
692                     </li>
693                     <li className="list-inline-item">
694                       <button
695                         class="btn btn-link btn-animate text-muted"
696                         onClick={linkEvent(this, this.handleModSticky)}
697                         data-tippy-content={
698                           post.stickied ? i18n.t('unsticky') : i18n.t('sticky')
699                         }
700                       >
701                         <svg
702                           class={`icon icon-inline ${
703                             post.stickied && 'text-success'
704                           }`}
705                         >
706                           <use xlinkHref="#icon-pin"></use>
707                         </svg>
708                       </button>
709                     </li>
710                   </>
711                 )}
712                 {/* Mods can ban from community, and appoint as mods to community */}
713                 {(this.canMod || this.canAdmin) && (
714                   <li className="list-inline-item">
715                     {!post.removed ? (
716                       <span
717                         class="pointer"
718                         onClick={linkEvent(this, this.handleModRemoveShow)}
719                       >
720                         {i18n.t('remove')}
721                       </span>
722                     ) : (
723                       <span
724                         class="pointer"
725                         onClick={linkEvent(this, this.handleModRemoveSubmit)}
726                       >
727                         {i18n.t('restore')}
728                       </span>
729                     )}
730                   </li>
731                 )}
732                 {this.canMod && (
733                   <>
734                     {!this.isMod && (
735                       <li className="list-inline-item">
736                         {!post.banned_from_community ? (
737                           <span
738                             class="pointer"
739                             onClick={linkEvent(
740                               this,
741                               this.handleModBanFromCommunityShow
742                             )}
743                           >
744                             {i18n.t('ban')}
745                           </span>
746                         ) : (
747                           <span
748                             class="pointer"
749                             onClick={linkEvent(
750                               this,
751                               this.handleModBanFromCommunitySubmit
752                             )}
753                           >
754                             {i18n.t('unban')}
755                           </span>
756                         )}
757                       </li>
758                     )}
759                     {!post.banned_from_community && (
760                       <li className="list-inline-item">
761                         <span
762                           class="pointer"
763                           onClick={linkEvent(
764                             this,
765                             this.handleAddModToCommunity
766                           )}
767                         >
768                           {this.isMod
769                             ? i18n.t('remove_as_mod')
770                             : i18n.t('appoint_as_mod')}
771                         </span>
772                       </li>
773                     )}
774                   </>
775                 )}
776                 {/* Community creators and admins can transfer community to another mod */}
777                 {(this.amCommunityCreator || this.canAdmin) && this.isMod && (
778                   <li className="list-inline-item">
779                     {!this.state.showConfirmTransferCommunity ? (
780                       <span
781                         class="pointer"
782                         onClick={linkEvent(
783                           this,
784                           this.handleShowConfirmTransferCommunity
785                         )}
786                       >
787                         {i18n.t('transfer_community')}
788                       </span>
789                     ) : (
790                       <>
791                         <span class="d-inline-block mr-1">
792                           {i18n.t('are_you_sure')}
793                         </span>
794                         <span
795                           class="pointer d-inline-block mr-1"
796                           onClick={linkEvent(
797                             this,
798                             this.handleTransferCommunity
799                           )}
800                         >
801                           {i18n.t('yes')}
802                         </span>
803                         <span
804                           class="pointer d-inline-block"
805                           onClick={linkEvent(
806                             this,
807                             this.handleCancelShowConfirmTransferCommunity
808                           )}
809                         >
810                           {i18n.t('no')}
811                         </span>
812                       </>
813                     )}
814                   </li>
815                 )}
816                 {/* Admins can ban from all, and appoint other admins */}
817                 {this.canAdmin && (
818                   <>
819                     {!this.isAdmin && (
820                       <li className="list-inline-item">
821                         {!post.banned ? (
822                           <span
823                             class="pointer"
824                             onClick={linkEvent(this, this.handleModBanShow)}
825                           >
826                             {i18n.t('ban_from_site')}
827                           </span>
828                         ) : (
829                           <span
830                             class="pointer"
831                             onClick={linkEvent(this, this.handleModBanSubmit)}
832                           >
833                             {i18n.t('unban_from_site')}
834                           </span>
835                         )}
836                       </li>
837                     )}
838                     {!post.banned && (
839                       <li className="list-inline-item">
840                         <span
841                           class="pointer"
842                           onClick={linkEvent(this, this.handleAddAdmin)}
843                         >
844                           {this.isAdmin
845                             ? i18n.t('remove_as_admin')
846                             : i18n.t('appoint_as_admin')}
847                         </span>
848                       </li>
849                     )}
850                   </>
851                 )}
852                 {/* Site Creator can transfer to another admin */}
853                 {this.amSiteCreator && this.isAdmin && (
854                   <li className="list-inline-item">
855                     {!this.state.showConfirmTransferSite ? (
856                       <span
857                         class="pointer"
858                         onClick={linkEvent(
859                           this,
860                           this.handleShowConfirmTransferSite
861                         )}
862                       >
863                         {i18n.t('transfer_site')}
864                       </span>
865                     ) : (
866                       <>
867                         <span class="d-inline-block mr-1">
868                           {i18n.t('are_you_sure')}
869                         </span>
870                         <span
871                           class="pointer d-inline-block mr-1"
872                           onClick={linkEvent(this, this.handleTransferSite)}
873                         >
874                           {i18n.t('yes')}
875                         </span>
876                         <span
877                           class="pointer d-inline-block"
878                           onClick={linkEvent(
879                             this,
880                             this.handleCancelShowConfirmTransferSite
881                           )}
882                         >
883                           {i18n.t('no')}
884                         </span>
885                       </>
886                     )}
887                   </li>
888                 )}
889               </>
890             )}
891           </>
892         )}
893       </ul>
894     );
895   }
896
897   removeAndBanDialogs() {
898     let post = this.props.post;
899     return (
900       <>
901         {this.state.showRemoveDialog && (
902           <form
903             class="form-inline"
904             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
905           >
906             <input
907               type="text"
908               class="form-control mr-2"
909               placeholder={i18n.t('reason')}
910               value={this.state.removeReason}
911               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
912             />
913             <button type="submit" class="btn btn-secondary">
914               {i18n.t('remove_post')}
915             </button>
916           </form>
917         )}
918         {this.state.showBanDialog && (
919           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
920             <div class="form-group row">
921               <label class="col-form-label" htmlFor="post-listing-reason">
922                 {i18n.t('reason')}
923               </label>
924               <input
925                 type="text"
926                 id="post-listing-reason"
927                 class="form-control mr-2"
928                 placeholder={i18n.t('reason')}
929                 value={this.state.banReason}
930                 onInput={linkEvent(this, this.handleModBanReasonChange)}
931               />
932             </div>
933             {/* TODO hold off on expires until later */}
934             {/* <div class="form-group row"> */}
935             {/*   <label class="col-form-label">Expires</label> */}
936             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
937             {/* </div> */}
938             <div class="form-group row">
939               <button type="submit" class="btn btn-secondary">
940                 {i18n.t('ban')} {post.creator_name}
941               </button>
942             </div>
943           </form>
944         )}
945       </>
946     );
947   }
948
949   mobileThumbnail() {
950     return this.props.post.thumbnail_url || isImage(this.props.post.url) ? (
951       <div class="row">
952         <div className={`${this.state.imageExpanded ? 'col-12' : 'col-8'}`}>
953           {this.postTitleLine()}
954         </div>
955         <div class="col-4">
956           {/* Post body prev or thumbnail */}
957           {!this.state.imageExpanded && this.thumbnail()}
958         </div>
959       </div>
960     ) : (
961       this.postTitleLine()
962     );
963   }
964
965   showMobilePreview() {
966     return (
967       this.props.post.body &&
968       !this.props.showBody && (
969         <div
970           className="md-div mb-1"
971           dangerouslySetInnerHTML={{
972             __html: md.render(previewLines(this.props.post.body)),
973           }}
974         />
975       )
976     );
977   }
978
979   listing() {
980     return (
981       <>
982         {/* The mobile view*/}
983         <div class="d-block d-sm-none">
984           <div class="row">
985             <div class="col-12">
986               {this.createdLine()}
987
988               {/* If it has a thumbnail, do a right aligned thumbnail */}
989               {this.mobileThumbnail()}
990
991               {/* Show a preview of the post body */}
992               {this.showMobilePreview()}
993
994               {this.commentsLine(true)}
995               {this.duplicatesLine()}
996               {this.postActions()}
997               {this.removeAndBanDialogs()}
998             </div>
999           </div>
1000         </div>
1001
1002         {/* The larger view*/}
1003         <div class="d-none d-sm-block">
1004           <div class="row">
1005             {this.voteBar()}
1006             {!this.state.imageExpanded && (
1007               <div class="col-sm-2 pr-0">
1008                 <div class="">{this.thumbnail()}</div>
1009               </div>
1010             )}
1011             <div
1012               class={`${
1013                 this.state.imageExpanded ? 'col-12' : 'col-12 col-sm-9'
1014               }`}
1015             >
1016               <div class="row">
1017                 <div className="col-12">
1018                   {this.postTitleLine()}
1019                   {this.createdLine()}
1020                   {this.commentsLine()}
1021                   {this.duplicatesLine()}
1022                   {this.postActions()}
1023                   {this.removeAndBanDialogs()}
1024                 </div>
1025               </div>
1026             </div>
1027           </div>
1028         </div>
1029       </>
1030     );
1031   }
1032
1033   private get myPost(): boolean {
1034     return (
1035       UserService.Instance.user &&
1036       this.props.post.creator_id == UserService.Instance.user.id
1037     );
1038   }
1039
1040   get isMod(): boolean {
1041     return (
1042       this.props.moderators &&
1043       isMod(
1044         this.props.moderators.map(m => m.user_id),
1045         this.props.post.creator_id
1046       )
1047     );
1048   }
1049
1050   get isAdmin(): boolean {
1051     return (
1052       this.props.admins &&
1053       isMod(
1054         this.props.admins.map(a => a.id),
1055         this.props.post.creator_id
1056       )
1057     );
1058   }
1059
1060   get canMod(): boolean {
1061     if (this.props.admins && this.props.moderators) {
1062       let adminsThenMods = this.props.admins
1063         .map(a => a.id)
1064         .concat(this.props.moderators.map(m => m.user_id));
1065
1066       return canMod(
1067         UserService.Instance.user,
1068         adminsThenMods,
1069         this.props.post.creator_id
1070       );
1071     } else {
1072       return false;
1073     }
1074   }
1075
1076   get canModOnSelf(): boolean {
1077     if (this.props.admins && this.props.moderators) {
1078       let adminsThenMods = this.props.admins
1079         .map(a => a.id)
1080         .concat(this.props.moderators.map(m => m.user_id));
1081
1082       return canMod(
1083         UserService.Instance.user,
1084         adminsThenMods,
1085         this.props.post.creator_id,
1086         true
1087       );
1088     } else {
1089       return false;
1090     }
1091   }
1092
1093   get canAdmin(): boolean {
1094     return (
1095       this.props.admins &&
1096       canMod(
1097         UserService.Instance.user,
1098         this.props.admins.map(a => a.id),
1099         this.props.post.creator_id
1100       )
1101     );
1102   }
1103
1104   get amCommunityCreator(): boolean {
1105     return (
1106       this.props.moderators &&
1107       UserService.Instance.user &&
1108       this.props.post.creator_id != UserService.Instance.user.id &&
1109       UserService.Instance.user.id == this.props.moderators[0].user_id
1110     );
1111   }
1112
1113   get amSiteCreator(): boolean {
1114     return (
1115       this.props.admins &&
1116       UserService.Instance.user &&
1117       this.props.post.creator_id != UserService.Instance.user.id &&
1118       UserService.Instance.user.id == this.props.admins[0].id
1119     );
1120   }
1121
1122   handlePostLike(i: PostListing) {
1123     if (!UserService.Instance.user) {
1124       this.context.router.history.push(`/login`);
1125     }
1126
1127     let new_vote = i.state.my_vote == 1 ? 0 : 1;
1128
1129     if (i.state.my_vote == 1) {
1130       i.state.score--;
1131       i.state.upvotes--;
1132     } else if (i.state.my_vote == -1) {
1133       i.state.downvotes--;
1134       i.state.upvotes++;
1135       i.state.score += 2;
1136     } else {
1137       i.state.upvotes++;
1138       i.state.score++;
1139     }
1140
1141     i.state.my_vote = new_vote;
1142
1143     let form: CreatePostLikeForm = {
1144       post_id: i.props.post.id,
1145       score: i.state.my_vote,
1146     };
1147
1148     WebSocketService.Instance.likePost(form);
1149     i.setState(i.state);
1150     setupTippy();
1151   }
1152
1153   handlePostDisLike(i: PostListing) {
1154     if (!UserService.Instance.user) {
1155       this.context.router.history.push(`/login`);
1156     }
1157
1158     let new_vote = i.state.my_vote == -1 ? 0 : -1;
1159
1160     if (i.state.my_vote == 1) {
1161       i.state.score -= 2;
1162       i.state.upvotes--;
1163       i.state.downvotes++;
1164     } else if (i.state.my_vote == -1) {
1165       i.state.downvotes--;
1166       i.state.score++;
1167     } else {
1168       i.state.downvotes++;
1169       i.state.score--;
1170     }
1171
1172     i.state.my_vote = new_vote;
1173
1174     let form: CreatePostLikeForm = {
1175       post_id: i.props.post.id,
1176       score: i.state.my_vote,
1177     };
1178
1179     WebSocketService.Instance.likePost(form);
1180     i.setState(i.state);
1181     setupTippy();
1182   }
1183
1184   handleEditClick(i: PostListing) {
1185     i.state.showEdit = true;
1186     i.setState(i.state);
1187   }
1188
1189   handleEditCancel() {
1190     this.state.showEdit = false;
1191     this.setState(this.state);
1192   }
1193
1194   // The actual editing is done in the recieve for post
1195   handleEditPost() {
1196     this.state.showEdit = false;
1197     this.setState(this.state);
1198   }
1199
1200   handleDeleteClick(i: PostListing) {
1201     let deleteForm: DeletePostForm = {
1202       edit_id: i.props.post.id,
1203       deleted: !i.props.post.deleted,
1204       auth: null,
1205     };
1206     WebSocketService.Instance.deletePost(deleteForm);
1207   }
1208
1209   handleSavePostClick(i: PostListing) {
1210     let saved = i.props.post.saved == undefined ? true : !i.props.post.saved;
1211     let form: SavePostForm = {
1212       post_id: i.props.post.id,
1213       save: saved,
1214     };
1215
1216     WebSocketService.Instance.savePost(form);
1217   }
1218
1219   get crossPostParams(): string {
1220     let params = `?title=${this.props.post.name}`;
1221     let post = this.props.post;
1222
1223     if (post.url) {
1224       params += `&url=${post.url}`;
1225     }
1226     if (this.props.post.body) {
1227       params += `&body=${this.props.post.body}`;
1228     }
1229     return params;
1230   }
1231
1232   handleModRemoveShow(i: PostListing) {
1233     i.state.showRemoveDialog = true;
1234     i.setState(i.state);
1235   }
1236
1237   handleModRemoveReasonChange(i: PostListing, event: any) {
1238     i.state.removeReason = event.target.value;
1239     i.setState(i.state);
1240   }
1241
1242   handleModRemoveSubmit(i: PostListing) {
1243     event.preventDefault();
1244     let form: RemovePostForm = {
1245       edit_id: i.props.post.id,
1246       removed: !i.props.post.removed,
1247       reason: i.state.removeReason,
1248       auth: null,
1249     };
1250     WebSocketService.Instance.removePost(form);
1251
1252     i.state.showRemoveDialog = false;
1253     i.setState(i.state);
1254   }
1255
1256   handleModLock(i: PostListing) {
1257     let form: LockPostForm = {
1258       edit_id: i.props.post.id,
1259       locked: !i.props.post.locked,
1260       auth: null,
1261     };
1262     WebSocketService.Instance.lockPost(form);
1263   }
1264
1265   handleModSticky(i: PostListing) {
1266     let form: StickyPostForm = {
1267       edit_id: i.props.post.id,
1268       stickied: !i.props.post.stickied,
1269       auth: null,
1270     };
1271     WebSocketService.Instance.stickyPost(form);
1272   }
1273
1274   handleModBanFromCommunityShow(i: PostListing) {
1275     i.state.showBanDialog = true;
1276     i.state.banType = BanType.Community;
1277     i.setState(i.state);
1278   }
1279
1280   handleModBanShow(i: PostListing) {
1281     i.state.showBanDialog = true;
1282     i.state.banType = BanType.Site;
1283     i.setState(i.state);
1284   }
1285
1286   handleModBanReasonChange(i: PostListing, event: any) {
1287     i.state.banReason = event.target.value;
1288     i.setState(i.state);
1289   }
1290
1291   handleModBanExpiresChange(i: PostListing, event: any) {
1292     i.state.banExpires = event.target.value;
1293     i.setState(i.state);
1294   }
1295
1296   handleModBanFromCommunitySubmit(i: PostListing) {
1297     i.state.banType = BanType.Community;
1298     i.setState(i.state);
1299     i.handleModBanBothSubmit(i);
1300   }
1301
1302   handleModBanSubmit(i: PostListing) {
1303     i.state.banType = BanType.Site;
1304     i.setState(i.state);
1305     i.handleModBanBothSubmit(i);
1306   }
1307
1308   handleModBanBothSubmit(i: PostListing) {
1309     event.preventDefault();
1310
1311     if (i.state.banType == BanType.Community) {
1312       let form: BanFromCommunityForm = {
1313         user_id: i.props.post.creator_id,
1314         community_id: i.props.post.community_id,
1315         ban: !i.props.post.banned_from_community,
1316         reason: i.state.banReason,
1317         expires: getUnixTime(i.state.banExpires),
1318       };
1319       WebSocketService.Instance.banFromCommunity(form);
1320     } else {
1321       let form: BanUserForm = {
1322         user_id: i.props.post.creator_id,
1323         ban: !i.props.post.banned,
1324         reason: i.state.banReason,
1325         expires: getUnixTime(i.state.banExpires),
1326       };
1327       WebSocketService.Instance.banUser(form);
1328     }
1329
1330     i.state.showBanDialog = false;
1331     i.setState(i.state);
1332   }
1333
1334   handleAddModToCommunity(i: PostListing) {
1335     let form: AddModToCommunityForm = {
1336       user_id: i.props.post.creator_id,
1337       community_id: i.props.post.community_id,
1338       added: !i.isMod,
1339     };
1340     WebSocketService.Instance.addModToCommunity(form);
1341     i.setState(i.state);
1342   }
1343
1344   handleAddAdmin(i: PostListing) {
1345     let form: AddAdminForm = {
1346       user_id: i.props.post.creator_id,
1347       added: !i.isAdmin,
1348     };
1349     WebSocketService.Instance.addAdmin(form);
1350     i.setState(i.state);
1351   }
1352
1353   handleShowConfirmTransferCommunity(i: PostListing) {
1354     i.state.showConfirmTransferCommunity = true;
1355     i.setState(i.state);
1356   }
1357
1358   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1359     i.state.showConfirmTransferCommunity = false;
1360     i.setState(i.state);
1361   }
1362
1363   handleTransferCommunity(i: PostListing) {
1364     let form: TransferCommunityForm = {
1365       community_id: i.props.post.community_id,
1366       user_id: i.props.post.creator_id,
1367     };
1368     WebSocketService.Instance.transferCommunity(form);
1369     i.state.showConfirmTransferCommunity = false;
1370     i.setState(i.state);
1371   }
1372
1373   handleShowConfirmTransferSite(i: PostListing) {
1374     i.state.showConfirmTransferSite = true;
1375     i.setState(i.state);
1376   }
1377
1378   handleCancelShowConfirmTransferSite(i: PostListing) {
1379     i.state.showConfirmTransferSite = false;
1380     i.setState(i.state);
1381   }
1382
1383   handleTransferSite(i: PostListing) {
1384     let form: TransferSiteForm = {
1385       user_id: i.props.post.creator_id,
1386     };
1387     WebSocketService.Instance.transferSite(form);
1388     i.state.showConfirmTransferSite = false;
1389     i.setState(i.state);
1390   }
1391
1392   handleImageExpandClick(i: PostListing) {
1393     i.state.imageExpanded = !i.state.imageExpanded;
1394     i.setState(i.state);
1395   }
1396
1397   handleViewSource(i: PostListing) {
1398     i.state.viewSource = !i.state.viewSource;
1399     i.setState(i.state);
1400   }
1401
1402   handleShowAdvanced(i: PostListing) {
1403     i.state.showAdvanced = !i.state.showAdvanced;
1404     i.setState(i.state);
1405     setupTippy();
1406   }
1407
1408   get pointsTippy(): string {
1409     let points = i18n.t('number_of_points', {
1410       count: this.state.score,
1411     });
1412
1413     let upvotes = i18n.t('number_of_upvotes', {
1414       count: this.state.upvotes,
1415     });
1416
1417     let downvotes = i18n.t('number_of_downvotes', {
1418       count: this.state.downvotes,
1419     });
1420
1421     return `${points} • ${upvotes} • ${downvotes}`;
1422   }
1423 }