]> Untitled Git - lemmy-ui.git/blob - src/shared/components/search.tsx
Frontend Settings - "Blur NSFW" and "Auto Expand" (#1640)
[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                   onMarkPostAsRead={() => {}}
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                   isTopLevel
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         isTopLevel
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                 onMarkPostAsRead={() => {}}
861               />
862             </div>
863           </div>
864         ))}
865       </>
866     );
867   }
868
869   get communities() {
870     const {
871       searchRes: searchResponse,
872       resolveObjectRes: resolveObjectResponse,
873     } = this.state;
874     const communities =
875       searchResponse.state === "success" ? searchResponse.data.communities : [];
876
877     if (
878       resolveObjectResponse.state === "success" &&
879       resolveObjectResponse.data.community
880     ) {
881       communities.unshift(resolveObjectResponse.data.community);
882     }
883
884     return (
885       <>
886         {communities.map(cv => (
887           <div key={cv.community.id} className="row">
888             <div className="col-12">{communityListing(cv)}</div>
889           </div>
890         ))}
891       </>
892     );
893   }
894
895   get users() {
896     const {
897       searchRes: searchResponse,
898       resolveObjectRes: resolveObjectResponse,
899     } = this.state;
900     const users =
901       searchResponse.state === "success" ? searchResponse.data.users : [];
902
903     if (
904       resolveObjectResponse.state === "success" &&
905       resolveObjectResponse.data.person
906     ) {
907       users.unshift(resolveObjectResponse.data.person);
908     }
909
910     return (
911       <>
912         {users.map(pvs => (
913           <div key={pvs.person.id} className="row">
914             <div className="col-12">{personListing(pvs)}</div>
915           </div>
916         ))}
917       </>
918     );
919   }
920
921   get resultsCount(): number {
922     const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
923
924     const searchCount =
925       r.state === "success"
926         ? r.data.posts.length +
927           r.data.comments.length +
928           r.data.communities.length +
929           r.data.users.length
930         : 0;
931
932     const resObjCount =
933       resolveRes.state === "success"
934         ? resolveRes.data.post ||
935           resolveRes.data.person ||
936           resolveRes.data.community ||
937           resolveRes.data.comment
938           ? 1
939           : 0
940         : 0;
941
942     return resObjCount + searchCount;
943   }
944
945   async search() {
946     const auth = myAuth();
947     const { searchText: q } = this.state;
948     const { communityId, creatorId, type, sort, listingType, page } =
949       getSearchQueryParams();
950
951     if (q) {
952       this.setState({ searchRes: { state: "loading" } });
953       this.setState({
954         searchRes: await HttpService.client.search({
955           q,
956           community_id: communityId ?? undefined,
957           creator_id: creatorId ?? undefined,
958           type_: type,
959           sort,
960           listing_type: listingType,
961           page,
962           limit: fetchLimit,
963           auth,
964         }),
965       });
966       window.scrollTo(0, 0);
967       restoreScrollPosition(this.context);
968
969       if (auth) {
970         this.setState({ resolveObjectRes: { state: "loading" } });
971         this.setState({
972           resolveObjectRes: await HttpService.silent_client.resolveObject({
973             q,
974             auth,
975           }),
976         });
977       }
978     }
979   }
980
981   handleCreatorSearch = debounce(async (text: string) => {
982     const { creatorId } = getSearchQueryParams();
983     const { creatorSearchOptions } = this.state;
984     const newOptions: Choice[] = [];
985
986     this.setState({ searchCreatorLoading: true });
987
988     const selectedChoice = creatorSearchOptions.find(
989       choice => getIdFromString(choice.value) === creatorId,
990     );
991
992     if (selectedChoice) {
993       newOptions.push(selectedChoice);
994     }
995
996     if (text.length > 0) {
997       newOptions.push(...(await fetchUsers(text)).map(personToChoice));
998     }
999
1000     this.setState({
1001       searchCreatorLoading: false,
1002       creatorSearchOptions: newOptions,
1003     });
1004   });
1005
1006   handleCommunitySearch = debounce(async (text: string) => {
1007     const { communityId } = getSearchQueryParams();
1008     const { communitySearchOptions } = this.state;
1009     this.setState({
1010       searchCommunitiesLoading: true,
1011     });
1012
1013     const newOptions: Choice[] = [];
1014
1015     const selectedChoice = communitySearchOptions.find(
1016       choice => getIdFromString(choice.value) === communityId,
1017     );
1018
1019     if (selectedChoice) {
1020       newOptions.push(selectedChoice);
1021     }
1022
1023     if (text.length > 0) {
1024       newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
1025     }
1026
1027     this.setState({
1028       searchCommunitiesLoading: false,
1029       communitySearchOptions: newOptions,
1030     });
1031   });
1032
1033   handleSortChange(sort: SortType) {
1034     this.updateUrl({ sort, page: 1 });
1035   }
1036
1037   handleTypeChange(i: Search, event: any) {
1038     const type = event.target.value as SearchType;
1039
1040     i.updateUrl({
1041       type,
1042       page: 1,
1043     });
1044   }
1045
1046   handlePageChange(page: number) {
1047     this.updateUrl({ page });
1048   }
1049
1050   handleListingTypeChange(listingType: ListingType) {
1051     this.updateUrl({
1052       listingType,
1053       page: 1,
1054     });
1055   }
1056
1057   handleCommunityFilterChange({ value }: Choice) {
1058     this.updateUrl({
1059       communityId: getIdFromString(value) ?? null,
1060       page: 1,
1061     });
1062   }
1063
1064   handleCreatorFilterChange({ value }: Choice) {
1065     this.updateUrl({
1066       creatorId: getIdFromString(value) ?? null,
1067       page: 1,
1068     });
1069   }
1070
1071   handleSearchSubmit(i: Search, event: any) {
1072     event.preventDefault();
1073
1074     i.updateUrl({
1075       q: i.state.searchText,
1076       page: 1,
1077     });
1078   }
1079
1080   handleQChange(i: Search, event: any) {
1081     i.setState({ searchText: event.target.value });
1082   }
1083
1084   async updateUrl({
1085     q,
1086     type,
1087     listingType,
1088     sort,
1089     communityId,
1090     creatorId,
1091     page,
1092   }: Partial<SearchProps>) {
1093     const {
1094       q: urlQ,
1095       type: urlType,
1096       listingType: urlListingType,
1097       communityId: urlCommunityId,
1098       sort: urlSort,
1099       creatorId: urlCreatorId,
1100       page: urlPage,
1101     } = getSearchQueryParams();
1102
1103     let query = q ?? this.state.searchText ?? urlQ;
1104
1105     if (query && query.length > 0) {
1106       query = encodeURIComponent(query);
1107     }
1108
1109     const queryParams: QueryParams<SearchProps> = {
1110       q: query,
1111       type: type ?? urlType,
1112       listingType: listingType ?? urlListingType,
1113       communityId: getUpdatedSearchId(communityId, urlCommunityId),
1114       creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
1115       page: (page ?? urlPage).toString(),
1116       sort: sort ?? urlSort,
1117     };
1118
1119     this.props.history.push(`/search${getQueryString(queryParams)}`);
1120   }
1121 }