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