]> Untitled Git - lemmy-ui.git/blob - src/shared/components/search.tsx
252e0de63f6176935170ad9efbf48cd47fbeabe4
[lemmy-ui.git] / src / shared / components / search.tsx
1 import type { NoOptionI18nKeys } from "i18next";
2 import { Component, linkEvent } from "inferno";
3 import {
4   CommentView,
5   CommunityView,
6   GetCommunity,
7   GetCommunityResponse,
8   GetPersonDetails,
9   GetPersonDetailsResponse,
10   GetSiteResponse,
11   ListCommunities,
12   ListCommunitiesResponse,
13   ListingType,
14   PersonView,
15   PostView,
16   ResolveObject,
17   ResolveObjectResponse,
18   Search as SearchForm,
19   SearchResponse,
20   SearchType,
21   SortType,
22 } from "lemmy-js-client";
23 import { i18n } from "../i18next";
24 import { CommentViewType, InitialFetchRequest } from "../interfaces";
25 import { FirstLoadService } from "../services/FirstLoadService";
26 import { HttpService, RequestState } from "../services/HttpService";
27 import {
28   Choice,
29   QueryParams,
30   RouteDataResponse,
31   capitalizeFirstLetter,
32   commentsToFlatNodes,
33   communityToChoice,
34   debounce,
35   enableDownvotes,
36   enableNsfw,
37   fetchCommunities,
38   fetchLimit,
39   fetchUsers,
40   getIdFromString,
41   getPageFromString,
42   getQueryParams,
43   getQueryString,
44   getUpdatedSearchId,
45   myAuth,
46   numToSI,
47   personToChoice,
48   restoreScrollPosition,
49   saveScrollPosition,
50   setIsoData,
51   showLocal,
52 } from "../utils";
53 import { CommentNodes } from "./comment/comment-nodes";
54 import { HtmlTags } from "./common/html-tags";
55 import { Spinner } from "./common/icon";
56 import { ListingTypeSelect } from "./common/listing-type-select";
57 import { Paginator } from "./common/paginator";
58 import { SearchableSelect } from "./common/searchable-select";
59 import { SortSelect } from "./common/sort-select";
60 import { CommunityLink } from "./community/community-link";
61 import { PersonListing } from "./person/person-listing";
62 import { PostListing } from "./post/post-listing";
63
64 interface SearchProps {
65   q?: string;
66   type: SearchType;
67   sort: SortType;
68   listingType: ListingType;
69   communityId?: number | null;
70   creatorId?: number | null;
71   page: number;
72 }
73
74 type SearchData = RouteDataResponse<{
75   communityResponse: GetCommunityResponse;
76   listCommunitiesResponse: ListCommunitiesResponse;
77   creatorDetailsResponse: GetPersonDetailsResponse;
78   searchResponse: SearchResponse;
79   resolveObjectResponse: ResolveObjectResponse;
80 }>;
81
82 type FilterType = "creator" | "community";
83
84 interface SearchState {
85   searchRes: RequestState<SearchResponse>;
86   resolveObjectRes: RequestState<ResolveObjectResponse>;
87   creatorDetailsRes: RequestState<GetPersonDetailsResponse>;
88   communitiesRes: RequestState<ListCommunitiesResponse>;
89   communityRes: RequestState<GetCommunityResponse>;
90   siteRes: GetSiteResponse;
91   searchText?: string;
92   communitySearchOptions: Choice[];
93   creatorSearchOptions: Choice[];
94   searchCreatorLoading: boolean;
95   searchCommunitiesLoading: boolean;
96   isIsomorphic: boolean;
97 }
98
99 interface Combined {
100   type_: string;
101   data: CommentView | PostView | CommunityView | PersonView;
102   published: string;
103 }
104
105 const defaultSearchType = "All";
106 const defaultSortType = "TopAll";
107 const defaultListingType = "All";
108
109 const searchTypes = ["All", "Comments", "Posts", "Communities", "Users", "Url"];
110
111 const getSearchQueryParams = () =>
112   getQueryParams<SearchProps>({
113     q: getSearchQueryFromQuery,
114     type: getSearchTypeFromQuery,
115     sort: getSortTypeFromQuery,
116     listingType: getListingTypeFromQuery,
117     communityId: getIdFromString,
118     creatorId: getIdFromString,
119     page: getPageFromString,
120   });
121
122 const getSearchQueryFromQuery = (q?: string): string | undefined =>
123   q ? decodeURIComponent(q) : undefined;
124
125 function getSearchTypeFromQuery(type_?: string): SearchType {
126   return type_ ? (type_ as SearchType) : defaultSearchType;
127 }
128
129 function getSortTypeFromQuery(sort?: string): SortType {
130   return sort ? (sort as SortType) : defaultSortType;
131 }
132
133 function getListingTypeFromQuery(listingType?: string): ListingType {
134   return listingType ? (listingType as ListingType) : defaultListingType;
135 }
136
137 function postViewToCombined(data: PostView): Combined {
138   return {
139     type_: "posts",
140     data,
141     published: data.post.published,
142   };
143 }
144
145 function commentViewToCombined(data: CommentView): Combined {
146   return {
147     type_: "comments",
148     data,
149     published: data.comment.published,
150   };
151 }
152
153 function communityViewToCombined(data: CommunityView): Combined {
154   return {
155     type_: "communities",
156     data,
157     published: data.community.published,
158   };
159 }
160
161 function personViewSafeToCombined(data: PersonView): Combined {
162   return {
163     type_: "users",
164     data,
165     published: data.person.published,
166   };
167 }
168
169 const Filter = ({
170   filterType,
171   options,
172   onChange,
173   onSearch,
174   value,
175   loading,
176 }: {
177   filterType: FilterType;
178   options: Choice[];
179   onSearch: (text: string) => void;
180   onChange: (choice: Choice) => void;
181   value?: number | null;
182   loading: boolean;
183 }) => {
184   return (
185     <div className="mb-3 col-sm-6">
186       <label className="col-form-label me-2" htmlFor={`${filterType}-filter`}>
187         {capitalizeFirstLetter(i18n.t(filterType))}
188       </label>
189       <SearchableSelect
190         id={`${filterType}-filter`}
191         options={[
192           {
193             label: i18n.t("all"),
194             value: "0",
195           },
196         ].concat(options)}
197         value={value ?? 0}
198         onSearch={onSearch}
199         onChange={onChange}
200         loading={loading}
201       />
202     </div>
203   );
204 };
205
206 const communityListing = ({
207   community,
208   counts: { subscribers },
209 }: CommunityView) =>
210   getListing(
211     <CommunityLink community={community} />,
212     subscribers,
213     "number_of_subscribers"
214   );
215
216 const personListing = ({ person, counts: { comment_count } }: PersonView) =>
217   getListing(
218     <PersonListing person={person} showApubName />,
219     comment_count,
220     "number_of_comments"
221   );
222
223 function getListing(
224   listing: JSX.ElementClass,
225   count: number,
226   translationKey: "number_of_comments" | "number_of_subscribers"
227 ) {
228   return (
229     <>
230       <span>{listing}</span>
231       <span>{` - ${i18n.t(translationKey, {
232         count: Number(count),
233         formattedCount: numToSI(count),
234       })}`}</span>
235     </>
236   );
237 }
238
239 export class Search extends Component<any, SearchState> {
240   private isoData = setIsoData<SearchData>(this.context);
241
242   state: SearchState = {
243     resolveObjectRes: { state: "empty" },
244     creatorDetailsRes: { state: "empty" },
245     communitiesRes: { state: "empty" },
246     communityRes: { state: "empty" },
247     siteRes: this.isoData.site_res,
248     creatorSearchOptions: [],
249     communitySearchOptions: [],
250     searchRes: { state: "empty" },
251     searchCreatorLoading: false,
252     searchCommunitiesLoading: false,
253     isIsomorphic: false,
254   };
255
256   constructor(props: any, context: any) {
257     super(props, context);
258
259     this.handleSortChange = this.handleSortChange.bind(this);
260     this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
261     this.handlePageChange = this.handlePageChange.bind(this);
262     this.handleCommunityFilterChange =
263       this.handleCommunityFilterChange.bind(this);
264     this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
265
266     const { q } = getSearchQueryParams();
267
268     this.state = {
269       ...this.state,
270       searchText: q,
271     };
272
273     // Only fetch the data if coming from another route
274     if (FirstLoadService.isFirstLoad) {
275       const {
276         communityResponse: communityRes,
277         creatorDetailsResponse: creatorDetailsRes,
278         listCommunitiesResponse: communitiesRes,
279         resolveObjectResponse: resolveObjectRes,
280         searchResponse: searchRes,
281       } = this.isoData.routeData;
282
283       this.state = {
284         ...this.state,
285         isIsomorphic: true,
286       };
287
288       if (creatorDetailsRes?.state === "success") {
289         this.state = {
290           ...this.state,
291           creatorSearchOptions:
292             creatorDetailsRes?.state === "success"
293               ? [personToChoice(creatorDetailsRes.data.person_view)]
294               : [],
295           creatorDetailsRes,
296         };
297       }
298
299       if (communitiesRes?.state === "success") {
300         this.state = {
301           ...this.state,
302           communitiesRes,
303         };
304       }
305
306       if (communityRes?.state === "success") {
307         this.state = {
308           ...this.state,
309           communityRes,
310         };
311       }
312
313       if (q !== "") {
314         this.state = {
315           ...this.state,
316         };
317
318         if (searchRes?.state === "success") {
319           this.state = {
320             ...this.state,
321             searchRes,
322           };
323         }
324
325         if (resolveObjectRes?.state === "success") {
326           this.state = {
327             ...this.state,
328             resolveObjectRes,
329           };
330         }
331       }
332     }
333   }
334
335   async componentDidMount() {
336     if (!this.state.isIsomorphic) {
337       const promises = [this.fetchCommunities()];
338       if (this.state.searchText) {
339         promises.push(this.search());
340       }
341
342       await Promise.all(promises);
343     }
344   }
345
346   async fetchCommunities() {
347     this.setState({ communitiesRes: { state: "loading" } });
348     this.setState({
349       communitiesRes: await HttpService.client.listCommunities({
350         type_: defaultListingType,
351         sort: defaultSortType,
352         limit: fetchLimit,
353         auth: myAuth(),
354       }),
355     });
356   }
357
358   componentWillUnmount() {
359     saveScrollPosition(this.context);
360   }
361
362   static async fetchInitialData({
363     client,
364     auth,
365     query: { communityId, creatorId, q, type, sort, listingType, page },
366   }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<SearchData> {
367     const community_id = getIdFromString(communityId);
368     let communityResponse: RequestState<GetCommunityResponse> = {
369       state: "empty",
370     };
371     let listCommunitiesResponse: RequestState<ListCommunitiesResponse> = {
372       state: "empty",
373     };
374     if (community_id) {
375       const getCommunityForm: GetCommunity = {
376         id: community_id,
377         auth,
378       };
379
380       communityResponse = await client.getCommunity(getCommunityForm);
381     } else {
382       const listCommunitiesForm: ListCommunities = {
383         type_: defaultListingType,
384         sort: defaultSortType,
385         limit: fetchLimit,
386         auth,
387       };
388
389       listCommunitiesResponse = await client.listCommunities(
390         listCommunitiesForm
391       );
392     }
393
394     const creator_id = getIdFromString(creatorId);
395     let creatorDetailsResponse: RequestState<GetPersonDetailsResponse> = {
396       state: "empty",
397     };
398     if (creator_id) {
399       const getCreatorForm: GetPersonDetails = {
400         person_id: creator_id,
401         auth,
402       };
403
404       creatorDetailsResponse = await client.getPersonDetails(getCreatorForm);
405     }
406
407     const query = getSearchQueryFromQuery(q);
408
409     let searchResponse: RequestState<SearchResponse> = { state: "empty" };
410     let resolveObjectResponse: RequestState<ResolveObjectResponse> = {
411       state: "empty",
412     };
413
414     if (query) {
415       const form: SearchForm = {
416         q: query,
417         community_id,
418         creator_id,
419         type_: getSearchTypeFromQuery(type),
420         sort: getSortTypeFromQuery(sort),
421         listing_type: getListingTypeFromQuery(listingType),
422         page: getPageFromString(page),
423         limit: fetchLimit,
424         auth,
425       };
426
427       if (query !== "") {
428         searchResponse = await client.search(form);
429         if (auth) {
430           const resolveObjectForm: ResolveObject = {
431             q: query,
432             auth,
433           };
434           resolveObjectResponse = await client.resolveObject(resolveObjectForm);
435         }
436       }
437     }
438
439     return {
440       communityResponse,
441       creatorDetailsResponse,
442       listCommunitiesResponse,
443       resolveObjectResponse,
444       searchResponse,
445     };
446   }
447
448   get documentTitle(): string {
449     const { q } = getSearchQueryParams();
450     const name = this.state.siteRes.site_view.site.name;
451     return `${i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
452   }
453
454   render() {
455     const { type, page } = getSearchQueryParams();
456
457     return (
458       <div className="container-lg">
459         <HtmlTags
460           title={this.documentTitle}
461           path={this.context.router.route.match.url}
462         />
463         <h5>{i18n.t("search")}</h5>
464         {this.selects}
465         {this.searchForm}
466         {this.displayResults(type)}
467         {this.resultsCount === 0 &&
468           this.state.searchRes.state === "success" && (
469             <span>{i18n.t("no_results")}</span>
470           )}
471         <Paginator page={page} onChange={this.handlePageChange} />
472       </div>
473     );
474   }
475
476   displayResults(type: SearchType) {
477     switch (type) {
478       case "All":
479         return this.all;
480       case "Comments":
481         return this.comments;
482       case "Posts":
483       case "Url":
484         return this.posts;
485       case "Communities":
486         return this.communities;
487       case "Users":
488         return this.users;
489       default:
490         return <></>;
491     }
492   }
493
494   get searchForm() {
495     return (
496       <form className="row" onSubmit={linkEvent(this, this.handleSearchSubmit)}>
497         <div className="col-auto">
498           <input
499             type="text"
500             className="form-control me-2 mb-2 col-sm-8"
501             value={this.state.searchText}
502             placeholder={`${i18n.t("search")}...`}
503             aria-label={i18n.t("search")}
504             onInput={linkEvent(this, this.handleQChange)}
505             required
506             minLength={1}
507           />
508         </div>
509         <div className="col-auto">
510           <button type="submit" className="btn btn-secondary mb-2">
511             {this.state.searchRes.state === "loading" ? (
512               <Spinner />
513             ) : (
514               <span>{i18n.t("search")}</span>
515             )}
516           </button>
517         </div>
518       </form>
519     );
520   }
521
522   get selects() {
523     const { type, listingType, sort, communityId, creatorId } =
524       getSearchQueryParams();
525     const {
526       communitySearchOptions,
527       creatorSearchOptions,
528       searchCommunitiesLoading,
529       searchCreatorLoading,
530       communitiesRes,
531     } = this.state;
532
533     const hasCommunities =
534       communitiesRes.state == "success" &&
535       communitiesRes.data.communities.length > 0;
536
537     return (
538       <div className="mb-2">
539         <select
540           value={type}
541           onChange={linkEvent(this, this.handleTypeChange)}
542           className="form-select d-inline-block w-auto mb-2"
543           aria-label={i18n.t("type")}
544         >
545           <option disabled aria-hidden="true">
546             {i18n.t("type")}
547           </option>
548           {searchTypes.map(option => (
549             <option value={option} key={option}>
550               {i18n.t(option.toString().toLowerCase() as NoOptionI18nKeys)}
551             </option>
552           ))}
553         </select>
554         <span className="ms-2">
555           <ListingTypeSelect
556             type_={listingType}
557             showLocal={showLocal(this.isoData)}
558             showSubscribed
559             onChange={this.handleListingTypeChange}
560           />
561         </span>
562         <span className="ms-2">
563           <SortSelect
564             sort={sort}
565             onChange={this.handleSortChange}
566             hideHot
567             hideMostComments
568           />
569         </span>
570         <div className="row">
571           {hasCommunities && (
572             <Filter
573               filterType="community"
574               onChange={this.handleCommunityFilterChange}
575               onSearch={this.handleCommunitySearch}
576               options={communitySearchOptions}
577               value={communityId}
578               loading={searchCommunitiesLoading}
579             />
580           )}
581           <Filter
582             filterType="creator"
583             onChange={this.handleCreatorFilterChange}
584             onSearch={this.handleCreatorSearch}
585             options={creatorSearchOptions}
586             value={creatorId}
587             loading={searchCreatorLoading}
588           />
589         </div>
590       </div>
591     );
592   }
593
594   buildCombined(): Combined[] {
595     const combined: Combined[] = [];
596     const {
597       resolveObjectRes: resolveObjectResponse,
598       searchRes: searchResponse,
599     } = this.state;
600
601     // Push the possible resolve / federated objects first
602     if (resolveObjectResponse.state == "success") {
603       const { comment, post, community, person } = resolveObjectResponse.data;
604
605       if (comment) {
606         combined.push(commentViewToCombined(comment));
607       }
608       if (post) {
609         combined.push(postViewToCombined(post));
610       }
611       if (community) {
612         combined.push(communityViewToCombined(community));
613       }
614       if (person) {
615         combined.push(personViewSafeToCombined(person));
616       }
617     }
618
619     // Push the search results
620     if (searchResponse.state === "success") {
621       const { comments, posts, communities, users } = searchResponse.data;
622
623       combined.push(
624         ...[
625           ...(comments?.map(commentViewToCombined) ?? []),
626           ...(posts?.map(postViewToCombined) ?? []),
627           ...(communities?.map(communityViewToCombined) ?? []),
628           ...(users?.map(personViewSafeToCombined) ?? []),
629         ]
630       );
631     }
632
633     const { sort } = getSearchQueryParams();
634
635     // Sort it
636     if (sort === "New") {
637       combined.sort((a, b) => b.published.localeCompare(a.published));
638     } else {
639       combined.sort((a, b) =>
640         Number(
641           ((b.data as CommentView | PostView).counts.score |
642             (b.data as CommunityView).counts.subscribers |
643             (b.data as PersonView).counts.comment_score) -
644             ((a.data as CommentView | PostView).counts.score |
645               (a.data as CommunityView).counts.subscribers |
646               (a.data as PersonView).counts.comment_score)
647         )
648       );
649     }
650
651     return combined;
652   }
653
654   get all() {
655     const combined = this.buildCombined();
656
657     return (
658       <div>
659         {combined.map(i => (
660           <div key={i.published} className="row">
661             <div className="col-12">
662               {i.type_ === "posts" && (
663                 <PostListing
664                   key={(i.data as PostView).post.id}
665                   post_view={i.data as PostView}
666                   showCommunity
667                   enableDownvotes={enableDownvotes(this.state.siteRes)}
668                   enableNsfw={enableNsfw(this.state.siteRes)}
669                   allLanguages={this.state.siteRes.all_languages}
670                   siteLanguages={this.state.siteRes.discussion_languages}
671                   viewOnly
672                   // All of these are unused, since its view only
673                   onPostEdit={() => {}}
674                   onPostVote={() => {}}
675                   onPostReport={() => {}}
676                   onBlockPerson={() => {}}
677                   onLockPost={() => {}}
678                   onDeletePost={() => {}}
679                   onRemovePost={() => {}}
680                   onSavePost={() => {}}
681                   onFeaturePost={() => {}}
682                   onPurgePerson={() => {}}
683                   onPurgePost={() => {}}
684                   onBanPersonFromCommunity={() => {}}
685                   onBanPerson={() => {}}
686                   onAddModToCommunity={() => {}}
687                   onAddAdmin={() => {}}
688                   onTransferCommunity={() => {}}
689                 />
690               )}
691               {i.type_ === "comments" && (
692                 <CommentNodes
693                   key={(i.data as CommentView).comment.id}
694                   nodes={[
695                     {
696                       comment_view: i.data as CommentView,
697                       children: [],
698                       depth: 0,
699                     },
700                   ]}
701                   viewType={CommentViewType.Flat}
702                   viewOnly
703                   locked
704                   noIndent
705                   enableDownvotes={enableDownvotes(this.state.siteRes)}
706                   allLanguages={this.state.siteRes.all_languages}
707                   siteLanguages={this.state.siteRes.discussion_languages}
708                   // All of these are unused, since its viewonly
709                   finished={new Map()}
710                   onSaveComment={() => {}}
711                   onBlockPerson={() => {}}
712                   onDeleteComment={() => {}}
713                   onRemoveComment={() => {}}
714                   onCommentVote={() => {}}
715                   onCommentReport={() => {}}
716                   onDistinguishComment={() => {}}
717                   onAddModToCommunity={() => {}}
718                   onAddAdmin={() => {}}
719                   onTransferCommunity={() => {}}
720                   onPurgeComment={() => {}}
721                   onPurgePerson={() => {}}
722                   onCommentReplyRead={() => {}}
723                   onPersonMentionRead={() => {}}
724                   onBanPersonFromCommunity={() => {}}
725                   onBanPerson={() => {}}
726                   onCreateComment={() => Promise.resolve({ state: "empty" })}
727                   onEditComment={() => Promise.resolve({ state: "empty" })}
728                 />
729               )}
730               {i.type_ === "communities" && (
731                 <div>{communityListing(i.data as CommunityView)}</div>
732               )}
733               {i.type_ === "users" && (
734                 <div>{personListing(i.data as PersonView)}</div>
735               )}
736             </div>
737           </div>
738         ))}
739       </div>
740     );
741   }
742
743   get comments() {
744     const {
745       searchRes: searchResponse,
746       resolveObjectRes: resolveObjectResponse,
747       siteRes,
748     } = this.state;
749     const comments =
750       searchResponse.state === "success" ? searchResponse.data.comments : [];
751
752     if (
753       resolveObjectResponse.state === "success" &&
754       resolveObjectResponse.data.comment
755     ) {
756       comments.unshift(resolveObjectResponse.data.comment);
757     }
758
759     return (
760       <CommentNodes
761         nodes={commentsToFlatNodes(comments)}
762         viewType={CommentViewType.Flat}
763         viewOnly
764         locked
765         noIndent
766         enableDownvotes={enableDownvotes(siteRes)}
767         allLanguages={siteRes.all_languages}
768         siteLanguages={siteRes.discussion_languages}
769         // All of these are unused, since its viewonly
770         finished={new Map()}
771         onSaveComment={() => {}}
772         onBlockPerson={() => {}}
773         onDeleteComment={() => {}}
774         onRemoveComment={() => {}}
775         onCommentVote={() => {}}
776         onCommentReport={() => {}}
777         onDistinguishComment={() => {}}
778         onAddModToCommunity={() => {}}
779         onAddAdmin={() => {}}
780         onTransferCommunity={() => {}}
781         onPurgeComment={() => {}}
782         onPurgePerson={() => {}}
783         onCommentReplyRead={() => {}}
784         onPersonMentionRead={() => {}}
785         onBanPersonFromCommunity={() => {}}
786         onBanPerson={() => {}}
787         onCreateComment={() => Promise.resolve({ state: "empty" })}
788         onEditComment={() => Promise.resolve({ state: "empty" })}
789       />
790     );
791   }
792
793   get posts() {
794     const {
795       searchRes: searchResponse,
796       resolveObjectRes: resolveObjectResponse,
797       siteRes,
798     } = this.state;
799     const posts =
800       searchResponse.state === "success" ? searchResponse.data.posts : [];
801
802     if (
803       resolveObjectResponse.state === "success" &&
804       resolveObjectResponse.data.post
805     ) {
806       posts.unshift(resolveObjectResponse.data.post);
807     }
808
809     return (
810       <>
811         {posts.map(pv => (
812           <div key={pv.post.id} className="row">
813             <div className="col-12">
814               <PostListing
815                 post_view={pv}
816                 showCommunity
817                 enableDownvotes={enableDownvotes(siteRes)}
818                 enableNsfw={enableNsfw(siteRes)}
819                 allLanguages={siteRes.all_languages}
820                 siteLanguages={siteRes.discussion_languages}
821                 viewOnly
822                 // All of these are unused, since its view only
823                 onPostEdit={() => {}}
824                 onPostVote={() => {}}
825                 onPostReport={() => {}}
826                 onBlockPerson={() => {}}
827                 onLockPost={() => {}}
828                 onDeletePost={() => {}}
829                 onRemovePost={() => {}}
830                 onSavePost={() => {}}
831                 onFeaturePost={() => {}}
832                 onPurgePerson={() => {}}
833                 onPurgePost={() => {}}
834                 onBanPersonFromCommunity={() => {}}
835                 onBanPerson={() => {}}
836                 onAddModToCommunity={() => {}}
837                 onAddAdmin={() => {}}
838                 onTransferCommunity={() => {}}
839               />
840             </div>
841           </div>
842         ))}
843       </>
844     );
845   }
846
847   get communities() {
848     const {
849       searchRes: searchResponse,
850       resolveObjectRes: resolveObjectResponse,
851     } = this.state;
852     const communities =
853       searchResponse.state === "success" ? searchResponse.data.communities : [];
854
855     if (
856       resolveObjectResponse.state === "success" &&
857       resolveObjectResponse.data.community
858     ) {
859       communities.unshift(resolveObjectResponse.data.community);
860     }
861
862     return (
863       <>
864         {communities.map(cv => (
865           <div key={cv.community.id} className="row">
866             <div className="col-12">{communityListing(cv)}</div>
867           </div>
868         ))}
869       </>
870     );
871   }
872
873   get users() {
874     const {
875       searchRes: searchResponse,
876       resolveObjectRes: resolveObjectResponse,
877     } = this.state;
878     const users =
879       searchResponse.state === "success" ? searchResponse.data.users : [];
880
881     if (
882       resolveObjectResponse.state === "success" &&
883       resolveObjectResponse.data.person
884     ) {
885       users.unshift(resolveObjectResponse.data.person);
886     }
887
888     return (
889       <>
890         {users.map(pvs => (
891           <div key={pvs.person.id} className="row">
892             <div className="col-12">{personListing(pvs)}</div>
893           </div>
894         ))}
895       </>
896     );
897   }
898
899   get resultsCount(): number {
900     const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
901
902     const searchCount =
903       r.state === "success"
904         ? r.data.posts.length +
905           r.data.comments.length +
906           r.data.communities.length +
907           r.data.users.length
908         : 0;
909
910     const resObjCount =
911       resolveRes.state === "success"
912         ? resolveRes.data.post ||
913           resolveRes.data.person ||
914           resolveRes.data.community ||
915           resolveRes.data.comment
916           ? 1
917           : 0
918         : 0;
919
920     return resObjCount + searchCount;
921   }
922
923   async search() {
924     const auth = myAuth();
925     const { searchText: q } = this.state;
926     const { communityId, creatorId, type, sort, listingType, page } =
927       getSearchQueryParams();
928
929     if (q) {
930       this.setState({ searchRes: { state: "loading" } });
931       this.setState({
932         searchRes: await HttpService.client.search({
933           q,
934           community_id: communityId ?? undefined,
935           creator_id: creatorId ?? undefined,
936           type_: type,
937           sort,
938           listing_type: listingType,
939           page,
940           limit: fetchLimit,
941           auth,
942         }),
943       });
944       window.scrollTo(0, 0);
945       restoreScrollPosition(this.context);
946
947       if (auth) {
948         this.setState({ resolveObjectRes: { state: "loading" } });
949         this.setState({
950           resolveObjectRes: await HttpService.client.resolveObject({
951             q,
952             auth,
953           }),
954         });
955       }
956     }
957   }
958
959   handleCreatorSearch = debounce(async (text: string) => {
960     const { creatorId } = getSearchQueryParams();
961     const { creatorSearchOptions } = this.state;
962     const newOptions: Choice[] = [];
963
964     this.setState({ searchCreatorLoading: true });
965
966     const selectedChoice = creatorSearchOptions.find(
967       choice => getIdFromString(choice.value) === creatorId
968     );
969
970     if (selectedChoice) {
971       newOptions.push(selectedChoice);
972     }
973
974     if (text.length > 0) {
975       newOptions.push(...(await fetchUsers(text)).map(personToChoice));
976     }
977
978     this.setState({
979       searchCreatorLoading: false,
980       creatorSearchOptions: newOptions,
981     });
982   });
983
984   handleCommunitySearch = debounce(async (text: string) => {
985     const { communityId } = getSearchQueryParams();
986     const { communitySearchOptions } = this.state;
987     this.setState({
988       searchCommunitiesLoading: true,
989     });
990
991     const newOptions: Choice[] = [];
992
993     const selectedChoice = communitySearchOptions.find(
994       choice => getIdFromString(choice.value) === communityId
995     );
996
997     if (selectedChoice) {
998       newOptions.push(selectedChoice);
999     }
1000
1001     if (text.length > 0) {
1002       newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
1003     }
1004
1005     this.setState({
1006       searchCommunitiesLoading: false,
1007       communitySearchOptions: newOptions,
1008     });
1009   });
1010
1011   handleSortChange(sort: SortType) {
1012     this.updateUrl({ sort, page: 1 });
1013   }
1014
1015   handleTypeChange(i: Search, event: any) {
1016     const type = event.target.value as SearchType;
1017
1018     i.updateUrl({
1019       type,
1020       page: 1,
1021     });
1022   }
1023
1024   handlePageChange(page: number) {
1025     this.updateUrl({ page });
1026   }
1027
1028   handleListingTypeChange(listingType: ListingType) {
1029     this.updateUrl({
1030       listingType,
1031       page: 1,
1032     });
1033   }
1034
1035   handleCommunityFilterChange({ value }: Choice) {
1036     this.updateUrl({
1037       communityId: getIdFromString(value) ?? null,
1038       page: 1,
1039     });
1040   }
1041
1042   handleCreatorFilterChange({ value }: Choice) {
1043     this.updateUrl({
1044       creatorId: getIdFromString(value) ?? null,
1045       page: 1,
1046     });
1047   }
1048
1049   handleSearchSubmit(i: Search, event: any) {
1050     event.preventDefault();
1051
1052     i.updateUrl({
1053       q: i.state.searchText,
1054       page: 1,
1055     });
1056   }
1057
1058   handleQChange(i: Search, event: any) {
1059     i.setState({ searchText: event.target.value });
1060   }
1061
1062   async updateUrl({
1063     q,
1064     type,
1065     listingType,
1066     sort,
1067     communityId,
1068     creatorId,
1069     page,
1070   }: Partial<SearchProps>) {
1071     const {
1072       q: urlQ,
1073       type: urlType,
1074       listingType: urlListingType,
1075       communityId: urlCommunityId,
1076       sort: urlSort,
1077       creatorId: urlCreatorId,
1078       page: urlPage,
1079     } = getSearchQueryParams();
1080
1081     let query = q ?? this.state.searchText ?? urlQ;
1082
1083     if (query && query.length > 0) {
1084       query = encodeURIComponent(query);
1085     }
1086
1087     const queryParams: QueryParams<SearchProps> = {
1088       q: query,
1089       type: type ?? urlType,
1090       listingType: listingType ?? urlListingType,
1091       communityId: getUpdatedSearchId(communityId, urlCommunityId),
1092       creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
1093       page: (page ?? urlPage).toString(),
1094       sort: sort ?? urlSort,
1095     };
1096
1097     this.props.history.push(`/search${getQueryString(queryParams)}`);
1098
1099     await this.search();
1100   }
1101 }