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