]> Untitled Git - lemmy-ui.git/blob - src/shared/components/search.tsx
e9aaaa1b3761c97b37fcb821ce6930ad7cfa8c35
[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="col-sm-6">
185       <label className="mb-1" 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
504         className="row gx-2 gy-3"
505         onSubmit={linkEvent(this, this.handleSearchSubmit)}
506       >
507         <div className="col-auto flex-grow-1 flex-sm-grow-0">
508           <input
509             type="text"
510             className="form-control me-2 mb-2 col-sm-8"
511             value={this.state.searchText}
512             placeholder={`${I18NextService.i18n.t("search")}...`}
513             aria-label={I18NextService.i18n.t("search")}
514             onInput={linkEvent(this, this.handleQChange)}
515             required
516             minLength={1}
517           />
518         </div>
519         <div className="col-auto">
520           <button type="submit" className="btn btn-secondary mb-2">
521             {this.state.searchRes.state === "loading" ? (
522               <Spinner />
523             ) : (
524               <span>{I18NextService.i18n.t("search")}</span>
525             )}
526           </button>
527         </div>
528       </form>
529     );
530   }
531
532   get selects() {
533     const { type, listingType, sort, communityId, creatorId } =
534       getSearchQueryParams();
535     const {
536       communitySearchOptions,
537       creatorSearchOptions,
538       searchCommunitiesLoading,
539       searchCreatorLoading,
540       communitiesRes,
541     } = this.state;
542
543     const hasCommunities =
544       communitiesRes.state == "success" &&
545       communitiesRes.data.communities.length > 0;
546
547     return (
548       <>
549         <div className="row row-cols-auto g-2 g-sm-3 mb-2 mb-sm-3">
550           <div className="col">
551             <select
552               value={type}
553               onChange={linkEvent(this, this.handleTypeChange)}
554               className="form-select d-inline-block w-auto"
555               aria-label={I18NextService.i18n.t("type")}
556             >
557               <option disabled aria-hidden="true">
558                 {I18NextService.i18n.t("type")}
559               </option>
560               {searchTypes.map(option => (
561                 <option value={option} key={option}>
562                   {I18NextService.i18n.t(
563                     option.toString().toLowerCase() as NoOptionI18nKeys
564                   )}
565                 </option>
566               ))}
567             </select>
568           </div>
569           <div className="col">
570             <ListingTypeSelect
571               type_={listingType}
572               showLocal={showLocal(this.isoData)}
573               showSubscribed
574               onChange={this.handleListingTypeChange}
575             />
576           </div>
577           <div className="col">
578             <SortSelect
579               sort={sort}
580               onChange={this.handleSortChange}
581               hideHot
582               hideMostComments
583             />
584           </div>
585         </div>
586         <div className="row gy-2 gx-4 mb-3">
587           {hasCommunities && (
588             <Filter
589               filterType="community"
590               onChange={this.handleCommunityFilterChange}
591               onSearch={this.handleCommunitySearch}
592               options={communitySearchOptions}
593               value={communityId}
594               loading={searchCommunitiesLoading}
595             />
596           )}
597           <Filter
598             filterType="creator"
599             onChange={this.handleCreatorFilterChange}
600             onSearch={this.handleCreatorSearch}
601             options={creatorSearchOptions}
602             value={creatorId}
603             loading={searchCreatorLoading}
604           />
605         </div>
606       </>
607     );
608   }
609
610   buildCombined(): Combined[] {
611     const combined: Combined[] = [];
612     const {
613       resolveObjectRes: resolveObjectResponse,
614       searchRes: searchResponse,
615     } = this.state;
616
617     // Push the possible resolve / federated objects first
618     if (resolveObjectResponse.state == "success") {
619       const { comment, post, community, person } = resolveObjectResponse.data;
620
621       if (comment) {
622         combined.push(commentViewToCombined(comment));
623       }
624       if (post) {
625         combined.push(postViewToCombined(post));
626       }
627       if (community) {
628         combined.push(communityViewToCombined(community));
629       }
630       if (person) {
631         combined.push(personViewSafeToCombined(person));
632       }
633     }
634
635     // Push the search results
636     if (searchResponse.state === "success") {
637       const { comments, posts, communities, users } = searchResponse.data;
638
639       combined.push(
640         ...[
641           ...(comments?.map(commentViewToCombined) ?? []),
642           ...(posts?.map(postViewToCombined) ?? []),
643           ...(communities?.map(communityViewToCombined) ?? []),
644           ...(users?.map(personViewSafeToCombined) ?? []),
645         ]
646       );
647     }
648
649     const { sort } = getSearchQueryParams();
650
651     // Sort it
652     if (sort === "New") {
653       combined.sort((a, b) => b.published.localeCompare(a.published));
654     } else {
655       combined.sort((a, b) =>
656         Number(
657           ((b.data as CommentView | PostView).counts.score |
658             (b.data as CommunityView).counts.subscribers |
659             (b.data as PersonView).counts.comment_score) -
660             ((a.data as CommentView | PostView).counts.score |
661               (a.data as CommunityView).counts.subscribers |
662               (a.data as PersonView).counts.comment_score)
663         )
664       );
665     }
666
667     return combined;
668   }
669
670   get all() {
671     const combined = this.buildCombined();
672
673     return (
674       <div>
675         {combined.map(i => (
676           <div key={i.published} className="row">
677             <div className="col-12">
678               {i.type_ === "posts" && (
679                 <PostListing
680                   key={(i.data as PostView).post.id}
681                   post_view={i.data as PostView}
682                   showCommunity
683                   enableDownvotes={enableDownvotes(this.state.siteRes)}
684                   enableNsfw={enableNsfw(this.state.siteRes)}
685                   allLanguages={this.state.siteRes.all_languages}
686                   siteLanguages={this.state.siteRes.discussion_languages}
687                   viewOnly
688                   // All of these are unused, since its view only
689                   onPostEdit={() => {}}
690                   onPostVote={() => {}}
691                   onPostReport={() => {}}
692                   onBlockPerson={() => {}}
693                   onLockPost={() => {}}
694                   onDeletePost={() => {}}
695                   onRemovePost={() => {}}
696                   onSavePost={() => {}}
697                   onFeaturePost={() => {}}
698                   onPurgePerson={() => {}}
699                   onPurgePost={() => {}}
700                   onBanPersonFromCommunity={() => {}}
701                   onBanPerson={() => {}}
702                   onAddModToCommunity={() => {}}
703                   onAddAdmin={() => {}}
704                   onTransferCommunity={() => {}}
705                 />
706               )}
707               {i.type_ === "comments" && (
708                 <CommentNodes
709                   key={(i.data as CommentView).comment.id}
710                   nodes={[
711                     {
712                       comment_view: i.data as CommentView,
713                       children: [],
714                       depth: 0,
715                     },
716                   ]}
717                   viewType={CommentViewType.Flat}
718                   viewOnly
719                   locked
720                   noIndent
721                   enableDownvotes={enableDownvotes(this.state.siteRes)}
722                   allLanguages={this.state.siteRes.all_languages}
723                   siteLanguages={this.state.siteRes.discussion_languages}
724                   // All of these are unused, since its viewonly
725                   finished={new Map()}
726                   onSaveComment={() => {}}
727                   onBlockPerson={() => {}}
728                   onDeleteComment={() => {}}
729                   onRemoveComment={() => {}}
730                   onCommentVote={() => {}}
731                   onCommentReport={() => {}}
732                   onDistinguishComment={() => {}}
733                   onAddModToCommunity={() => {}}
734                   onAddAdmin={() => {}}
735                   onTransferCommunity={() => {}}
736                   onPurgeComment={() => {}}
737                   onPurgePerson={() => {}}
738                   onCommentReplyRead={() => {}}
739                   onPersonMentionRead={() => {}}
740                   onBanPersonFromCommunity={() => {}}
741                   onBanPerson={() => {}}
742                   onCreateComment={() => Promise.resolve({ state: "empty" })}
743                   onEditComment={() => Promise.resolve({ state: "empty" })}
744                 />
745               )}
746               {i.type_ === "communities" && (
747                 <div>{communityListing(i.data as CommunityView)}</div>
748               )}
749               {i.type_ === "users" && (
750                 <div>{personListing(i.data as PersonView)}</div>
751               )}
752             </div>
753           </div>
754         ))}
755       </div>
756     );
757   }
758
759   get comments() {
760     const {
761       searchRes: searchResponse,
762       resolveObjectRes: resolveObjectResponse,
763       siteRes,
764     } = this.state;
765     const comments =
766       searchResponse.state === "success" ? searchResponse.data.comments : [];
767
768     if (
769       resolveObjectResponse.state === "success" &&
770       resolveObjectResponse.data.comment
771     ) {
772       comments.unshift(resolveObjectResponse.data.comment);
773     }
774
775     return (
776       <CommentNodes
777         nodes={commentsToFlatNodes(comments)}
778         viewType={CommentViewType.Flat}
779         viewOnly
780         locked
781         noIndent
782         enableDownvotes={enableDownvotes(siteRes)}
783         allLanguages={siteRes.all_languages}
784         siteLanguages={siteRes.discussion_languages}
785         // All of these are unused, since its viewonly
786         finished={new Map()}
787         onSaveComment={() => {}}
788         onBlockPerson={() => {}}
789         onDeleteComment={() => {}}
790         onRemoveComment={() => {}}
791         onCommentVote={() => {}}
792         onCommentReport={() => {}}
793         onDistinguishComment={() => {}}
794         onAddModToCommunity={() => {}}
795         onAddAdmin={() => {}}
796         onTransferCommunity={() => {}}
797         onPurgeComment={() => {}}
798         onPurgePerson={() => {}}
799         onCommentReplyRead={() => {}}
800         onPersonMentionRead={() => {}}
801         onBanPersonFromCommunity={() => {}}
802         onBanPerson={() => {}}
803         onCreateComment={() => Promise.resolve({ state: "empty" })}
804         onEditComment={() => Promise.resolve({ state: "empty" })}
805       />
806     );
807   }
808
809   get posts() {
810     const {
811       searchRes: searchResponse,
812       resolveObjectRes: resolveObjectResponse,
813       siteRes,
814     } = this.state;
815     const posts =
816       searchResponse.state === "success" ? searchResponse.data.posts : [];
817
818     if (
819       resolveObjectResponse.state === "success" &&
820       resolveObjectResponse.data.post
821     ) {
822       posts.unshift(resolveObjectResponse.data.post);
823     }
824
825     return (
826       <>
827         {posts.map(pv => (
828           <div key={pv.post.id} className="row">
829             <div className="col-12">
830               <PostListing
831                 post_view={pv}
832                 showCommunity
833                 enableDownvotes={enableDownvotes(siteRes)}
834                 enableNsfw={enableNsfw(siteRes)}
835                 allLanguages={siteRes.all_languages}
836                 siteLanguages={siteRes.discussion_languages}
837                 viewOnly
838                 // All of these are unused, since its view only
839                 onPostEdit={() => {}}
840                 onPostVote={() => {}}
841                 onPostReport={() => {}}
842                 onBlockPerson={() => {}}
843                 onLockPost={() => {}}
844                 onDeletePost={() => {}}
845                 onRemovePost={() => {}}
846                 onSavePost={() => {}}
847                 onFeaturePost={() => {}}
848                 onPurgePerson={() => {}}
849                 onPurgePost={() => {}}
850                 onBanPersonFromCommunity={() => {}}
851                 onBanPerson={() => {}}
852                 onAddModToCommunity={() => {}}
853                 onAddAdmin={() => {}}
854                 onTransferCommunity={() => {}}
855               />
856             </div>
857           </div>
858         ))}
859       </>
860     );
861   }
862
863   get communities() {
864     const {
865       searchRes: searchResponse,
866       resolveObjectRes: resolveObjectResponse,
867     } = this.state;
868     const communities =
869       searchResponse.state === "success" ? searchResponse.data.communities : [];
870
871     if (
872       resolveObjectResponse.state === "success" &&
873       resolveObjectResponse.data.community
874     ) {
875       communities.unshift(resolveObjectResponse.data.community);
876     }
877
878     return (
879       <>
880         {communities.map(cv => (
881           <div key={cv.community.id} className="row">
882             <div className="col-12">{communityListing(cv)}</div>
883           </div>
884         ))}
885       </>
886     );
887   }
888
889   get users() {
890     const {
891       searchRes: searchResponse,
892       resolveObjectRes: resolveObjectResponse,
893     } = this.state;
894     const users =
895       searchResponse.state === "success" ? searchResponse.data.users : [];
896
897     if (
898       resolveObjectResponse.state === "success" &&
899       resolveObjectResponse.data.person
900     ) {
901       users.unshift(resolveObjectResponse.data.person);
902     }
903
904     return (
905       <>
906         {users.map(pvs => (
907           <div key={pvs.person.id} className="row">
908             <div className="col-12">{personListing(pvs)}</div>
909           </div>
910         ))}
911       </>
912     );
913   }
914
915   get resultsCount(): number {
916     const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
917
918     const searchCount =
919       r.state === "success"
920         ? r.data.posts.length +
921           r.data.comments.length +
922           r.data.communities.length +
923           r.data.users.length
924         : 0;
925
926     const resObjCount =
927       resolveRes.state === "success"
928         ? resolveRes.data.post ||
929           resolveRes.data.person ||
930           resolveRes.data.community ||
931           resolveRes.data.comment
932           ? 1
933           : 0
934         : 0;
935
936     return resObjCount + searchCount;
937   }
938
939   async search() {
940     const auth = myAuth();
941     const { searchText: q } = this.state;
942     const { communityId, creatorId, type, sort, listingType, page } =
943       getSearchQueryParams();
944
945     if (q) {
946       this.setState({ searchRes: { state: "loading" } });
947       this.setState({
948         searchRes: await HttpService.client.search({
949           q,
950           community_id: communityId ?? undefined,
951           creator_id: creatorId ?? undefined,
952           type_: type,
953           sort,
954           listing_type: listingType,
955           page,
956           limit: fetchLimit,
957           auth,
958         }),
959       });
960       window.scrollTo(0, 0);
961       restoreScrollPosition(this.context);
962
963       if (auth) {
964         this.setState({ resolveObjectRes: { state: "loading" } });
965         this.setState({
966           resolveObjectRes: await HttpService.silent_client.resolveObject({
967             q,
968             auth,
969           }),
970         });
971       }
972     }
973   }
974
975   handleCreatorSearch = debounce(async (text: string) => {
976     const { creatorId } = getSearchQueryParams();
977     const { creatorSearchOptions } = this.state;
978     const newOptions: Choice[] = [];
979
980     this.setState({ searchCreatorLoading: true });
981
982     const selectedChoice = creatorSearchOptions.find(
983       choice => getIdFromString(choice.value) === creatorId
984     );
985
986     if (selectedChoice) {
987       newOptions.push(selectedChoice);
988     }
989
990     if (text.length > 0) {
991       newOptions.push(...(await fetchUsers(text)).map(personToChoice));
992     }
993
994     this.setState({
995       searchCreatorLoading: false,
996       creatorSearchOptions: newOptions,
997     });
998   });
999
1000   handleCommunitySearch = debounce(async (text: string) => {
1001     const { communityId } = getSearchQueryParams();
1002     const { communitySearchOptions } = this.state;
1003     this.setState({
1004       searchCommunitiesLoading: true,
1005     });
1006
1007     const newOptions: Choice[] = [];
1008
1009     const selectedChoice = communitySearchOptions.find(
1010       choice => getIdFromString(choice.value) === communityId
1011     );
1012
1013     if (selectedChoice) {
1014       newOptions.push(selectedChoice);
1015     }
1016
1017     if (text.length > 0) {
1018       newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
1019     }
1020
1021     this.setState({
1022       searchCommunitiesLoading: false,
1023       communitySearchOptions: newOptions,
1024     });
1025   });
1026
1027   handleSortChange(sort: SortType) {
1028     this.updateUrl({ sort, page: 1 });
1029   }
1030
1031   handleTypeChange(i: Search, event: any) {
1032     const type = event.target.value as SearchType;
1033
1034     i.updateUrl({
1035       type,
1036       page: 1,
1037     });
1038   }
1039
1040   handlePageChange(page: number) {
1041     this.updateUrl({ page });
1042   }
1043
1044   handleListingTypeChange(listingType: ListingType) {
1045     this.updateUrl({
1046       listingType,
1047       page: 1,
1048     });
1049   }
1050
1051   handleCommunityFilterChange({ value }: Choice) {
1052     this.updateUrl({
1053       communityId: getIdFromString(value) ?? null,
1054       page: 1,
1055     });
1056   }
1057
1058   handleCreatorFilterChange({ value }: Choice) {
1059     this.updateUrl({
1060       creatorId: getIdFromString(value) ?? null,
1061       page: 1,
1062     });
1063   }
1064
1065   handleSearchSubmit(i: Search, event: any) {
1066     event.preventDefault();
1067
1068     i.updateUrl({
1069       q: i.state.searchText,
1070       page: 1,
1071     });
1072   }
1073
1074   handleQChange(i: Search, event: any) {
1075     i.setState({ searchText: event.target.value });
1076   }
1077
1078   async updateUrl({
1079     q,
1080     type,
1081     listingType,
1082     sort,
1083     communityId,
1084     creatorId,
1085     page,
1086   }: Partial<SearchProps>) {
1087     const {
1088       q: urlQ,
1089       type: urlType,
1090       listingType: urlListingType,
1091       communityId: urlCommunityId,
1092       sort: urlSort,
1093       creatorId: urlCreatorId,
1094       page: urlPage,
1095     } = getSearchQueryParams();
1096
1097     let query = q ?? this.state.searchText ?? urlQ;
1098
1099     if (query && query.length > 0) {
1100       query = encodeURIComponent(query);
1101     }
1102
1103     const queryParams: QueryParams<SearchProps> = {
1104       q: query,
1105       type: type ?? urlType,
1106       listingType: listingType ?? urlListingType,
1107       communityId: getUpdatedSearchId(communityId, urlCommunityId),
1108       creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
1109       page: (page ?? urlPage).toString(),
1110       sort: sort ?? urlSort,
1111     };
1112
1113     this.props.history.push(`/search${getQueryString(queryParams)}`);
1114   }
1115 }