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