]> Untitled Git - lemmy-ui.git/blob - src/shared/components/community/community.tsx
8a70378b795abebad70838b41d76e58952515a8a
[lemmy-ui.git] / src / shared / components / community / community.tsx
1 import { Component, linkEvent } from "inferno";
2 import { RouteComponentProps } from "inferno-router/dist/Route";
3 import {
4   AddModToCommunityResponse,
5   BanFromCommunityResponse,
6   BlockCommunityResponse,
7   BlockPersonResponse,
8   CommentResponse,
9   CommentView,
10   CommunityResponse,
11   GetComments,
12   GetCommentsResponse,
13   GetCommunity,
14   GetCommunityResponse,
15   GetPosts,
16   GetPostsResponse,
17   PostReportResponse,
18   PostResponse,
19   PostView,
20   PurgeItemResponse,
21   SortType,
22   UserOperation,
23   wsJsonToRes,
24   wsUserOp,
25 } from "lemmy-js-client";
26 import { Subscription } from "rxjs";
27 import { i18n } from "../../i18next";
28 import {
29   CommentViewType,
30   DataType,
31   InitialFetchRequest,
32 } from "../../interfaces";
33 import { UserService, WebSocketService } from "../../services";
34 import {
35   QueryParams,
36   commentsToFlatNodes,
37   communityRSSUrl,
38   createCommentLikeRes,
39   createPostLikeFindRes,
40   editCommentRes,
41   editPostFindRes,
42   enableDownvotes,
43   enableNsfw,
44   fetchLimit,
45   getDataTypeString,
46   getPageFromString,
47   getQueryParams,
48   getQueryString,
49   isPostBlocked,
50   myAuth,
51   notifyPost,
52   nsfwCheck,
53   postToCommentSortType,
54   relTags,
55   restoreScrollPosition,
56   saveCommentRes,
57   saveScrollPosition,
58   setIsoData,
59   setupTippy,
60   showLocal,
61   toast,
62   updateCommunityBlock,
63   updatePersonBlock,
64   wsClient,
65   wsSubscribe,
66 } from "../../utils";
67 import { CommentNodes } from "../comment/comment-nodes";
68 import { BannerIconHeader } from "../common/banner-icon-header";
69 import { DataTypeSelect } from "../common/data-type-select";
70 import { HtmlTags } from "../common/html-tags";
71 import { Icon, Spinner } from "../common/icon";
72 import { Paginator } from "../common/paginator";
73 import { SortSelect } from "../common/sort-select";
74 import { Sidebar } from "../community/sidebar";
75 import { SiteSidebar } from "../home/site-sidebar";
76 import { PostListings } from "../post/post-listings";
77 import { CommunityLink } from "./community-link";
78
79 interface State {
80   communityRes?: GetCommunityResponse;
81   communityLoading: boolean;
82   listingsLoading: boolean;
83   posts: PostView[];
84   comments: CommentView[];
85   showSidebarMobile: boolean;
86 }
87
88 interface CommunityProps {
89   dataType: DataType;
90   sort: SortType;
91   page: bigint;
92 }
93
94 function getCommunityQueryParams() {
95   return getQueryParams<CommunityProps>({
96     dataType: getDataTypeFromQuery,
97     page: getPageFromString,
98     sort: getSortTypeFromQuery,
99   });
100 }
101
102 function getDataTypeFromQuery(type?: string): DataType {
103   return type ? DataType[type] : DataType.Post;
104 }
105
106 function getSortTypeFromQuery(type?: string): SortType {
107   const mySortType =
108     UserService.Instance.myUserInfo?.local_user_view.local_user
109       .default_sort_type;
110
111   return type ? (type as SortType) : mySortType ?? "Active";
112 }
113
114 export class Community extends Component<
115   RouteComponentProps<{ name: string }>,
116   State
117 > {
118   private isoData = setIsoData(this.context);
119   private subscription?: Subscription;
120   state: State = {
121     communityLoading: true,
122     listingsLoading: true,
123     posts: [],
124     comments: [],
125     showSidebarMobile: false,
126   };
127
128   constructor(props: RouteComponentProps<{ name: string }>, context: any) {
129     super(props, context);
130
131     this.handleSortChange = this.handleSortChange.bind(this);
132     this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
133     this.handlePageChange = this.handlePageChange.bind(this);
134
135     this.parseMessage = this.parseMessage.bind(this);
136     this.subscription = wsSubscribe(this.parseMessage);
137
138     // Only fetch the data if coming from another route
139     if (this.isoData.path == this.context.router.route.match.url) {
140       this.state = {
141         ...this.state,
142         communityRes: this.isoData.routeData[0] as GetCommunityResponse,
143       };
144       const postsRes = this.isoData.routeData[1] as
145         | GetPostsResponse
146         | undefined;
147       const commentsRes = this.isoData.routeData[2] as
148         | GetCommentsResponse
149         | undefined;
150
151       if (postsRes) {
152         this.state = { ...this.state, posts: postsRes.posts };
153       }
154
155       if (commentsRes) {
156         this.state = { ...this.state, comments: commentsRes.comments };
157       }
158
159       this.state = {
160         ...this.state,
161         communityLoading: false,
162         listingsLoading: false,
163       };
164     } else {
165       this.fetchCommunity();
166       this.fetchData();
167     }
168   }
169
170   fetchCommunity() {
171     const form: GetCommunity = {
172       name: this.props.match.params.name,
173       auth: myAuth(false),
174     };
175     WebSocketService.Instance.send(wsClient.getCommunity(form));
176   }
177
178   componentDidMount() {
179     setupTippy();
180   }
181
182   componentWillUnmount() {
183     saveScrollPosition(this.context);
184     this.subscription?.unsubscribe();
185   }
186
187   static fetchInitialData({
188     client,
189     path,
190     query: { dataType: urlDataType, page: urlPage, sort: urlSort },
191     auth,
192   }: InitialFetchRequest<QueryParams<CommunityProps>>): Promise<any>[] {
193     const pathSplit = path.split("/");
194     const promises: Promise<any>[] = [];
195
196     const communityName = pathSplit[2];
197     const communityForm: GetCommunity = {
198       name: communityName,
199       auth,
200     };
201     promises.push(client.getCommunity(communityForm));
202
203     const dataType = getDataTypeFromQuery(urlDataType);
204
205     const sort = getSortTypeFromQuery(urlSort);
206
207     const page = getPageFromString(urlPage);
208
209     if (dataType === DataType.Post) {
210       const getPostsForm: GetPosts = {
211         community_name: communityName,
212         page,
213         limit: fetchLimit,
214         sort,
215         type_: "All",
216         saved_only: false,
217         auth,
218       };
219       promises.push(client.getPosts(getPostsForm));
220       promises.push(Promise.resolve());
221     } else {
222       const getCommentsForm: GetComments = {
223         community_name: communityName,
224         page,
225         limit: fetchLimit,
226         sort: postToCommentSortType(sort),
227         type_: "All",
228         saved_only: false,
229         auth,
230       };
231       promises.push(Promise.resolve());
232       promises.push(client.getComments(getCommentsForm));
233     }
234
235     return promises;
236   }
237
238   get documentTitle(): string {
239     const cRes = this.state.communityRes;
240     return cRes
241       ? `${cRes.community_view.community.title} - ${this.isoData.site_res.site_view.site.name}`
242       : "";
243   }
244
245   render() {
246     const res = this.state.communityRes;
247     const { page } = getCommunityQueryParams();
248
249     return (
250       <div className="container-lg">
251         {this.state.communityLoading ? (
252           <h5>
253             <Spinner large />
254           </h5>
255         ) : (
256           res && (
257             <>
258               <HtmlTags
259                 title={this.documentTitle}
260                 path={this.context.router.route.match.url}
261                 description={res.community_view.community.description}
262                 image={res.community_view.community.icon}
263               />
264
265               <div className="row">
266                 <div className="col-12 col-md-8">
267                   {this.communityInfo}
268                   <div className="d-block d-md-none">
269                     <button
270                       className="btn btn-secondary d-inline-block mb-2 mr-3"
271                       onClick={linkEvent(this, this.handleShowSidebarMobile)}
272                     >
273                       {i18n.t("sidebar")}{" "}
274                       <Icon
275                         icon={
276                           this.state.showSidebarMobile
277                             ? `minus-square`
278                             : `plus-square`
279                         }
280                         classes="icon-inline"
281                       />
282                     </button>
283                     {this.state.showSidebarMobile && this.sidebar(res)}
284                   </div>
285                   {this.selects}
286                   {this.listings}
287                   <Paginator page={page} onChange={this.handlePageChange} />
288                 </div>
289                 <div className="d-none d-md-block col-md-4">
290                   {this.sidebar(res)}
291                 </div>
292               </div>
293             </>
294           )
295         )}
296       </div>
297     );
298   }
299
300   sidebar({
301     community_view,
302     moderators,
303     online,
304     discussion_languages,
305     site,
306   }: GetCommunityResponse) {
307     const { site_res } = this.isoData;
308     // For some reason, this returns an empty vec if it matches the site langs
309     const communityLangs =
310       discussion_languages.length === 0
311         ? site_res.all_languages.map(({ id }) => id)
312         : discussion_languages;
313
314     return (
315       <>
316         <Sidebar
317           community_view={community_view}
318           moderators={moderators}
319           admins={site_res.admins}
320           online={online}
321           enableNsfw={enableNsfw(site_res)}
322           editable
323           allLanguages={site_res.all_languages}
324           siteLanguages={site_res.discussion_languages}
325           communityLanguages={communityLangs}
326         />
327         {!community_view.community.local && site && (
328           <SiteSidebar site={site} showLocal={showLocal(this.isoData)} />
329         )}
330       </>
331     );
332   }
333
334   get listings() {
335     const { dataType } = getCommunityQueryParams();
336     const { site_res } = this.isoData;
337     const { listingsLoading, posts, comments, communityRes } = this.state;
338
339     if (listingsLoading) {
340       return (
341         <h5>
342           <Spinner large />
343         </h5>
344       );
345     } else if (dataType === DataType.Post) {
346       return (
347         <PostListings
348           posts={posts}
349           removeDuplicates
350           enableDownvotes={enableDownvotes(site_res)}
351           enableNsfw={enableNsfw(site_res)}
352           allLanguages={site_res.all_languages}
353           siteLanguages={site_res.discussion_languages}
354         />
355       );
356     } else {
357       return (
358         <CommentNodes
359           nodes={commentsToFlatNodes(comments)}
360           viewType={CommentViewType.Flat}
361           noIndent
362           showContext
363           enableDownvotes={enableDownvotes(site_res)}
364           moderators={communityRes?.moderators}
365           admins={site_res.admins}
366           allLanguages={site_res.all_languages}
367           siteLanguages={site_res.discussion_languages}
368         />
369       );
370     }
371   }
372
373   get communityInfo() {
374     const community = this.state.communityRes?.community_view.community;
375
376     return (
377       community && (
378         <div className="mb-2">
379           <BannerIconHeader banner={community.banner} icon={community.icon} />
380           <h5 className="mb-0 overflow-wrap-anywhere">{community.title}</h5>
381           <CommunityLink
382             community={community}
383             realLink
384             useApubName
385             muted
386             hideAvatar
387           />
388         </div>
389       )
390     );
391   }
392
393   get selects() {
394     // let communityRss = this.state.communityRes.map(r =>
395     //   communityRSSUrl(r.community_view.community.actor_id, this.state.sort)
396     // );
397     const { dataType, sort } = getCommunityQueryParams();
398     const res = this.state.communityRes;
399     const communityRss = res
400       ? communityRSSUrl(res.community_view.community.actor_id, sort)
401       : undefined;
402
403     return (
404       <div className="mb-3">
405         <span className="mr-3">
406           <DataTypeSelect
407             type_={dataType}
408             onChange={this.handleDataTypeChange}
409           />
410         </span>
411         <span className="mr-2">
412           <SortSelect sort={sort} onChange={this.handleSortChange} />
413         </span>
414         {communityRss && (
415           <>
416             <a href={communityRss} title="RSS" rel={relTags}>
417               <Icon icon="rss" classes="text-muted small" />
418             </a>
419             <link
420               rel="alternate"
421               type="application/atom+xml"
422               href={communityRss}
423             />
424           </>
425         )}
426       </div>
427     );
428   }
429
430   handlePageChange(page: bigint) {
431     this.updateUrl({ page });
432     window.scrollTo(0, 0);
433   }
434
435   handleSortChange(sort: SortType) {
436     this.updateUrl({ sort, page: 1n });
437     window.scrollTo(0, 0);
438   }
439
440   handleDataTypeChange(dataType: DataType) {
441     this.updateUrl({ dataType, page: 1n });
442     window.scrollTo(0, 0);
443   }
444
445   handleShowSidebarMobile(i: Community) {
446     i.setState(({ showSidebarMobile }) => ({
447       showSidebarMobile: !showSidebarMobile,
448     }));
449   }
450
451   updateUrl({ dataType, page, sort }: Partial<CommunityProps>) {
452     const {
453       dataType: urlDataType,
454       page: urlPage,
455       sort: urlSort,
456     } = getCommunityQueryParams();
457
458     const queryParams: QueryParams<CommunityProps> = {
459       dataType: getDataTypeString(dataType ?? urlDataType),
460       page: (page ?? urlPage).toString(),
461       sort: sort ?? urlSort,
462     };
463
464     this.props.history.push(
465       `/c/${this.props.match.params.name}${getQueryString(queryParams)}`
466     );
467
468     this.setState({
469       comments: [],
470       posts: [],
471       listingsLoading: true,
472     });
473
474     this.fetchData();
475   }
476
477   fetchData() {
478     const { dataType, page, sort } = getCommunityQueryParams();
479     const { name } = this.props.match.params;
480
481     let req: string;
482     if (dataType === DataType.Post) {
483       const form: GetPosts = {
484         page,
485         limit: fetchLimit,
486         sort,
487         type_: "All",
488         community_name: name,
489         saved_only: false,
490         auth: myAuth(false),
491       };
492       req = wsClient.getPosts(form);
493     } else {
494       const form: GetComments = {
495         page,
496         limit: fetchLimit,
497         sort: postToCommentSortType(sort),
498         type_: "All",
499         community_name: name,
500         saved_only: false,
501         auth: myAuth(false),
502       };
503
504       req = wsClient.getComments(form);
505     }
506
507     WebSocketService.Instance.send(req);
508   }
509
510   parseMessage(msg: any) {
511     const { page } = getCommunityQueryParams();
512     const op = wsUserOp(msg);
513     console.log(msg);
514     const res = this.state.communityRes;
515
516     if (msg.error) {
517       toast(i18n.t(msg.error), "danger");
518       this.context.router.history.push("/");
519     } else if (msg.reconnect) {
520       if (res) {
521         WebSocketService.Instance.send(
522           wsClient.communityJoin({
523             community_id: res.community_view.community.id,
524           })
525         );
526       }
527
528       this.fetchData();
529     } else {
530       switch (op) {
531         case UserOperation.GetCommunity: {
532           const data = wsJsonToRes<GetCommunityResponse>(msg);
533
534           this.setState({ communityRes: data, communityLoading: false });
535           // TODO why is there no auth in this form?
536           WebSocketService.Instance.send(
537             wsClient.communityJoin({
538               community_id: data.community_view.community.id,
539             })
540           );
541
542           break;
543         }
544
545         case UserOperation.EditCommunity:
546         case UserOperation.DeleteCommunity:
547         case UserOperation.RemoveCommunity: {
548           const { community_view, discussion_languages } =
549             wsJsonToRes<CommunityResponse>(msg);
550
551           if (res) {
552             res.community_view = community_view;
553             res.discussion_languages = discussion_languages;
554             this.setState(this.state);
555           }
556
557           break;
558         }
559
560         case UserOperation.FollowCommunity: {
561           const {
562             community_view: {
563               subscribed,
564               counts: { subscribers },
565             },
566           } = wsJsonToRes<CommunityResponse>(msg);
567
568           if (res) {
569             res.community_view.subscribed = subscribed;
570             res.community_view.counts.subscribers = subscribers;
571             this.setState(this.state);
572           }
573
574           break;
575         }
576
577         case UserOperation.GetPosts: {
578           const { posts } = wsJsonToRes<GetPostsResponse>(msg);
579
580           this.setState({ posts, listingsLoading: false });
581           restoreScrollPosition(this.context);
582           setupTippy();
583
584           break;
585         }
586
587         case UserOperation.EditPost:
588         case UserOperation.DeletePost:
589         case UserOperation.RemovePost:
590         case UserOperation.LockPost:
591         case UserOperation.FeaturePost:
592         case UserOperation.SavePost: {
593           const { post_view } = wsJsonToRes<PostResponse>(msg);
594
595           editPostFindRes(post_view, this.state.posts);
596           this.setState(this.state);
597
598           break;
599         }
600
601         case UserOperation.CreatePost: {
602           const { post_view } = wsJsonToRes<PostResponse>(msg);
603
604           const showPostNotifs =
605             UserService.Instance.myUserInfo?.local_user_view.local_user
606               .show_new_post_notifs;
607
608           // Only push these if you're on the first page, you pass the nsfw check, and it isn't blocked
609           if (
610             page === 1n &&
611             nsfwCheck(post_view) &&
612             !isPostBlocked(post_view)
613           ) {
614             this.state.posts.unshift(post_view);
615             if (showPostNotifs) {
616               notifyPost(post_view, this.context.router);
617             }
618             this.setState(this.state);
619           }
620
621           break;
622         }
623
624         case UserOperation.CreatePostLike: {
625           const { post_view } = wsJsonToRes<PostResponse>(msg);
626
627           createPostLikeFindRes(post_view, this.state.posts);
628           this.setState(this.state);
629
630           break;
631         }
632
633         case UserOperation.AddModToCommunity: {
634           const { moderators } = wsJsonToRes<AddModToCommunityResponse>(msg);
635
636           if (res) {
637             res.moderators = moderators;
638             this.setState(this.state);
639           }
640
641           break;
642         }
643
644         case UserOperation.BanFromCommunity: {
645           const {
646             person_view: {
647               person: { id: personId },
648             },
649             banned,
650           } = wsJsonToRes<BanFromCommunityResponse>(msg);
651
652           // TODO this might be incorrect
653           this.state.posts
654             .filter(p => p.creator.id === personId)
655             .forEach(p => (p.creator_banned_from_community = banned));
656
657           this.setState(this.state);
658
659           break;
660         }
661
662         case UserOperation.GetComments: {
663           const { comments } = wsJsonToRes<GetCommentsResponse>(msg);
664           this.setState({ comments, listingsLoading: false });
665
666           break;
667         }
668
669         case UserOperation.EditComment:
670         case UserOperation.DeleteComment:
671         case UserOperation.RemoveComment: {
672           const { comment_view } = wsJsonToRes<CommentResponse>(msg);
673           editCommentRes(comment_view, this.state.comments);
674           this.setState(this.state);
675
676           break;
677         }
678
679         case UserOperation.CreateComment: {
680           const { form_id, comment_view } = wsJsonToRes<CommentResponse>(msg);
681
682           // Necessary since it might be a user reply
683           if (form_id) {
684             this.setState(({ comments }) => ({
685               comments: [comment_view].concat(comments),
686             }));
687           }
688
689           break;
690         }
691
692         case UserOperation.SaveComment: {
693           const { comment_view } = wsJsonToRes<CommentResponse>(msg);
694
695           saveCommentRes(comment_view, this.state.comments);
696           this.setState(this.state);
697
698           break;
699         }
700
701         case UserOperation.CreateCommentLike: {
702           const { comment_view } = wsJsonToRes<CommentResponse>(msg);
703
704           createCommentLikeRes(comment_view, this.state.comments);
705           this.setState(this.state);
706
707           break;
708         }
709
710         case UserOperation.BlockPerson: {
711           const data = wsJsonToRes<BlockPersonResponse>(msg);
712           updatePersonBlock(data);
713
714           break;
715         }
716
717         case UserOperation.CreatePostReport:
718         case UserOperation.CreateCommentReport: {
719           const data = wsJsonToRes<PostReportResponse>(msg);
720
721           if (data) {
722             toast(i18n.t("report_created"));
723           }
724
725           break;
726         }
727
728         case UserOperation.PurgeCommunity: {
729           const { success } = wsJsonToRes<PurgeItemResponse>(msg);
730
731           if (success) {
732             toast(i18n.t("purge_success"));
733             this.context.router.history.push(`/`);
734           }
735
736           break;
737         }
738
739         case UserOperation.BlockCommunity: {
740           const data = wsJsonToRes<BlockCommunityResponse>(msg);
741           if (res) {
742             res.community_view.blocked = data.blocked;
743             this.setState(this.state);
744           }
745           updateCommunityBlock(data);
746
747           break;
748         }
749       }
750     }
751   }
752 }