]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post/post-listing.tsx
Adding Community Language fixes. #783 (#868)
[lemmy-ui.git] / src / shared / components / post / post-listing.tsx
1 import { None, Option, Some } from "@sniptt/monads";
2 import classNames from "classnames";
3 import { Component, linkEvent } from "inferno";
4 import { Link } from "inferno-router";
5 import {
6   AddAdmin,
7   AddModToCommunity,
8   BanFromCommunity,
9   BanPerson,
10   BlockPerson,
11   CommunityModeratorView,
12   CreatePostLike,
13   CreatePostReport,
14   DeletePost,
15   FeaturePost,
16   Language,
17   LockPost,
18   PersonViewSafe,
19   PostFeatureType,
20   PostView,
21   PurgePerson,
22   PurgePost,
23   RemovePost,
24   SavePost,
25   toUndefined,
26   TransferCommunity,
27 } from "lemmy-js-client";
28 import { externalHost } from "../../env";
29 import { i18n } from "../../i18next";
30 import { BanType, PurgeType } from "../../interfaces";
31 import { UserService, WebSocketService } from "../../services";
32 import {
33   amAdmin,
34   amCommunityCreator,
35   auth,
36   canAdmin,
37   canMod,
38   futureDaysToUnixTime,
39   hostname,
40   isAdmin,
41   isBanned,
42   isImage,
43   isMod,
44   isVideo,
45   mdNoImages,
46   mdToHtml,
47   mdToHtmlInline,
48   numToSI,
49   relTags,
50   setupTippy,
51   showScores,
52   wsClient,
53 } from "../../utils";
54 import { Icon, PurgeWarning, Spinner } from "../common/icon";
55 import { MomentTime } from "../common/moment-time";
56 import { PictrsImage } from "../common/pictrs-image";
57 import { CommunityLink } from "../community/community-link";
58 import { PersonListing } from "../person/person-listing";
59 import { MetadataCard } from "./metadata-card";
60 import { PostForm } from "./post-form";
61
62 interface PostListingState {
63   showEdit: boolean;
64   showRemoveDialog: boolean;
65   showPurgeDialog: boolean;
66   purgeReason: Option<string>;
67   purgeType: PurgeType;
68   purgeLoading: boolean;
69   removeReason: Option<string>;
70   showBanDialog: boolean;
71   banReason: Option<string>;
72   banExpireDays: Option<number>;
73   banType: BanType;
74   removeData: boolean;
75   showConfirmTransferSite: boolean;
76   showConfirmTransferCommunity: boolean;
77   imageExpanded: boolean;
78   viewSource: boolean;
79   showAdvanced: boolean;
80   showMoreMobile: boolean;
81   showBody: boolean;
82   showReportDialog: boolean;
83   reportReason: Option<string>;
84   my_vote: Option<number>;
85   score: number;
86   upvotes: number;
87   downvotes: number;
88 }
89
90 interface PostListingProps {
91   post_view: PostView;
92   duplicates: Option<PostView[]>;
93   moderators: Option<CommunityModeratorView[]>;
94   admins: Option<PersonViewSafe[]>;
95   allLanguages: Language[];
96   siteLanguages: number[];
97   showCommunity?: boolean;
98   showBody?: boolean;
99   hideImage?: boolean;
100   enableDownvotes?: boolean;
101   enableNsfw?: boolean;
102   viewOnly?: boolean;
103 }
104
105 export class PostListing extends Component<PostListingProps, PostListingState> {
106   private emptyState: PostListingState = {
107     showEdit: false,
108     showRemoveDialog: false,
109     showPurgeDialog: false,
110     purgeReason: None,
111     purgeType: PurgeType.Person,
112     purgeLoading: false,
113     removeReason: None,
114     showBanDialog: false,
115     banReason: None,
116     banExpireDays: None,
117     banType: BanType.Community,
118     removeData: false,
119     showConfirmTransferSite: false,
120     showConfirmTransferCommunity: false,
121     imageExpanded: false,
122     viewSource: false,
123     showAdvanced: false,
124     showMoreMobile: false,
125     showBody: false,
126     showReportDialog: false,
127     reportReason: None,
128     my_vote: this.props.post_view.my_vote,
129     score: this.props.post_view.counts.score,
130     upvotes: this.props.post_view.counts.upvotes,
131     downvotes: this.props.post_view.counts.downvotes,
132   };
133
134   constructor(props: any, context: any) {
135     super(props, context);
136
137     this.state = this.emptyState;
138     this.handlePostLike = this.handlePostLike.bind(this);
139     this.handlePostDisLike = this.handlePostDisLike.bind(this);
140     this.handleEditPost = this.handleEditPost.bind(this);
141     this.handleEditCancel = this.handleEditCancel.bind(this);
142   }
143
144   componentWillReceiveProps(nextProps: PostListingProps) {
145     this.setState({
146       my_vote: nextProps.post_view.my_vote,
147       upvotes: nextProps.post_view.counts.upvotes,
148       downvotes: nextProps.post_view.counts.downvotes,
149       score: nextProps.post_view.counts.score,
150     });
151     if (this.props.post_view.post.id !== nextProps.post_view.post.id) {
152       this.setState({ imageExpanded: false });
153     }
154   }
155
156   render() {
157     let post = this.props.post_view.post;
158     return (
159       <div className="post-listing">
160         {!this.state.showEdit ? (
161           <>
162             {this.listing()}
163             {this.state.imageExpanded && !this.props.hideImage && this.img}
164             {post.url.isSome() &&
165               this.showBody &&
166               post.embed_title.isSome() && <MetadataCard post={post} />}
167             {this.showBody && this.body()}
168           </>
169         ) : (
170           <div className="col-12">
171             <PostForm
172               post_view={Some(this.props.post_view)}
173               communities={None}
174               params={None}
175               onEdit={this.handleEditPost}
176               onCancel={this.handleEditCancel}
177               enableNsfw={this.props.enableNsfw}
178               enableDownvotes={this.props.enableDownvotes}
179               allLanguages={this.props.allLanguages}
180               siteLanguages={this.props.siteLanguages}
181             />
182           </div>
183         )}
184       </div>
185     );
186   }
187
188   body() {
189     return this.props.post_view.post.body.match({
190       some: body => (
191         <div className="col-12 card my-2 p-2">
192           {this.state.viewSource ? (
193             <pre>{body}</pre>
194           ) : (
195             <div className="md-div" dangerouslySetInnerHTML={mdToHtml(body)} />
196           )}
197         </div>
198       ),
199       none: <></>,
200     });
201   }
202
203   get img() {
204     return this.imageSrc.match({
205       some: src => (
206         <>
207           <div className="offset-sm-3 my-2 d-none d-sm-block">
208             <a href={src} className="d-inline-block">
209               <PictrsImage src={src} />
210             </a>
211           </div>
212           <div className="my-2 d-block d-sm-none">
213             <a
214               className="d-inline-block"
215               onClick={linkEvent(this, this.handleImageExpandClick)}
216             >
217               <PictrsImage src={src} />
218             </a>
219           </div>
220         </>
221       ),
222       none: <></>,
223     });
224   }
225
226   imgThumb(src: string) {
227     let post_view = this.props.post_view;
228     return (
229       <PictrsImage
230         src={src}
231         thumbnail
232         alt=""
233         nsfw={post_view.post.nsfw || post_view.community.nsfw}
234       />
235     );
236   }
237
238   get imageSrc(): Option<string> {
239     let post = this.props.post_view.post;
240     let url = post.url;
241     let thumbnail = post.thumbnail_url;
242
243     if (url.isSome() && isImage(url.unwrap())) {
244       if (url.unwrap().includes("pictrs")) {
245         return url;
246       } else if (thumbnail.isSome()) {
247         return thumbnail;
248       } else {
249         return url;
250       }
251     } else if (thumbnail.isSome()) {
252       return thumbnail;
253     } else {
254       return None;
255     }
256   }
257
258   thumbnail() {
259     let post = this.props.post_view.post;
260     let url = post.url;
261     let thumbnail = post.thumbnail_url;
262
263     if (!this.props.hideImage && url.isSome() && isImage(url.unwrap())) {
264       return (
265         <a
266           href={this.imageSrc.unwrap()}
267           className="text-body d-inline-block position-relative mb-2"
268           data-tippy-content={i18n.t("expand_here")}
269           onClick={linkEvent(this, this.handleImageExpandClick)}
270           aria-label={i18n.t("expand_here")}
271         >
272           {this.imgThumb(this.imageSrc.unwrap())}
273           <Icon icon="image" classes="mini-overlay" />
274         </a>
275       );
276     } else if (!this.props.hideImage && url.isSome() && thumbnail.isSome()) {
277       return (
278         <a
279           className="text-body d-inline-block position-relative mb-2"
280           href={url.unwrap()}
281           rel={relTags}
282           title={url.unwrap()}
283         >
284           {this.imgThumb(this.imageSrc.unwrap())}
285           <Icon icon="external-link" classes="mini-overlay" />
286         </a>
287       );
288     } else if (url.isSome()) {
289       if (!this.props.hideImage && isVideo(url.unwrap())) {
290         return (
291           <div className="embed-responsive embed-responsive-16by9">
292             <video
293               playsInline
294               muted
295               loop
296               controls
297               className="embed-responsive-item"
298             >
299               <source src={url.unwrap()} type="video/mp4" />
300             </video>
301           </div>
302         );
303       } else {
304         return (
305           <a
306             className="text-body"
307             href={url.unwrap()}
308             title={url.unwrap()}
309             rel={relTags}
310           >
311             <div className="thumbnail rounded bg-light d-flex justify-content-center">
312               <Icon icon="external-link" classes="d-flex align-items-center" />
313             </div>
314           </a>
315         );
316       }
317     } else {
318       return (
319         <Link
320           className="text-body"
321           to={`/post/${post.id}`}
322           title={i18n.t("comments")}
323         >
324           <div className="thumbnail rounded bg-light d-flex justify-content-center">
325             <Icon icon="message-square" classes="d-flex align-items-center" />
326           </div>
327         </Link>
328       );
329     }
330   }
331
332   createdLine() {
333     let post_view = this.props.post_view;
334     return (
335       <ul className="list-inline mb-1 text-muted small">
336         <li className="list-inline-item">
337           <PersonListing person={post_view.creator} />
338
339           {this.creatorIsMod_ && (
340             <span className="mx-1 badge badge-light">{i18n.t("mod")}</span>
341           )}
342           {this.creatorIsAdmin_ && (
343             <span className="mx-1 badge badge-light">{i18n.t("admin")}</span>
344           )}
345           {post_view.creator.bot_account && (
346             <span className="mx-1 badge badge-light">
347               {i18n.t("bot_account").toLowerCase()}
348             </span>
349           )}
350           {(post_view.creator_banned_from_community ||
351             isBanned(post_view.creator)) && (
352             <span className="mx-1 badge badge-danger">{i18n.t("banned")}</span>
353           )}
354           {post_view.creator_blocked && (
355             <span className="mx-1 badge badge-danger">{"blocked"}</span>
356           )}
357           {this.props.showCommunity && (
358             <span>
359               <span className="mx-1"> {i18n.t("to")} </span>
360               <CommunityLink community={post_view.community} />
361             </span>
362           )}
363         </li>
364         <li className="list-inline-item">•</li>
365         {post_view.post.url.match({
366           some: url =>
367             !(hostname(url) == externalHost) && (
368               <>
369                 <li className="list-inline-item">
370                   <a
371                     className="text-muted font-italic"
372                     href={url}
373                     title={url}
374                     rel={relTags}
375                   >
376                     {hostname(url)}
377                   </a>
378                 </li>
379                 <li className="list-inline-item">•</li>
380               </>
381             ),
382           none: <></>,
383         })}
384         <li className="list-inline-item">
385           <span>
386             <MomentTime
387               published={post_view.post.published}
388               updated={post_view.post.updated}
389             />
390           </span>
391         </li>
392         {post_view.post.body.match({
393           some: body => (
394             <>
395               <li className="list-inline-item">•</li>
396               <li className="list-inline-item">
397                 <button
398                   className="text-muted btn btn-sm btn-link p-0"
399                   data-tippy-content={mdNoImages.render(body)}
400                   data-tippy-allowHtml={true}
401                   onClick={linkEvent(this, this.handleShowBody)}
402                 >
403                   <Icon icon="book-open" classes="icon-inline mr-1" />
404                 </button>
405               </li>
406             </>
407           ),
408           none: <></>,
409         })}
410       </ul>
411     );
412   }
413
414   voteBar() {
415     return (
416       <div className={`vote-bar col-1 pr-0 small text-center`}>
417         <button
418           className={`btn-animate btn btn-link p-0 ${
419             this.state.my_vote.unwrapOr(0) == 1 ? "text-info" : "text-muted"
420           }`}
421           onClick={this.handlePostLike}
422           data-tippy-content={i18n.t("upvote")}
423           aria-label={i18n.t("upvote")}
424         >
425           <Icon icon="arrow-up1" classes="upvote" />
426         </button>
427         {showScores() ? (
428           <div
429             className={`unselectable pointer font-weight-bold text-muted px-1`}
430             data-tippy-content={this.pointsTippy}
431           >
432             {numToSI(this.state.score)}
433           </div>
434         ) : (
435           <div className="p-1"></div>
436         )}
437         {this.props.enableDownvotes && (
438           <button
439             className={`btn-animate btn btn-link p-0 ${
440               this.state.my_vote.unwrapOr(0) == -1
441                 ? "text-danger"
442                 : "text-muted"
443             }`}
444             onClick={this.handlePostDisLike}
445             data-tippy-content={i18n.t("downvote")}
446             aria-label={i18n.t("downvote")}
447           >
448             <Icon icon="arrow-down1" classes="downvote" />
449           </button>
450         )}
451       </div>
452     );
453   }
454
455   get postLink() {
456     let post = this.props.post_view.post;
457     return (
458       <Link
459         className={
460           !post.featured_community && !post.featured_local
461             ? "text-body"
462             : "text-primary"
463         }
464         to={`/post/${post.id}`}
465         title={i18n.t("comments")}
466       >
467         <div dangerouslySetInnerHTML={mdToHtmlInline(post.name)} />
468       </Link>
469     );
470   }
471
472   postTitleLine() {
473     let post = this.props.post_view.post;
474     return (
475       <div className="post-title overflow-hidden">
476         <h5>
477           {post.url.match({
478             some: url =>
479               this.props.showBody ? (
480                 <a
481                   className={
482                     !post.featured_community && !post.featured_local
483                       ? "text-body"
484                       : "text-primary"
485                   }
486                   href={url}
487                   title={url}
488                   rel={relTags}
489                 >
490                   <div dangerouslySetInnerHTML={mdToHtmlInline(post.name)} />
491                 </a>
492               ) : (
493                 this.postLink
494               ),
495             none: this.postLink,
496           })}
497           {post.url.map(isImage).or(post.thumbnail_url).unwrapOr(false) && (
498             <button
499               className="btn btn-link text-monospace text-muted small d-inline-block ml-2"
500               data-tippy-content={i18n.t("expand_here")}
501               onClick={linkEvent(this, this.handleImageExpandClick)}
502             >
503               <Icon
504                 icon={
505                   !this.state.imageExpanded ? "plus-square" : "minus-square"
506                 }
507                 classes="icon-inline"
508               />
509             </button>
510           )}
511           {post.removed && (
512             <small className="ml-2 text-muted font-italic">
513               {i18n.t("removed")}
514             </small>
515           )}
516           {post.deleted && (
517             <small
518               className="unselectable pointer ml-2 text-muted font-italic"
519               data-tippy-content={i18n.t("deleted")}
520             >
521               <Icon icon="trash" classes="icon-inline text-danger" />
522             </small>
523           )}
524           {post.locked && (
525             <small
526               className="unselectable pointer ml-2 text-muted font-italic"
527               data-tippy-content={i18n.t("locked")}
528             >
529               <Icon icon="lock" classes="icon-inline text-danger" />
530             </small>
531           )}
532           {post.featured_community && (
533             <small
534               className="unselectable pointer ml-2 text-muted font-italic"
535               data-tippy-content={i18n.t("featured")}
536             >
537               <Icon icon="pin" classes="icon-inline text-primary" />
538             </small>
539           )}
540           {post.featured_local && (
541             <small
542               className="unselectable pointer ml-2 text-muted font-italic"
543               data-tippy-content={i18n.t("featured")}
544             >
545               <Icon icon="pin" classes="icon-inline text-secondary" />
546             </small>
547           )}
548           {post.nsfw && (
549             <small className="ml-2 text-muted font-italic">
550               {i18n.t("nsfw")}
551             </small>
552           )}
553         </h5>
554       </div>
555     );
556   }
557
558   duplicatesLine() {
559     return this.props.duplicates.match({
560       some: dupes =>
561         dupes.length > 0 && (
562           <ul className="list-inline mb-1 small text-muted">
563             <>
564               <li className="list-inline-item mr-2">
565                 {i18n.t("cross_posted_to")}
566               </li>
567               {dupes.map(pv => (
568                 <li key={pv.post.id} className="list-inline-item mr-2">
569                   <Link to={`/post/${pv.post.id}`}>
570                     {pv.community.local
571                       ? pv.community.name
572                       : `${pv.community.name}@${hostname(
573                           pv.community.actor_id
574                         )}`}
575                   </Link>
576                 </li>
577               ))}
578             </>
579           </ul>
580         ),
581       none: <></>,
582     });
583   }
584
585   commentsLine(mobile = false) {
586     let post = this.props.post_view.post;
587     return (
588       <div className="d-flex justify-content-start flex-wrap text-muted font-weight-bold mb-1">
589         {this.commentsButton}
590         {!post.local && (
591           <a
592             className="btn btn-link btn-animate text-muted py-0"
593             title={i18n.t("link")}
594             href={post.ap_id}
595           >
596             <Icon icon="fedilink" inline />
597           </a>
598         )}
599         {mobile && !this.props.viewOnly && this.mobileVotes}
600         {UserService.Instance.myUserInfo.isSome() &&
601           !this.props.viewOnly &&
602           this.postActions(mobile)}
603       </div>
604     );
605   }
606
607   postActions(mobile = false) {
608     // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
609     // Possible enhancement: Make each button a component.
610     let post_view = this.props.post_view;
611     return (
612       <>
613         {this.saveButton}
614         {this.crossPostButton}
615         {mobile && this.showMoreButton}
616         {(!mobile || this.state.showAdvanced) && (
617           <>
618             {!this.myPost && (
619               <>
620                 {this.reportButton}
621                 {this.blockButton}
622               </>
623             )}
624             {this.myPost && (this.showBody || this.state.showAdvanced) && (
625               <>
626                 {this.editButton}
627                 {this.deleteButton}
628               </>
629             )}
630           </>
631         )}
632         {this.state.showAdvanced && (
633           <>
634             {this.showBody &&
635               post_view.post.body.isSome() &&
636               this.viewSourceButton}
637             {this.canModOnSelf_ && (
638               <>
639                 {this.lockButton}
640                 {this.featureButton}
641               </>
642             )}
643             {(this.canMod_ || this.canAdmin_) && <>{this.modRemoveButton}</>}
644           </>
645         )}
646         {!mobile && this.showMoreButton}
647       </>
648     );
649   }
650
651   get commentsButton() {
652     let post_view = this.props.post_view;
653     return (
654       <button className="btn btn-link text-muted py-0 pl-0">
655         <Link
656           className="text-muted"
657           title={i18n.t("number_of_comments", {
658             count: post_view.counts.comments,
659             formattedCount: post_view.counts.comments,
660           })}
661           to={`/post/${post_view.post.id}?scrollToComments=true`}
662         >
663           <Icon icon="message-square" classes="mr-1" inline />
664           <span className="mr-2">
665             {i18n.t("number_of_comments", {
666               count: post_view.counts.comments,
667               formattedCount: numToSI(post_view.counts.comments),
668             })}
669           </span>
670           {this.unreadCount.match({
671             some: unreadCount => (
672               <span className="small text-warning">
673                 ({unreadCount} {i18n.t("new")})
674               </span>
675             ),
676             none: <></>,
677           })}
678         </Link>
679       </button>
680     );
681   }
682
683   get unreadCount(): Option<number> {
684     let pv = this.props.post_view;
685     if (pv.unread_comments == pv.counts.comments || pv.unread_comments == 0) {
686       return None;
687     } else {
688       return Some(pv.unread_comments);
689     }
690   }
691
692   get mobileVotes() {
693     // TODO: make nicer
694     let tippy = showScores() ? { "data-tippy-content": this.pointsTippy } : {};
695     return (
696       <>
697         <div>
698           <button
699             className={`btn-animate btn py-0 px-1 ${
700               this.state.my_vote.unwrapOr(0) == 1 ? "text-info" : "text-muted"
701             }`}
702             {...tippy}
703             onClick={this.handlePostLike}
704             aria-label={i18n.t("upvote")}
705           >
706             <Icon icon="arrow-up1" classes="icon-inline small" />
707             {showScores() && (
708               <span className="ml-2">{numToSI(this.state.upvotes)}</span>
709             )}
710           </button>
711           {this.props.enableDownvotes && (
712             <button
713               className={`ml-2 btn-animate btn py-0 px-1 ${
714                 this.state.my_vote.unwrapOr(0) == -1
715                   ? "text-danger"
716                   : "text-muted"
717               }`}
718               onClick={this.handlePostDisLike}
719               {...tippy}
720               aria-label={i18n.t("downvote")}
721             >
722               <Icon icon="arrow-down1" classes="icon-inline small" />
723               {showScores() && (
724                 <span
725                   className={classNames("ml-2", {
726                     invisible: this.state.downvotes === 0,
727                   })}
728                 >
729                   {numToSI(this.state.downvotes)}
730                 </span>
731               )}
732             </button>
733           )}
734         </div>
735       </>
736     );
737   }
738
739   get saveButton() {
740     let saved = this.props.post_view.saved;
741     let label = saved ? i18n.t("unsave") : i18n.t("save");
742     return (
743       <button
744         className="btn btn-link btn-animate text-muted py-0"
745         onClick={linkEvent(this, this.handleSavePostClick)}
746         data-tippy-content={label}
747         aria-label={label}
748       >
749         <Icon
750           icon="star"
751           classes={classNames({ "text-warning": saved })}
752           inline
753         />
754       </button>
755     );
756   }
757
758   get crossPostButton() {
759     return (
760       <Link
761         className="btn btn-link btn-animate text-muted py-0"
762         to={`/create_post${this.crossPostParams}`}
763         title={i18n.t("cross_post")}
764       >
765         <Icon icon="copy" inline />
766       </Link>
767     );
768   }
769
770   get reportButton() {
771     return (
772       <button
773         className="btn btn-link btn-animate text-muted py-0"
774         onClick={linkEvent(this, this.handleShowReportDialog)}
775         data-tippy-content={i18n.t("show_report_dialog")}
776         aria-label={i18n.t("show_report_dialog")}
777       >
778         <Icon icon="flag" inline />
779       </button>
780     );
781   }
782
783   get blockButton() {
784     return (
785       <button
786         className="btn btn-link btn-animate text-muted py-0"
787         onClick={linkEvent(this, this.handleBlockUserClick)}
788         data-tippy-content={i18n.t("block_user")}
789         aria-label={i18n.t("block_user")}
790       >
791         <Icon icon="slash" inline />
792       </button>
793     );
794   }
795
796   get editButton() {
797     return (
798       <button
799         className="btn btn-link btn-animate text-muted py-0"
800         onClick={linkEvent(this, this.handleEditClick)}
801         data-tippy-content={i18n.t("edit")}
802         aria-label={i18n.t("edit")}
803       >
804         <Icon icon="edit" inline />
805       </button>
806     );
807   }
808
809   get deleteButton() {
810     let deleted = this.props.post_view.post.deleted;
811     let label = !deleted ? i18n.t("delete") : i18n.t("restore");
812     return (
813       <button
814         className="btn btn-link btn-animate text-muted py-0"
815         onClick={linkEvent(this, this.handleDeleteClick)}
816         data-tippy-content={label}
817         aria-label={label}
818       >
819         <Icon
820           icon="trash"
821           classes={classNames({ "text-danger": deleted })}
822           inline
823         />
824       </button>
825     );
826   }
827
828   get showMoreButton() {
829     return (
830       <button
831         className="btn btn-link btn-animate text-muted py-0"
832         onClick={linkEvent(this, this.handleShowAdvanced)}
833         data-tippy-content={i18n.t("more")}
834         aria-label={i18n.t("more")}
835       >
836         <Icon icon="more-vertical" inline />
837       </button>
838     );
839   }
840
841   get viewSourceButton() {
842     return (
843       <button
844         className="btn btn-link btn-animate text-muted py-0"
845         onClick={linkEvent(this, this.handleViewSource)}
846         data-tippy-content={i18n.t("view_source")}
847         aria-label={i18n.t("view_source")}
848       >
849         <Icon
850           icon="file-text"
851           classes={classNames({ "text-success": this.state.viewSource })}
852           inline
853         />
854       </button>
855     );
856   }
857
858   get lockButton() {
859     let locked = this.props.post_view.post.locked;
860     let label = locked ? i18n.t("unlock") : i18n.t("lock");
861     return (
862       <button
863         className="btn btn-link btn-animate text-muted py-0"
864         onClick={linkEvent(this, this.handleModLock)}
865         data-tippy-content={label}
866         aria-label={label}
867       >
868         <Icon
869           icon="lock"
870           classes={classNames({ "text-danger": locked })}
871           inline
872         />
873       </button>
874     );
875   }
876
877   get featureButton() {
878     const featured_community = this.props.post_view.post.featured_community;
879     const label_community = featured_community
880       ? i18n.t("unfeature_from_community")
881       : i18n.t("feature_in_community");
882
883     const is_admin = amAdmin();
884     const featured_local = this.props.post_view.post.featured_local;
885     const label_local = featured_local
886       ? i18n.t("unfeature_from_local")
887       : i18n.t("feature_in_local");
888     return (
889       <span>
890         <button
891           className="btn btn-link btn-animate text-muted py-0 pl-0"
892           onClick={() => this.handleModFeaturePost(this, true)}
893           data-tippy-content={label_community}
894           aria-label={label_community}
895         >
896           <Icon
897             icon="pin"
898             classes={classNames({ "text-success": featured_community })}
899             inline
900           />{" "}
901           Community
902         </button>
903         {is_admin && (
904           <button
905             className="btn btn-link btn-animate text-muted py-0"
906             onClick={() => this.handleModFeaturePost(this, false)}
907             data-tippy-content={label_local}
908             aria-label={label_local}
909           >
910             <Icon
911               icon="pin"
912               classes={classNames({ "text-success": featured_local })}
913               inline
914             />{" "}
915             Local
916           </button>
917         )}
918       </span>
919     );
920   }
921
922   get modRemoveButton() {
923     let removed = this.props.post_view.post.removed;
924     return (
925       <button
926         className="btn btn-link btn-animate text-muted py-0"
927         onClick={linkEvent(
928           this,
929           !removed ? this.handleModRemoveShow : this.handleModRemoveSubmit
930         )}
931       >
932         {/* TODO: Find an icon for this. */}
933         {!removed ? i18n.t("remove") : i18n.t("restore")}
934       </button>
935     );
936   }
937
938   /**
939    * Mod/Admin actions to be taken against the author.
940    */
941   userActionsLine() {
942     // TODO: make nicer
943     let post_view = this.props.post_view;
944     return (
945       this.state.showAdvanced && (
946         <>
947           {this.canMod_ && (
948             <>
949               {!this.creatorIsMod_ &&
950                 (!post_view.creator_banned_from_community ? (
951                   <button
952                     className="btn btn-link btn-animate text-muted py-0"
953                     onClick={linkEvent(
954                       this,
955                       this.handleModBanFromCommunityShow
956                     )}
957                     aria-label={i18n.t("ban")}
958                   >
959                     {i18n.t("ban")}
960                   </button>
961                 ) : (
962                   <button
963                     className="btn btn-link btn-animate text-muted py-0"
964                     onClick={linkEvent(
965                       this,
966                       this.handleModBanFromCommunitySubmit
967                     )}
968                     aria-label={i18n.t("unban")}
969                   >
970                     {i18n.t("unban")}
971                   </button>
972                 ))}
973               {!post_view.creator_banned_from_community && (
974                 <button
975                   className="btn btn-link btn-animate text-muted py-0"
976                   onClick={linkEvent(this, this.handleAddModToCommunity)}
977                   aria-label={
978                     this.creatorIsMod_
979                       ? i18n.t("remove_as_mod")
980                       : i18n.t("appoint_as_mod")
981                   }
982                 >
983                   {this.creatorIsMod_
984                     ? i18n.t("remove_as_mod")
985                     : i18n.t("appoint_as_mod")}
986                 </button>
987               )}
988             </>
989           )}
990           {/* Community creators and admins can transfer community to another mod */}
991           {(amCommunityCreator(this.props.moderators, post_view.creator.id) ||
992             this.canAdmin_) &&
993             this.creatorIsMod_ &&
994             (!this.state.showConfirmTransferCommunity ? (
995               <button
996                 className="btn btn-link btn-animate text-muted py-0"
997                 onClick={linkEvent(
998                   this,
999                   this.handleShowConfirmTransferCommunity
1000                 )}
1001                 aria-label={i18n.t("transfer_community")}
1002               >
1003                 {i18n.t("transfer_community")}
1004               </button>
1005             ) : (
1006               <>
1007                 <button
1008                   className="d-inline-block mr-1 btn btn-link btn-animate text-muted py-0"
1009                   aria-label={i18n.t("are_you_sure")}
1010                 >
1011                   {i18n.t("are_you_sure")}
1012                 </button>
1013                 <button
1014                   className="btn btn-link btn-animate text-muted py-0 d-inline-block mr-1"
1015                   aria-label={i18n.t("yes")}
1016                   onClick={linkEvent(this, this.handleTransferCommunity)}
1017                 >
1018                   {i18n.t("yes")}
1019                 </button>
1020                 <button
1021                   className="btn btn-link btn-animate text-muted py-0 d-inline-block"
1022                   onClick={linkEvent(
1023                     this,
1024                     this.handleCancelShowConfirmTransferCommunity
1025                   )}
1026                   aria-label={i18n.t("no")}
1027                 >
1028                   {i18n.t("no")}
1029                 </button>
1030               </>
1031             ))}
1032           {/* Admins can ban from all, and appoint other admins */}
1033           {this.canAdmin_ && (
1034             <>
1035               {!this.creatorIsAdmin_ && (
1036                 <>
1037                   {!isBanned(post_view.creator) ? (
1038                     <button
1039                       className="btn btn-link btn-animate text-muted py-0"
1040                       onClick={linkEvent(this, this.handleModBanShow)}
1041                       aria-label={i18n.t("ban_from_site")}
1042                     >
1043                       {i18n.t("ban_from_site")}
1044                     </button>
1045                   ) : (
1046                     <button
1047                       className="btn btn-link btn-animate text-muted py-0"
1048                       onClick={linkEvent(this, this.handleModBanSubmit)}
1049                       aria-label={i18n.t("unban_from_site")}
1050                     >
1051                       {i18n.t("unban_from_site")}
1052                     </button>
1053                   )}
1054                   <button
1055                     className="btn btn-link btn-animate text-muted py-0"
1056                     onClick={linkEvent(this, this.handlePurgePersonShow)}
1057                     aria-label={i18n.t("purge_user")}
1058                   >
1059                     {i18n.t("purge_user")}
1060                   </button>
1061                   <button
1062                     className="btn btn-link btn-animate text-muted py-0"
1063                     onClick={linkEvent(this, this.handlePurgePostShow)}
1064                     aria-label={i18n.t("purge_post")}
1065                   >
1066                     {i18n.t("purge_post")}
1067                   </button>
1068                 </>
1069               )}
1070               {!isBanned(post_view.creator) && post_view.creator.local && (
1071                 <button
1072                   className="btn btn-link btn-animate text-muted py-0"
1073                   onClick={linkEvent(this, this.handleAddAdmin)}
1074                   aria-label={
1075                     this.creatorIsAdmin_
1076                       ? i18n.t("remove_as_admin")
1077                       : i18n.t("appoint_as_admin")
1078                   }
1079                 >
1080                   {this.creatorIsAdmin_
1081                     ? i18n.t("remove_as_admin")
1082                     : i18n.t("appoint_as_admin")}
1083                 </button>
1084               )}
1085             </>
1086           )}
1087         </>
1088       )
1089     );
1090   }
1091
1092   removeAndBanDialogs() {
1093     let post = this.props.post_view;
1094     let purgeTypeText: string;
1095     if (this.state.purgeType == PurgeType.Post) {
1096       purgeTypeText = i18n.t("purge_post");
1097     } else if (this.state.purgeType == PurgeType.Person) {
1098       purgeTypeText = `${i18n.t("purge")} ${post.creator.name}`;
1099     }
1100     return (
1101       <>
1102         {this.state.showRemoveDialog && (
1103           <form
1104             className="form-inline"
1105             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
1106           >
1107             <label className="sr-only" htmlFor="post-listing-remove-reason">
1108               {i18n.t("reason")}
1109             </label>
1110             <input
1111               type="text"
1112               id="post-listing-remove-reason"
1113               className="form-control mr-2"
1114               placeholder={i18n.t("reason")}
1115               value={toUndefined(this.state.removeReason)}
1116               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
1117             />
1118             <button
1119               type="submit"
1120               className="btn btn-secondary"
1121               aria-label={i18n.t("remove_post")}
1122             >
1123               {i18n.t("remove_post")}
1124             </button>
1125           </form>
1126         )}
1127         {this.state.showBanDialog && (
1128           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
1129             <div className="form-group row col-12">
1130               <label
1131                 className="col-form-label"
1132                 htmlFor="post-listing-ban-reason"
1133               >
1134                 {i18n.t("reason")}
1135               </label>
1136               <input
1137                 type="text"
1138                 id="post-listing-ban-reason"
1139                 className="form-control mr-2"
1140                 placeholder={i18n.t("reason")}
1141                 value={toUndefined(this.state.banReason)}
1142                 onInput={linkEvent(this, this.handleModBanReasonChange)}
1143               />
1144               <label className="col-form-label" htmlFor={`mod-ban-expires`}>
1145                 {i18n.t("expires")}
1146               </label>
1147               <input
1148                 type="number"
1149                 id={`mod-ban-expires`}
1150                 className="form-control mr-2"
1151                 placeholder={i18n.t("number_of_days")}
1152                 value={toUndefined(this.state.banExpireDays)}
1153                 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
1154               />
1155               <div className="form-group">
1156                 <div className="form-check">
1157                   <input
1158                     className="form-check-input"
1159                     id="mod-ban-remove-data"
1160                     type="checkbox"
1161                     checked={this.state.removeData}
1162                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
1163                   />
1164                   <label
1165                     className="form-check-label"
1166                     htmlFor="mod-ban-remove-data"
1167                     title={i18n.t("remove_content_more")}
1168                   >
1169                     {i18n.t("remove_content")}
1170                   </label>
1171                 </div>
1172               </div>
1173             </div>
1174             {/* TODO hold off on expires until later */}
1175             {/* <div class="form-group row"> */}
1176             {/*   <label class="col-form-label">Expires</label> */}
1177             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1178             {/* </div> */}
1179             <div className="form-group row">
1180               <button
1181                 type="submit"
1182                 className="btn btn-secondary"
1183                 aria-label={i18n.t("ban")}
1184               >
1185                 {i18n.t("ban")} {post.creator.name}
1186               </button>
1187             </div>
1188           </form>
1189         )}
1190         {this.state.showReportDialog && (
1191           <form
1192             className="form-inline"
1193             onSubmit={linkEvent(this, this.handleReportSubmit)}
1194           >
1195             <label className="sr-only" htmlFor="post-report-reason">
1196               {i18n.t("reason")}
1197             </label>
1198             <input
1199               type="text"
1200               id="post-report-reason"
1201               className="form-control mr-2"
1202               placeholder={i18n.t("reason")}
1203               required
1204               value={toUndefined(this.state.reportReason)}
1205               onInput={linkEvent(this, this.handleReportReasonChange)}
1206             />
1207             <button
1208               type="submit"
1209               className="btn btn-secondary"
1210               aria-label={i18n.t("create_report")}
1211             >
1212               {i18n.t("create_report")}
1213             </button>
1214           </form>
1215         )}
1216         {this.state.showPurgeDialog && (
1217           <form
1218             className="form-inline"
1219             onSubmit={linkEvent(this, this.handlePurgeSubmit)}
1220           >
1221             <PurgeWarning />
1222             <label className="sr-only" htmlFor="purge-reason">
1223               {i18n.t("reason")}
1224             </label>
1225             <input
1226               type="text"
1227               id="purge-reason"
1228               className="form-control mr-2"
1229               placeholder={i18n.t("reason")}
1230               value={toUndefined(this.state.purgeReason)}
1231               onInput={linkEvent(this, this.handlePurgeReasonChange)}
1232             />
1233             {this.state.purgeLoading ? (
1234               <Spinner />
1235             ) : (
1236               <button
1237                 type="submit"
1238                 className="btn btn-secondary"
1239                 aria-label={purgeTypeText}
1240               >
1241                 {purgeTypeText}
1242               </button>
1243             )}
1244           </form>
1245         )}
1246       </>
1247     );
1248   }
1249
1250   mobileThumbnail() {
1251     let post = this.props.post_view.post;
1252     return post.thumbnail_url.isSome() ||
1253       post.url.map(isImage).unwrapOr(false) ? (
1254       <div className="row">
1255         <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
1256           {this.postTitleLine()}
1257         </div>
1258         <div className="col-4">
1259           {/* Post body prev or thumbnail */}
1260           {!this.state.imageExpanded && this.thumbnail()}
1261         </div>
1262       </div>
1263     ) : (
1264       this.postTitleLine()
1265     );
1266   }
1267
1268   showMobilePreview() {
1269     let post = this.props.post_view.post;
1270     return (
1271       !this.showBody &&
1272       post.body.match({
1273         some: body => <div className="md-div mb-1 preview-lines">{body}</div>,
1274         none: <></>,
1275       })
1276     );
1277   }
1278
1279   listing() {
1280     return (
1281       <>
1282         {/* The mobile view*/}
1283         <div className="d-block d-sm-none">
1284           <div className="row">
1285             <div className="col-12">
1286               {this.createdLine()}
1287
1288               {/* If it has a thumbnail, do a right aligned thumbnail */}
1289               {this.mobileThumbnail()}
1290
1291               {/* Show a preview of the post body */}
1292               {this.showMobilePreview()}
1293
1294               {this.commentsLine(true)}
1295               {this.userActionsLine()}
1296               {this.duplicatesLine()}
1297               {this.removeAndBanDialogs()}
1298             </div>
1299           </div>
1300         </div>
1301
1302         {/* The larger view*/}
1303         <div className="d-none d-sm-block">
1304           <div className="row">
1305             {!this.props.viewOnly && this.voteBar()}
1306             <div className="col-sm-2 pr-0">
1307               <div className="">{this.thumbnail()}</div>
1308             </div>
1309             <div className="col-12 col-sm-9">
1310               <div className="row">
1311                 <div className="col-12">
1312                   {this.postTitleLine()}
1313                   {this.createdLine()}
1314                   {this.commentsLine()}
1315                   {this.duplicatesLine()}
1316                   {this.userActionsLine()}
1317                   {this.removeAndBanDialogs()}
1318                 </div>
1319               </div>
1320             </div>
1321           </div>
1322         </div>
1323       </>
1324     );
1325   }
1326
1327   private get myPost(): boolean {
1328     return UserService.Instance.myUserInfo.match({
1329       some: mui =>
1330         this.props.post_view.creator.id == mui.local_user_view.person.id,
1331       none: false,
1332     });
1333   }
1334
1335   handlePostLike(event: any) {
1336     event.preventDefault();
1337     if (UserService.Instance.myUserInfo.isNone()) {
1338       this.context.router.history.push(`/login`);
1339     }
1340
1341     let myVote = this.state.my_vote.unwrapOr(0);
1342     let newVote = myVote == 1 ? 0 : 1;
1343
1344     if (myVote == 1) {
1345       this.setState({
1346         score: this.state.score - 1,
1347         upvotes: this.state.upvotes - 1,
1348       });
1349     } else if (myVote == -1) {
1350       this.setState({
1351         score: this.state.score + 2,
1352         upvotes: this.state.upvotes + 1,
1353         downvotes: this.state.downvotes - 1,
1354       });
1355     } else {
1356       this.setState({
1357         score: this.state.score + 1,
1358         upvotes: this.state.upvotes + 1,
1359       });
1360     }
1361
1362     this.setState({ my_vote: Some(newVote) });
1363
1364     let form = new CreatePostLike({
1365       post_id: this.props.post_view.post.id,
1366       score: newVote,
1367       auth: auth().unwrap(),
1368     });
1369
1370     WebSocketService.Instance.send(wsClient.likePost(form));
1371     this.setState(this.state);
1372     setupTippy();
1373   }
1374
1375   handlePostDisLike(event: any) {
1376     event.preventDefault();
1377     if (UserService.Instance.myUserInfo.isNone()) {
1378       this.context.router.history.push(`/login`);
1379     }
1380
1381     let myVote = this.state.my_vote.unwrapOr(0);
1382     let newVote = myVote == -1 ? 0 : -1;
1383
1384     if (myVote == 1) {
1385       this.setState({
1386         score: this.state.score - 2,
1387         upvotes: this.state.upvotes - 1,
1388         downvotes: this.state.downvotes + 1,
1389       });
1390     } else if (myVote == -1) {
1391       this.setState({
1392         score: this.state.score + 1,
1393         downvotes: this.state.downvotes - 1,
1394       });
1395     } else {
1396       this.setState({
1397         score: this.state.score - 1,
1398         downvotes: this.state.downvotes + 1,
1399       });
1400     }
1401
1402     this.setState({ my_vote: Some(newVote) });
1403
1404     let form = new CreatePostLike({
1405       post_id: this.props.post_view.post.id,
1406       score: newVote,
1407       auth: auth().unwrap(),
1408     });
1409
1410     WebSocketService.Instance.send(wsClient.likePost(form));
1411     this.setState(this.state);
1412     setupTippy();
1413   }
1414
1415   handleEditClick(i: PostListing) {
1416     i.setState({ showEdit: true });
1417   }
1418
1419   handleEditCancel() {
1420     this.setState({ showEdit: false });
1421   }
1422
1423   // The actual editing is done in the recieve for post
1424   handleEditPost() {
1425     this.setState({ showEdit: false });
1426   }
1427
1428   handleShowReportDialog(i: PostListing) {
1429     i.setState({ showReportDialog: !i.state.showReportDialog });
1430   }
1431
1432   handleReportReasonChange(i: PostListing, event: any) {
1433     i.setState({ reportReason: Some(event.target.value) });
1434   }
1435
1436   handleReportSubmit(i: PostListing, event: any) {
1437     event.preventDefault();
1438     let form = new CreatePostReport({
1439       post_id: i.props.post_view.post.id,
1440       reason: toUndefined(i.state.reportReason),
1441       auth: auth().unwrap(),
1442     });
1443     WebSocketService.Instance.send(wsClient.createPostReport(form));
1444
1445     i.setState({ showReportDialog: false });
1446   }
1447
1448   handleBlockUserClick(i: PostListing) {
1449     let blockUserForm = new BlockPerson({
1450       person_id: i.props.post_view.creator.id,
1451       block: true,
1452       auth: auth().unwrap(),
1453     });
1454     WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
1455   }
1456
1457   handleDeleteClick(i: PostListing) {
1458     let deleteForm = new DeletePost({
1459       post_id: i.props.post_view.post.id,
1460       deleted: !i.props.post_view.post.deleted,
1461       auth: auth().unwrap(),
1462     });
1463     WebSocketService.Instance.send(wsClient.deletePost(deleteForm));
1464   }
1465
1466   handleSavePostClick(i: PostListing) {
1467     let saved =
1468       i.props.post_view.saved == undefined ? true : !i.props.post_view.saved;
1469     let form = new SavePost({
1470       post_id: i.props.post_view.post.id,
1471       save: saved,
1472       auth: auth().unwrap(),
1473     });
1474
1475     WebSocketService.Instance.send(wsClient.savePost(form));
1476   }
1477
1478   get crossPostParams(): string {
1479     let post = this.props.post_view.post;
1480     let params = `?title=${encodeURIComponent(post.name)}`;
1481
1482     if (post.url.isSome()) {
1483       params += `&url=${encodeURIComponent(post.url.unwrap())}`;
1484     }
1485     if (post.body.isSome()) {
1486       params += `&body=${encodeURIComponent(this.crossPostBody())}`;
1487     }
1488     return params;
1489   }
1490
1491   crossPostBody(): string {
1492     let post = this.props.post_view.post;
1493     let body = `${i18n.t("cross_posted_from")} ${post.ap_id}\n\n${post.body
1494       .unwrap()
1495       .replace(/^/gm, "> ")}`;
1496     return body;
1497   }
1498
1499   get showBody(): boolean {
1500     return this.props.showBody || this.state.showBody;
1501   }
1502
1503   handleModRemoveShow(i: PostListing) {
1504     i.setState({
1505       showRemoveDialog: !i.state.showRemoveDialog,
1506       showBanDialog: false,
1507     });
1508   }
1509
1510   handleModRemoveReasonChange(i: PostListing, event: any) {
1511     i.setState({ removeReason: Some(event.target.value) });
1512   }
1513
1514   handleModRemoveDataChange(i: PostListing, event: any) {
1515     i.setState({ removeData: event.target.checked });
1516   }
1517
1518   handleModRemoveSubmit(i: PostListing, event: any) {
1519     event.preventDefault();
1520     let form = new RemovePost({
1521       post_id: i.props.post_view.post.id,
1522       removed: !i.props.post_view.post.removed,
1523       reason: i.state.removeReason,
1524       auth: auth().unwrap(),
1525     });
1526     WebSocketService.Instance.send(wsClient.removePost(form));
1527
1528     i.setState({ showRemoveDialog: false });
1529   }
1530
1531   handleModLock(i: PostListing) {
1532     let form = new LockPost({
1533       post_id: i.props.post_view.post.id,
1534       locked: !i.props.post_view.post.locked,
1535       auth: auth().unwrap(),
1536     });
1537     WebSocketService.Instance.send(wsClient.lockPost(form));
1538   }
1539
1540   handleModFeaturePost(i: PostListing, is_community: boolean) {
1541     let form = new FeaturePost({
1542       post_id: i.props.post_view.post.id,
1543       featured: is_community
1544         ? !i.props.post_view.post.featured_community
1545         : !i.props.post_view.post.featured_local,
1546       feature_type: is_community
1547         ? PostFeatureType.Community
1548         : PostFeatureType.Local,
1549       auth: auth().unwrap(),
1550     });
1551     WebSocketService.Instance.send(wsClient.featurePost(form));
1552   }
1553
1554   handleModBanFromCommunityShow(i: PostListing) {
1555     i.setState({
1556       showBanDialog: true,
1557       banType: BanType.Community,
1558       showRemoveDialog: false,
1559     });
1560   }
1561
1562   handleModBanShow(i: PostListing) {
1563     i.setState({
1564       showBanDialog: true,
1565       banType: BanType.Site,
1566       showRemoveDialog: false,
1567     });
1568   }
1569
1570   handlePurgePersonShow(i: PostListing) {
1571     i.setState({
1572       showPurgeDialog: true,
1573       purgeType: PurgeType.Person,
1574       showRemoveDialog: false,
1575     });
1576   }
1577
1578   handlePurgePostShow(i: PostListing) {
1579     i.setState({
1580       showPurgeDialog: true,
1581       purgeType: PurgeType.Post,
1582       showRemoveDialog: false,
1583     });
1584   }
1585
1586   handlePurgeReasonChange(i: PostListing, event: any) {
1587     i.setState({ purgeReason: Some(event.target.value) });
1588   }
1589
1590   handlePurgeSubmit(i: PostListing, event: any) {
1591     event.preventDefault();
1592
1593     if (i.state.purgeType == PurgeType.Person) {
1594       let form = new PurgePerson({
1595         person_id: i.props.post_view.creator.id,
1596         reason: i.state.purgeReason,
1597         auth: auth().unwrap(),
1598       });
1599       WebSocketService.Instance.send(wsClient.purgePerson(form));
1600     } else if (i.state.purgeType == PurgeType.Post) {
1601       let form = new PurgePost({
1602         post_id: i.props.post_view.post.id,
1603         reason: i.state.purgeReason,
1604         auth: auth().unwrap(),
1605       });
1606       WebSocketService.Instance.send(wsClient.purgePost(form));
1607     }
1608
1609     i.setState({ purgeLoading: true });
1610   }
1611
1612   handleModBanReasonChange(i: PostListing, event: any) {
1613     i.setState({ banReason: Some(event.target.value) });
1614   }
1615
1616   handleModBanExpireDaysChange(i: PostListing, event: any) {
1617     i.setState({ banExpireDays: Some(event.target.value) });
1618   }
1619
1620   handleModBanFromCommunitySubmit(i: PostListing) {
1621     i.setState({ banType: BanType.Community });
1622     i.handleModBanBothSubmit(i);
1623   }
1624
1625   handleModBanSubmit(i: PostListing) {
1626     i.setState({ banType: BanType.Site });
1627     i.handleModBanBothSubmit(i);
1628   }
1629
1630   handleModBanBothSubmit(i: PostListing, event?: any) {
1631     if (event) event.preventDefault();
1632
1633     if (i.state.banType == BanType.Community) {
1634       // If its an unban, restore all their data
1635       let ban = !i.props.post_view.creator_banned_from_community;
1636       if (ban == false) {
1637         i.setState({ removeData: false });
1638       }
1639       let form = new BanFromCommunity({
1640         person_id: i.props.post_view.creator.id,
1641         community_id: i.props.post_view.community.id,
1642         ban,
1643         remove_data: Some(i.state.removeData),
1644         reason: i.state.banReason,
1645         expires: i.state.banExpireDays.map(futureDaysToUnixTime),
1646         auth: auth().unwrap(),
1647       });
1648       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1649     } else {
1650       // If its an unban, restore all their data
1651       let ban = !i.props.post_view.creator.banned;
1652       if (ban == false) {
1653         i.setState({ removeData: false });
1654       }
1655       let form = new BanPerson({
1656         person_id: i.props.post_view.creator.id,
1657         ban,
1658         remove_data: Some(i.state.removeData),
1659         reason: i.state.banReason,
1660         expires: i.state.banExpireDays.map(futureDaysToUnixTime),
1661         auth: auth().unwrap(),
1662       });
1663       WebSocketService.Instance.send(wsClient.banPerson(form));
1664     }
1665
1666     i.setState({ showBanDialog: false });
1667   }
1668
1669   handleAddModToCommunity(i: PostListing) {
1670     let form = new AddModToCommunity({
1671       person_id: i.props.post_view.creator.id,
1672       community_id: i.props.post_view.community.id,
1673       added: !i.creatorIsMod_,
1674       auth: auth().unwrap(),
1675     });
1676     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1677     i.setState(i.state);
1678   }
1679
1680   handleAddAdmin(i: PostListing) {
1681     let form = new AddAdmin({
1682       person_id: i.props.post_view.creator.id,
1683       added: !i.creatorIsAdmin_,
1684       auth: auth().unwrap(),
1685     });
1686     WebSocketService.Instance.send(wsClient.addAdmin(form));
1687     i.setState(i.state);
1688   }
1689
1690   handleShowConfirmTransferCommunity(i: PostListing) {
1691     i.setState({ showConfirmTransferCommunity: true });
1692   }
1693
1694   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1695     i.setState({ showConfirmTransferCommunity: false });
1696   }
1697
1698   handleTransferCommunity(i: PostListing) {
1699     let form = new TransferCommunity({
1700       community_id: i.props.post_view.community.id,
1701       person_id: i.props.post_view.creator.id,
1702       auth: auth().unwrap(),
1703     });
1704     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1705     i.setState({ showConfirmTransferCommunity: false });
1706   }
1707
1708   handleShowConfirmTransferSite(i: PostListing) {
1709     i.setState({ showConfirmTransferSite: true });
1710   }
1711
1712   handleCancelShowConfirmTransferSite(i: PostListing) {
1713     i.setState({ showConfirmTransferSite: false });
1714   }
1715
1716   handleImageExpandClick(i: PostListing, event: any) {
1717     event.preventDefault();
1718     i.setState({ imageExpanded: !i.state.imageExpanded });
1719     setupTippy();
1720   }
1721
1722   handleViewSource(i: PostListing) {
1723     i.setState({ viewSource: !i.state.viewSource });
1724   }
1725
1726   handleShowAdvanced(i: PostListing) {
1727     i.setState({ showAdvanced: !i.state.showAdvanced });
1728     setupTippy();
1729   }
1730
1731   handleShowMoreMobile(i: PostListing) {
1732     i.setState({
1733       showMoreMobile: !i.state.showMoreMobile,
1734       showAdvanced: !i.state.showAdvanced,
1735     });
1736     setupTippy();
1737   }
1738
1739   handleShowBody(i: PostListing) {
1740     i.setState({ showBody: !i.state.showBody });
1741     setupTippy();
1742   }
1743
1744   get pointsTippy(): string {
1745     let points = i18n.t("number_of_points", {
1746       count: this.state.score,
1747       formattedCount: this.state.score,
1748     });
1749
1750     let upvotes = i18n.t("number_of_upvotes", {
1751       count: this.state.upvotes,
1752       formattedCount: this.state.upvotes,
1753     });
1754
1755     let downvotes = i18n.t("number_of_downvotes", {
1756       count: this.state.downvotes,
1757       formattedCount: this.state.downvotes,
1758     });
1759
1760     return `${points} • ${upvotes} • ${downvotes}`;
1761   }
1762
1763   get canModOnSelf_(): boolean {
1764     return canMod(
1765       this.props.moderators,
1766       this.props.admins,
1767       this.props.post_view.creator.id,
1768       undefined,
1769       true
1770     );
1771   }
1772
1773   get canMod_(): boolean {
1774     return canMod(
1775       this.props.moderators,
1776       this.props.admins,
1777       this.props.post_view.creator.id
1778     );
1779   }
1780
1781   get canAdmin_(): boolean {
1782     return canAdmin(this.props.admins, this.props.post_view.creator.id);
1783   }
1784
1785   get creatorIsMod_(): boolean {
1786     return isMod(this.props.moderators, this.props.post_view.creator.id);
1787   }
1788
1789   get creatorIsAdmin_(): boolean {
1790     return isAdmin(this.props.admins, this.props.post_view.creator.id);
1791   }
1792 }