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