]> Untitled Git - lemmy-ui.git/blob - src/shared/components/search.tsx
Re-organized components folder. (#339)
[lemmy-ui.git] / src / shared / components / search.tsx
1 import { Component, linkEvent } from "inferno";
2 import {
3   CommentResponse,
4   CommentView,
5   CommunityView,
6   GetCommunity,
7   GetPersonDetails,
8   ListCommunities,
9   ListCommunitiesResponse,
10   ListingType,
11   PersonViewSafe,
12   PostResponse,
13   PostView,
14   Search as SearchForm,
15   SearchResponse,
16   SearchType,
17   Site,
18   SortType,
19   UserOperation,
20 } from "lemmy-js-client";
21 import { Subscription } from "rxjs";
22 import { InitialFetchRequest } from "shared/interfaces";
23 import { i18n } from "../i18next";
24 import { WebSocketService } from "../services";
25 import {
26   authField,
27   capitalizeFirstLetter,
28   choicesConfig,
29   commentsToFlatNodes,
30   communitySelectName,
31   communityToChoice,
32   createCommentLikeRes,
33   createPostLikeFindRes,
34   debounce,
35   fetchCommunities,
36   fetchLimit,
37   fetchUsers,
38   isBrowser,
39   personSelectName,
40   personToChoice,
41   restoreScrollPosition,
42   routeListingTypeToEnum,
43   routeSearchTypeToEnum,
44   routeSortTypeToEnum,
45   saveScrollPosition,
46   setIsoData,
47   setOptionalAuth,
48   showLocal,
49   toast,
50   wsClient,
51   wsJsonToRes,
52   wsSubscribe,
53   wsUserOp,
54 } from "../utils";
55 import { CommentNodes } from "./comment/comment-nodes";
56 import { HtmlTags } from "./common/html-tags";
57 import { Spinner } from "./common/icon";
58 import { ListingTypeSelect } from "./common/listing-type-select";
59 import { Paginator } from "./common/paginator";
60 import { SortSelect } from "./common/sort-select";
61 import { CommunityLink } from "./community/community-link";
62 import { PersonListing } from "./person/person-listing";
63 import { PostListing } from "./post/post-listing";
64
65 var Choices: any;
66 if (isBrowser()) {
67   Choices = require("choices.js");
68 }
69
70 interface SearchProps {
71   q: string;
72   type_: SearchType;
73   sort: SortType;
74   listingType: ListingType;
75   communityId: number;
76   creatorId: number;
77   page: number;
78 }
79
80 interface SearchState {
81   q: string;
82   type_: SearchType;
83   sort: SortType;
84   listingType: ListingType;
85   communityId: number;
86   creatorId: number;
87   page: number;
88   searchResponse: SearchResponse;
89   communities: CommunityView[];
90   creator?: PersonViewSafe;
91   loading: boolean;
92   site: Site;
93   searchText: string;
94 }
95
96 interface UrlParams {
97   q?: string;
98   type_?: SearchType;
99   sort?: SortType;
100   listingType?: ListingType;
101   communityId?: number;
102   creatorId?: number;
103   page?: number;
104 }
105
106 export class Search extends Component<any, SearchState> {
107   private isoData = setIsoData(this.context);
108   private communityChoices: any;
109   private creatorChoices: any;
110   private subscription: Subscription;
111   private emptyState: SearchState = {
112     q: Search.getSearchQueryFromProps(this.props.match.params.q),
113     type_: Search.getSearchTypeFromProps(this.props.match.params.type),
114     sort: Search.getSortTypeFromProps(this.props.match.params.sort),
115     listingType: Search.getListingTypeFromProps(
116       this.props.match.params.listing_type
117     ),
118     page: Search.getPageFromProps(this.props.match.params.page),
119     searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
120     communityId: Search.getCommunityIdFromProps(
121       this.props.match.params.community_id
122     ),
123     creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id),
124     searchResponse: {
125       type_: null,
126       posts: [],
127       comments: [],
128       communities: [],
129       users: [],
130     },
131     loading: true,
132     site: this.isoData.site_res.site_view.site,
133     communities: [],
134   };
135
136   static getSearchQueryFromProps(q: string): string {
137     return decodeURIComponent(q) || "";
138   }
139
140   static getSearchTypeFromProps(type_: string): SearchType {
141     return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
142   }
143
144   static getSortTypeFromProps(sort: string): SortType {
145     return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
146   }
147
148   static getListingTypeFromProps(listingType: string): ListingType {
149     return listingType ? routeListingTypeToEnum(listingType) : ListingType.All;
150   }
151
152   static getCommunityIdFromProps(id: string): number {
153     return id ? Number(id) : 0;
154   }
155
156   static getCreatorIdFromProps(id: string): number {
157     return id ? Number(id) : 0;
158   }
159
160   static getPageFromProps(page: string): number {
161     return page ? Number(page) : 1;
162   }
163
164   constructor(props: any, context: any) {
165     super(props, context);
166
167     this.state = this.emptyState;
168     this.handleSortChange = this.handleSortChange.bind(this);
169     this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
170     this.handlePageChange = this.handlePageChange.bind(this);
171
172     this.parseMessage = this.parseMessage.bind(this);
173     this.subscription = wsSubscribe(this.parseMessage);
174
175     // Only fetch the data if coming from another route
176     if (this.isoData.path == this.context.router.route.match.url) {
177       let singleOrMultipleCommunities = this.isoData.routeData[0];
178       if (singleOrMultipleCommunities.communities) {
179         this.state.communities = this.isoData.routeData[0].communities;
180       } else {
181         this.state.communities = [this.isoData.routeData[0].community_view];
182       }
183
184       let creator = this.isoData.routeData[1];
185       if (creator?.person_view) {
186         this.state.creator = this.isoData.routeData[1].person_view;
187       }
188       if (this.state.q != "") {
189         this.state.searchResponse = this.isoData.routeData[2];
190         this.state.loading = false;
191       } else {
192         this.search();
193       }
194     } else {
195       this.fetchCommunities();
196       this.search();
197     }
198   }
199
200   componentWillUnmount() {
201     this.subscription.unsubscribe();
202     saveScrollPosition(this.context);
203   }
204
205   componentDidMount() {
206     this.setupCommunityFilter();
207     this.setupCreatorFilter();
208   }
209
210   static getDerivedStateFromProps(props: any): SearchProps {
211     return {
212       q: Search.getSearchQueryFromProps(props.match.params.q),
213       type_: Search.getSearchTypeFromProps(props.match.params.type),
214       sort: Search.getSortTypeFromProps(props.match.params.sort),
215       listingType: Search.getListingTypeFromProps(
216         props.match.params.listing_type
217       ),
218       communityId: Search.getCommunityIdFromProps(
219         props.match.params.community_id
220       ),
221       creatorId: Search.getCreatorIdFromProps(props.match.params.creator_id),
222       page: Search.getPageFromProps(props.match.params.page),
223     };
224   }
225
226   fetchCommunities() {
227     let listCommunitiesForm: ListCommunities = {
228       type_: ListingType.All,
229       sort: SortType.TopAll,
230       limit: fetchLimit,
231       auth: authField(false),
232     };
233     WebSocketService.Instance.send(
234       wsClient.listCommunities(listCommunitiesForm)
235     );
236   }
237
238   static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
239     let pathSplit = req.path.split("/");
240     let promises: Promise<any>[] = [];
241
242     let communityId = this.getCommunityIdFromProps(pathSplit[11]);
243     if (communityId !== 0) {
244       let getCommunityForm: GetCommunity = {
245         id: communityId,
246       };
247       setOptionalAuth(getCommunityForm, req.auth);
248       promises.push(req.client.getCommunity(getCommunityForm));
249     } else {
250       let listCommunitiesForm: ListCommunities = {
251         type_: ListingType.All,
252         sort: SortType.TopAll,
253         limit: fetchLimit,
254       };
255       setOptionalAuth(listCommunitiesForm, req.auth);
256       promises.push(req.client.listCommunities(listCommunitiesForm));
257     }
258
259     let creatorId = this.getCreatorIdFromProps(pathSplit[13]);
260     if (creatorId !== 0) {
261       let getCreatorForm: GetPersonDetails = {
262         person_id: creatorId,
263       };
264       setOptionalAuth(getCreatorForm, req.auth);
265       promises.push(req.client.getPersonDetails(getCreatorForm));
266     } else {
267       promises.push(Promise.resolve());
268     }
269
270     let form: SearchForm = {
271       q: this.getSearchQueryFromProps(pathSplit[3]),
272       type_: this.getSearchTypeFromProps(pathSplit[5]),
273       sort: this.getSortTypeFromProps(pathSplit[7]),
274       listing_type: this.getListingTypeFromProps(pathSplit[9]),
275       page: this.getPageFromProps(pathSplit[15]),
276       limit: fetchLimit,
277     };
278     if (communityId !== 0) {
279       form.community_id = communityId;
280     }
281     if (creatorId !== 0) {
282       form.creator_id = creatorId;
283     }
284     setOptionalAuth(form, req.auth);
285
286     if (form.q != "") {
287       promises.push(req.client.search(form));
288     }
289
290     return promises;
291   }
292
293   componentDidUpdate(_: any, lastState: SearchState) {
294     if (
295       lastState.q !== this.state.q ||
296       lastState.type_ !== this.state.type_ ||
297       lastState.sort !== this.state.sort ||
298       lastState.listingType !== this.state.listingType ||
299       lastState.communityId !== this.state.communityId ||
300       lastState.creatorId !== this.state.creatorId ||
301       lastState.page !== this.state.page
302     ) {
303       this.setState({ loading: true, searchText: this.state.q });
304       this.search();
305     }
306   }
307
308   get documentTitle(): string {
309     if (this.state.q) {
310       return `${i18n.t("search")} - ${this.state.q} - ${this.state.site.name}`;
311     } else {
312       return `${i18n.t("search")} - ${this.state.site.name}`;
313     }
314   }
315
316   render() {
317     return (
318       <div class="container">
319         <HtmlTags
320           title={this.documentTitle}
321           path={this.context.router.route.match.url}
322         />
323         <h5>{i18n.t("search")}</h5>
324         {this.selects()}
325         {this.searchForm()}
326         {this.state.type_ == SearchType.All && this.all()}
327         {this.state.type_ == SearchType.Comments && this.comments()}
328         {this.state.type_ == SearchType.Posts && this.posts()}
329         {this.state.type_ == SearchType.Communities && this.communities()}
330         {this.state.type_ == SearchType.Users && this.users()}
331         {this.state.type_ == SearchType.Url && this.posts()}
332         {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
333         <Paginator page={this.state.page} onChange={this.handlePageChange} />
334       </div>
335     );
336   }
337
338   searchForm() {
339     return (
340       <form
341         class="form-inline"
342         onSubmit={linkEvent(this, this.handleSearchSubmit)}
343       >
344         <input
345           type="text"
346           class="form-control mr-2 mb-2"
347           value={this.state.searchText}
348           placeholder={`${i18n.t("search")}...`}
349           aria-label={i18n.t("search")}
350           onInput={linkEvent(this, this.handleQChange)}
351           required
352           minLength={3}
353         />
354         <button type="submit" class="btn btn-secondary mr-2 mb-2">
355           {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
356         </button>
357       </form>
358     );
359   }
360
361   selects() {
362     return (
363       <div className="mb-2">
364         <select
365           value={this.state.type_}
366           onChange={linkEvent(this, this.handleTypeChange)}
367           class="custom-select w-auto mb-2"
368           aria-label={i18n.t("type")}
369         >
370           <option disabled aria-hidden="true">
371             {i18n.t("type")}
372           </option>
373           <option value={SearchType.All}>{i18n.t("all")}</option>
374           <option value={SearchType.Comments}>{i18n.t("comments")}</option>
375           <option value={SearchType.Posts}>{i18n.t("posts")}</option>
376           <option value={SearchType.Communities}>
377             {i18n.t("communities")}
378           </option>
379           <option value={SearchType.Users}>{i18n.t("users")}</option>
380           <option value={SearchType.Url}>{i18n.t("url")}</option>
381         </select>
382         <span class="ml-2">
383           <ListingTypeSelect
384             type_={this.state.listingType}
385             showLocal={showLocal(this.isoData)}
386             onChange={this.handleListingTypeChange}
387           />
388         </span>
389         <span class="ml-2">
390           <SortSelect
391             sort={this.state.sort}
392             onChange={this.handleSortChange}
393             hideHot
394             hideMostComments
395           />
396         </span>
397         <div class="form-row">
398           {this.state.communities.length > 0 && this.communityFilter()}
399           {this.creatorFilter()}
400         </div>
401       </div>
402     );
403   }
404
405   all() {
406     let combined: {
407       type_: string;
408       data: CommentView | PostView | CommunityView | PersonViewSafe;
409       published: string;
410     }[] = [];
411     let comments = this.state.searchResponse.comments.map(e => {
412       return { type_: "comments", data: e, published: e.comment.published };
413     });
414     let posts = this.state.searchResponse.posts.map(e => {
415       return { type_: "posts", data: e, published: e.post.published };
416     });
417     let communities = this.state.searchResponse.communities.map(e => {
418       return {
419         type_: "communities",
420         data: e,
421         published: e.community.published,
422       };
423     });
424     let users = this.state.searchResponse.users.map(e => {
425       return { type_: "users", data: e, published: e.person.published };
426     });
427
428     combined.push(...comments);
429     combined.push(...posts);
430     combined.push(...communities);
431     combined.push(...users);
432
433     // Sort it
434     if (this.state.sort == SortType.New) {
435       combined.sort((a, b) => b.published.localeCompare(a.published));
436     } else {
437       combined.sort(
438         (a, b) =>
439           ((b.data as CommentView | PostView).counts.score |
440             (b.data as CommunityView).counts.subscribers |
441             (b.data as PersonViewSafe).counts.comment_score) -
442           ((a.data as CommentView | PostView).counts.score |
443             (a.data as CommunityView).counts.subscribers |
444             (a.data as PersonViewSafe).counts.comment_score)
445       );
446     }
447
448     return (
449       <div>
450         {combined.map(i => (
451           <div class="row">
452             <div class="col-12">
453               {i.type_ == "posts" && (
454                 <PostListing
455                   key={(i.data as PostView).post.id}
456                   post_view={i.data as PostView}
457                   showCommunity
458                   enableDownvotes={this.state.site.enable_downvotes}
459                   enableNsfw={this.state.site.enable_nsfw}
460                 />
461               )}
462               {i.type_ == "comments" && (
463                 <CommentNodes
464                   key={(i.data as CommentView).comment.id}
465                   nodes={[{ comment_view: i.data as CommentView }]}
466                   locked
467                   noIndent
468                   enableDownvotes={this.state.site.enable_downvotes}
469                 />
470               )}
471               {i.type_ == "communities" && (
472                 <div>{this.communityListing(i.data as CommunityView)}</div>
473               )}
474               {i.type_ == "users" && (
475                 <div>{this.userListing(i.data as PersonViewSafe)}</div>
476               )}
477             </div>
478           </div>
479         ))}
480       </div>
481     );
482   }
483
484   comments() {
485     return (
486       <CommentNodes
487         nodes={commentsToFlatNodes(this.state.searchResponse.comments)}
488         locked
489         noIndent
490         enableDownvotes={this.state.site.enable_downvotes}
491       />
492     );
493   }
494
495   posts() {
496     return (
497       <>
498         {this.state.searchResponse.posts.map(post => (
499           <div class="row">
500             <div class="col-12">
501               <PostListing
502                 post_view={post}
503                 showCommunity
504                 enableDownvotes={this.state.site.enable_downvotes}
505                 enableNsfw={this.state.site.enable_nsfw}
506               />
507             </div>
508           </div>
509         ))}
510       </>
511     );
512   }
513
514   communities() {
515     return (
516       <>
517         {this.state.searchResponse.communities.map(community => (
518           <div class="row">
519             <div class="col-12">{this.communityListing(community)}</div>
520           </div>
521         ))}
522       </>
523     );
524   }
525
526   communityListing(community_view: CommunityView) {
527     return (
528       <>
529         <span>
530           <CommunityLink community={community_view.community} />
531         </span>
532         <span>{` -
533         ${i18n.t("number_of_subscribers", {
534           count: community_view.counts.subscribers,
535         })}
536       `}</span>
537       </>
538     );
539   }
540
541   userListing(person_view: PersonViewSafe) {
542     return [
543       <span>
544         <PersonListing person={person_view.person} showApubName />
545       </span>,
546       <span>{` - ${i18n.t("number_of_comments", {
547         count: person_view.counts.comment_count,
548       })}`}</span>,
549     ];
550   }
551
552   users() {
553     return (
554       <>
555         {this.state.searchResponse.users.map(user => (
556           <div class="row">
557             <div class="col-12">{this.userListing(user)}</div>
558           </div>
559         ))}
560       </>
561     );
562   }
563
564   communityFilter() {
565     return (
566       <div class="form-group col-sm-6">
567         <label class="col-form-label" htmlFor="community-filter">
568           {i18n.t("community")}
569         </label>
570         <div>
571           <select
572             class="form-control"
573             id="community-filter"
574             value={this.state.communityId}
575           >
576             <option value="0">{i18n.t("all")}</option>
577             {this.state.communities.map(cv => (
578               <option value={cv.community.id}>{communitySelectName(cv)}</option>
579             ))}
580           </select>
581         </div>
582       </div>
583     );
584   }
585
586   creatorFilter() {
587     return (
588       <div class="form-group col-sm-6">
589         <label class="col-form-label" htmlFor="creator-filter">
590           {capitalizeFirstLetter(i18n.t("creator"))}
591         </label>
592         <div>
593           <select
594             class="form-control"
595             id="creator-filter"
596             value={this.state.creatorId}
597           >
598             <option value="0">{i18n.t("all")}</option>
599             {this.state.creator && (
600               <option value={this.state.creator.person.id}>
601                 {personSelectName(this.state.creator)}
602               </option>
603             )}
604           </select>
605         </div>
606       </div>
607     );
608   }
609
610   resultsCount(): number {
611     let res = this.state.searchResponse;
612     return (
613       res.posts.length +
614       res.comments.length +
615       res.communities.length +
616       res.users.length
617     );
618   }
619
620   handlePageChange(page: number) {
621     this.updateUrl({ page });
622   }
623
624   search() {
625     let form: SearchForm = {
626       q: this.state.q,
627       type_: this.state.type_,
628       sort: this.state.sort,
629       listing_type: this.state.listingType,
630       page: this.state.page,
631       limit: fetchLimit,
632       auth: authField(false),
633     };
634     if (this.state.communityId !== 0) {
635       form.community_id = this.state.communityId;
636     }
637     if (this.state.creatorId !== 0) {
638       form.creator_id = this.state.creatorId;
639     }
640
641     if (this.state.q != "") {
642       WebSocketService.Instance.send(wsClient.search(form));
643     }
644   }
645
646   setupCommunityFilter() {
647     if (isBrowser()) {
648       let selectId: any = document.getElementById("community-filter");
649       if (selectId) {
650         this.communityChoices = new Choices(selectId, choicesConfig);
651         this.communityChoices.passedElement.element.addEventListener(
652           "choice",
653           (e: any) => {
654             this.handleCommunityFilterChange(Number(e.detail.choice.value));
655           },
656           false
657         );
658         this.communityChoices.passedElement.element.addEventListener(
659           "search",
660           debounce(async (e: any) => {
661             let communities = (await fetchCommunities(e.detail.value))
662               .communities;
663             let choices = communities.map(cv => communityToChoice(cv));
664             choices.unshift({ value: "0", label: i18n.t("all") });
665             this.communityChoices.setChoices(choices, "value", "label", true);
666           }, 400),
667           false
668         );
669       }
670     }
671   }
672
673   setupCreatorFilter() {
674     if (isBrowser()) {
675       let selectId: any = document.getElementById("creator-filter");
676       if (selectId) {
677         this.creatorChoices = new Choices(selectId, choicesConfig);
678         this.creatorChoices.passedElement.element.addEventListener(
679           "choice",
680           (e: any) => {
681             this.handleCreatorFilterChange(Number(e.detail.choice.value));
682           },
683           false
684         );
685         this.creatorChoices.passedElement.element.addEventListener(
686           "search",
687           debounce(async (e: any) => {
688             let creators = (await fetchUsers(e.detail.value)).users;
689             let choices = creators.map(pvs => personToChoice(pvs));
690             choices.unshift({ value: "0", label: i18n.t("all") });
691             this.creatorChoices.setChoices(choices, "value", "label", true);
692           }, 400),
693           false
694         );
695       }
696     }
697   }
698
699   handleSortChange(val: SortType) {
700     this.updateUrl({ sort: val, page: 1 });
701   }
702
703   handleTypeChange(i: Search, event: any) {
704     i.updateUrl({
705       type_: SearchType[event.target.value],
706       page: 1,
707     });
708   }
709
710   handleListingTypeChange(val: ListingType) {
711     this.updateUrl({
712       listingType: val,
713       page: 1,
714     });
715   }
716
717   handleCommunityFilterChange(communityId: number) {
718     this.updateUrl({
719       communityId,
720       page: 1,
721     });
722   }
723
724   handleCreatorFilterChange(creatorId: number) {
725     this.updateUrl({
726       creatorId,
727       page: 1,
728     });
729   }
730
731   handleSearchSubmit(i: Search, event: any) {
732     event.preventDefault();
733     i.updateUrl({
734       q: i.state.searchText,
735       type_: i.state.type_,
736       listingType: i.state.listingType,
737       communityId: i.state.communityId,
738       creatorId: i.state.creatorId,
739       sort: i.state.sort,
740       page: i.state.page,
741     });
742   }
743
744   handleQChange(i: Search, event: any) {
745     i.setState({ searchText: event.target.value });
746   }
747
748   updateUrl(paramUpdates: UrlParams) {
749     const qStr = paramUpdates.q || this.state.q;
750     const qStrEncoded = encodeURIComponent(qStr);
751     const typeStr = paramUpdates.type_ || this.state.type_;
752     const listingTypeStr = paramUpdates.listingType || this.state.listingType;
753     const sortStr = paramUpdates.sort || this.state.sort;
754     const communityId =
755       paramUpdates.communityId == 0
756         ? 0
757         : paramUpdates.communityId || this.state.communityId;
758     const creatorId =
759       paramUpdates.creatorId == 0
760         ? 0
761         : paramUpdates.creatorId || this.state.creatorId;
762     const page = paramUpdates.page || this.state.page;
763     this.props.history.push(
764       `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/listing_type/${listingTypeStr}/community_id/${communityId}/creator_id/${creatorId}/page/${page}`
765     );
766   }
767
768   parseMessage(msg: any) {
769     console.log(msg);
770     let op = wsUserOp(msg);
771     if (msg.error) {
772       toast(i18n.t(msg.error), "danger");
773       return;
774     } else if (op == UserOperation.Search) {
775       let data = wsJsonToRes<SearchResponse>(msg).data;
776       this.state.searchResponse = data;
777       this.state.loading = false;
778       window.scrollTo(0, 0);
779       this.setState(this.state);
780       restoreScrollPosition(this.context);
781     } else if (op == UserOperation.CreateCommentLike) {
782       let data = wsJsonToRes<CommentResponse>(msg).data;
783       createCommentLikeRes(
784         data.comment_view,
785         this.state.searchResponse.comments
786       );
787       this.setState(this.state);
788     } else if (op == UserOperation.CreatePostLike) {
789       let data = wsJsonToRes<PostResponse>(msg).data;
790       createPostLikeFindRes(data.post_view, this.state.searchResponse.posts);
791       this.setState(this.state);
792     } else if (op == UserOperation.ListCommunities) {
793       let data = wsJsonToRes<ListCommunitiesResponse>(msg).data;
794       this.state.communities = data.communities;
795       this.setState(this.state);
796       this.setupCommunityFilter();
797     }
798   }
799 }