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