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