]> Untitled Git - lemmy-ui.git/blob - src/shared/components/search.tsx
Fixing an issue with federated search names
[lemmy-ui.git] / src / shared / components / search.tsx
1 import { Component, linkEvent } from "inferno";
2 import { Subscription } from "rxjs";
3 import {
4   UserOperation,
5   PostView,
6   CommentView,
7   CommunityView,
8   PersonViewSafe,
9   SortType,
10   Search as SearchForm,
11   SearchResponse,
12   SearchType,
13   PostResponse,
14   CommentResponse,
15   Site,
16   ListingType,
17   ListCommunities,
18   ListCommunitiesResponse,
19   GetCommunity,
20   GetPersonDetails,
21 } from "lemmy-js-client";
22 import { WebSocketService } from "../services";
23 import {
24   wsJsonToRes,
25   fetchLimit,
26   routeSearchTypeToEnum,
27   routeSortTypeToEnum,
28   toast,
29   createCommentLikeRes,
30   createPostLikeFindRes,
31   commentsToFlatNodes,
32   setIsoData,
33   wsSubscribe,
34   wsUserOp,
35   wsClient,
36   authField,
37   setOptionalAuth,
38   saveScrollPosition,
39   restoreScrollPosition,
40   routeListingTypeToEnum,
41   showLocal,
42   isBrowser,
43   choicesConfig,
44   debounce,
45   fetchCommunities,
46   communityToChoice,
47   fetchUsers,
48   personToChoice,
49   capitalizeFirstLetter,
50   communitySelectName,
51   personSelectName,
52 } from "../utils";
53 import { PostListing } from "./post-listing";
54 import { HtmlTags } from "./html-tags";
55 import { Spinner } from "./icon";
56 import { PersonListing } from "./person-listing";
57 import { CommunityLink } from "./community-link";
58 import { SortSelect } from "./sort-select";
59 import { ListingTypeSelect } from "./listing-type-select";
60 import { CommentNodes } from "./comment-nodes";
61 import { i18n } from "../i18next";
62 import { InitialFetchRequest } from "shared/interfaces";
63
64 var Choices;
65 if (isBrowser()) {
66   Choices = require("choices.js");
67 }
68
69 interface SearchProps {
70   q: string;
71   type_: SearchType;
72   sort: SortType;
73   listingType: ListingType;
74   communityId: number;
75   creatorId: number;
76   page: number;
77 }
78
79 interface SearchState {
80   q: string;
81   type_: SearchType;
82   sort: SortType;
83   listingType: ListingType;
84   communityId: number;
85   creatorId: number;
86   page: number;
87   searchResponse: SearchResponse;
88   communities: CommunityView[];
89   creator?: PersonViewSafe;
90   loading: boolean;
91   site: Site;
92   searchText: string;
93 }
94
95 interface UrlParams {
96   q?: string;
97   type_?: SearchType;
98   sort?: SortType;
99   listingType?: ListingType;
100   communityId?: number;
101   creatorId?: number;
102   page?: number;
103 }
104
105 export class Search extends Component<any, SearchState> {
106   private isoData = setIsoData(this.context);
107   private communityChoices: any;
108   private creatorChoices: any;
109   private subscription: Subscription;
110   private emptyState: SearchState = {
111     q: Search.getSearchQueryFromProps(this.props.match.params.q),
112     type_: Search.getSearchTypeFromProps(this.props.match.params.type),
113     sort: Search.getSortTypeFromProps(this.props.match.params.sort),
114     listingType: Search.getListingTypeFromProps(
115       this.props.match.params.listing_type
116     ),
117     page: Search.getPageFromProps(this.props.match.params.page),
118     searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
119     communityId: Search.getCommunityIdFromProps(
120       this.props.match.params.community_id
121     ),
122     creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id),
123     searchResponse: {
124       type_: null,
125       posts: [],
126       comments: [],
127       communities: [],
128       users: [],
129     },
130     loading: true,
131     site: this.isoData.site_res.site_view.site,
132     communities: [],
133   };
134
135   static getSearchQueryFromProps(q: string): string {
136     return decodeURIComponent(q) || "";
137   }
138
139   static getSearchTypeFromProps(type_: string): SearchType {
140     return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
141   }
142
143   static getSortTypeFromProps(sort: string): SortType {
144     return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
145   }
146
147   static getListingTypeFromProps(listingType: string): ListingType {
148     return listingType ? routeListingTypeToEnum(listingType) : ListingType.All;
149   }
150
151   static getCommunityIdFromProps(id: string): number {
152     return id ? Number(id) : 0;
153   }
154
155   static getCreatorIdFromProps(id: string): number {
156     return id ? Number(id) : 0;
157   }
158
159   static getPageFromProps(page: string): number {
160     return page ? Number(page) : 1;
161   }
162
163   constructor(props: any, context: any) {
164     super(props, context);
165
166     this.state = this.emptyState;
167     this.handleSortChange = this.handleSortChange.bind(this);
168     this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
169
170     this.parseMessage = this.parseMessage.bind(this);
171     this.subscription = wsSubscribe(this.parseMessage);
172
173     // Only fetch the data if coming from another route
174     if (this.isoData.path == this.context.router.route.match.url) {
175       let singleOrMultipleCommunities = this.isoData.routeData[0];
176       if (singleOrMultipleCommunities.communities) {
177         this.state.communities = this.isoData.routeData[0].communities;
178       } else {
179         this.state.communities = [this.isoData.routeData[0].community_view];
180       }
181
182       let creator = this.isoData.routeData[1];
183       if (creator?.person_view) {
184         this.state.creator = this.isoData.routeData[1].person_view;
185       }
186       if (this.state.q != "") {
187         this.state.searchResponse = this.isoData.routeData[2];
188         this.state.loading = false;
189       } else {
190         this.search();
191       }
192     } else {
193       this.fetchCommunities();
194       this.search();
195     }
196   }
197
198   componentWillUnmount() {
199     this.subscription.unsubscribe();
200     saveScrollPosition(this.context);
201   }
202
203   componentDidMount() {
204     this.setupCommunityFilter();
205     this.setupCreatorFilter();
206   }
207
208   static getDerivedStateFromProps(props: any): SearchProps {
209     return {
210       q: Search.getSearchQueryFromProps(props.match.params.q),
211       type_: Search.getSearchTypeFromProps(props.match.params.type),
212       sort: Search.getSortTypeFromProps(props.match.params.sort),
213       listingType: Search.getListingTypeFromProps(
214         props.match.params.listing_type
215       ),
216       communityId: Search.getCommunityIdFromProps(
217         props.match.params.community_id
218       ),
219       creatorId: Search.getCreatorIdFromProps(props.match.params.creator_id),
220       page: Search.getPageFromProps(props.match.params.page),
221     };
222   }
223
224   fetchCommunities() {
225     let listCommunitiesForm: ListCommunities = {
226       type_: ListingType.All,
227       sort: SortType.TopAll,
228       limit: fetchLimit,
229       auth: authField(false),
230     };
231     WebSocketService.Instance.send(
232       wsClient.listCommunities(listCommunitiesForm)
233     );
234   }
235
236   static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
237     let pathSplit = req.path.split("/");
238     let promises: Promise<any>[] = [];
239
240     let communityId = this.getCommunityIdFromProps(pathSplit[11]);
241     if (communityId !== 0) {
242       let getCommunityForm: GetCommunity = {
243         id: communityId,
244       };
245       setOptionalAuth(getCommunityForm, req.auth);
246       promises.push(req.client.getCommunity(getCommunityForm));
247     } else {
248       let listCommunitiesForm: ListCommunities = {
249         type_: ListingType.All,
250         sort: SortType.TopAll,
251         limit: fetchLimit,
252       };
253       setOptionalAuth(listCommunitiesForm, req.auth);
254       promises.push(req.client.listCommunities(listCommunitiesForm));
255     }
256
257     let creatorId = this.getCreatorIdFromProps(pathSplit[13]);
258     if (creatorId !== 0) {
259       let getCreatorForm: GetPersonDetails = {
260         person_id: creatorId,
261       };
262       setOptionalAuth(getCreatorForm, req.auth);
263       promises.push(req.client.getPersonDetails(getCreatorForm));
264     } else {
265       promises.push(Promise.resolve());
266     }
267
268     let form: SearchForm = {
269       q: this.getSearchQueryFromProps(pathSplit[3]),
270       type_: this.getSearchTypeFromProps(pathSplit[5]),
271       sort: this.getSortTypeFromProps(pathSplit[7]),
272       listing_type: this.getListingTypeFromProps(pathSplit[9]),
273       page: this.getPageFromProps(pathSplit[15]),
274       limit: fetchLimit,
275     };
276     if (communityId !== 0) {
277       form.community_id = communityId;
278     }
279     if (creatorId !== 0) {
280       form.creator_id = creatorId;
281     }
282     setOptionalAuth(form, req.auth);
283
284     if (form.q != "") {
285       promises.push(req.client.search(form));
286     }
287
288     return promises;
289   }
290
291   componentDidUpdate(_: any, lastState: SearchState) {
292     if (
293       lastState.q !== this.state.q ||
294       lastState.type_ !== this.state.type_ ||
295       lastState.sort !== this.state.sort ||
296       lastState.listingType !== this.state.listingType ||
297       lastState.communityId !== this.state.communityId ||
298       lastState.creatorId !== this.state.creatorId ||
299       lastState.page !== this.state.page
300     ) {
301       this.setState({ loading: true, searchText: this.state.q });
302       this.search();
303     }
304   }
305
306   get documentTitle(): string {
307     if (this.state.q) {
308       return `${i18n.t("search")} - ${this.state.q} - ${this.state.site.name}`;
309     } else {
310       return `${i18n.t("search")} - ${this.state.site.name}`;
311     }
312   }
313
314   render() {
315     return (
316       <div class="container">
317         <HtmlTags
318           title={this.documentTitle}
319           path={this.context.router.route.match.url}
320         />
321         <h5>{i18n.t("search")}</h5>
322         {this.selects()}
323         {this.searchForm()}
324         {this.state.type_ == SearchType.All && this.all()}
325         {this.state.type_ == SearchType.Comments && this.comments()}
326         {this.state.type_ == SearchType.Posts && this.posts()}
327         {this.state.type_ == SearchType.Communities && this.communities()}
328         {this.state.type_ == SearchType.Users && this.users()}
329         {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
330         {this.paginator()}
331       </div>
332     );
333   }
334
335   searchForm() {
336     return (
337       <form
338         class="form-inline"
339         onSubmit={linkEvent(this, this.handleSearchSubmit)}
340       >
341         <input
342           type="text"
343           class="form-control mr-2 mb-2"
344           value={this.state.searchText}
345           placeholder={`${i18n.t("search")}...`}
346           aria-label={i18n.t("search")}
347           onInput={linkEvent(this, this.handleQChange)}
348           required
349           minLength={3}
350         />
351         <button type="submit" class="btn btn-secondary mr-2 mb-2">
352           {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
353         </button>
354       </form>
355     );
356   }
357
358   selects() {
359     return (
360       <div className="mb-2">
361         <select
362           value={this.state.type_}
363           onChange={linkEvent(this, this.handleTypeChange)}
364           class="custom-select w-auto mb-2"
365           aria-label={i18n.t("type")}
366         >
367           <option disabled aria-hidden="true">
368             {i18n.t("type")}
369           </option>
370           <option value={SearchType.All}>{i18n.t("all")}</option>
371           <option value={SearchType.Comments}>{i18n.t("comments")}</option>
372           <option value={SearchType.Posts}>{i18n.t("posts")}</option>
373           <option value={SearchType.Communities}>
374             {i18n.t("communities")}
375           </option>
376           <option value={SearchType.Users}>{i18n.t("users")}</option>
377         </select>
378         <span class="ml-2">
379           <ListingTypeSelect
380             type_={this.state.listingType}
381             showLocal={showLocal(this.isoData)}
382             onChange={this.handleListingTypeChange}
383           />
384         </span>
385         <span class="ml-2">
386           <SortSelect
387             sort={this.state.sort}
388             onChange={this.handleSortChange}
389             hideHot
390             hideMostComments
391           />
392         </span>
393         <div class="form-row">
394           {this.state.communities.length > 0 && this.communityFilter()}
395           {this.creatorFilter()}
396         </div>
397       </div>
398     );
399   }
400
401   all() {
402     let combined: {
403       type_: string;
404       data: CommentView | PostView | CommunityView | PersonViewSafe;
405       published: string;
406     }[] = [];
407     let comments = this.state.searchResponse.comments.map(e => {
408       return { type_: "comments", data: e, published: e.comment.published };
409     });
410     let posts = this.state.searchResponse.posts.map(e => {
411       return { type_: "posts", data: e, published: e.post.published };
412     });
413     let communities = this.state.searchResponse.communities.map(e => {
414       return {
415         type_: "communities",
416         data: e,
417         published: e.community.published,
418       };
419     });
420     let users = this.state.searchResponse.users.map(e => {
421       return { type_: "users", data: e, published: e.person.published };
422     });
423
424     combined.push(...comments);
425     combined.push(...posts);
426     combined.push(...communities);
427     combined.push(...users);
428
429     // Sort it
430     if (this.state.sort == SortType.New) {
431       combined.sort((a, b) => b.published.localeCompare(a.published));
432     } else {
433       combined.sort(
434         (a, b) =>
435           ((b.data as CommentView | PostView).counts.score |
436             (b.data as CommunityView).counts.subscribers |
437             (b.data as PersonViewSafe).counts.comment_score) -
438           ((a.data as CommentView | PostView).counts.score |
439             (a.data as CommunityView).counts.subscribers |
440             (a.data as PersonViewSafe).counts.comment_score)
441       );
442     }
443
444     return (
445       <div>
446         {combined.map(i => (
447           <div class="row">
448             <div class="col-12">
449               {i.type_ == "posts" && (
450                 <PostListing
451                   key={(i.data as PostView).post.id}
452                   post_view={i.data as PostView}
453                   showCommunity
454                   enableDownvotes={this.state.site.enable_downvotes}
455                   enableNsfw={this.state.site.enable_nsfw}
456                 />
457               )}
458               {i.type_ == "comments" && (
459                 <CommentNodes
460                   key={(i.data as CommentView).comment.id}
461                   nodes={[{ comment_view: i.data as CommentView }]}
462                   locked
463                   noIndent
464                   enableDownvotes={this.state.site.enable_downvotes}
465                 />
466               )}
467               {i.type_ == "communities" && (
468                 <div>{this.communityListing(i.data as CommunityView)}</div>
469               )}
470               {i.type_ == "users" && (
471                 <div>{this.userListing(i.data as PersonViewSafe)}</div>
472               )}
473             </div>
474           </div>
475         ))}
476       </div>
477     );
478   }
479
480   comments() {
481     return (
482       <CommentNodes
483         nodes={commentsToFlatNodes(this.state.searchResponse.comments)}
484         locked
485         noIndent
486         enableDownvotes={this.state.site.enable_downvotes}
487       />
488     );
489   }
490
491   posts() {
492     return (
493       <>
494         {this.state.searchResponse.posts.map(post => (
495           <div class="row">
496             <div class="col-12">
497               <PostListing
498                 post_view={post}
499                 showCommunity
500                 enableDownvotes={this.state.site.enable_downvotes}
501                 enableNsfw={this.state.site.enable_nsfw}
502               />
503             </div>
504           </div>
505         ))}
506       </>
507     );
508   }
509
510   communities() {
511     return (
512       <>
513         {this.state.searchResponse.communities.map(community => (
514           <div class="row">
515             <div class="col-12">{this.communityListing(community)}</div>
516           </div>
517         ))}
518       </>
519     );
520   }
521
522   communityListing(community_view: CommunityView) {
523     return (
524       <>
525         <span>
526           <CommunityLink community={community_view.community} />
527         </span>
528         <span>{` -
529         ${i18n.t("number_of_subscribers", {
530           count: community_view.counts.subscribers,
531         })}
532       `}</span>
533       </>
534     );
535   }
536
537   userListing(person_view: PersonViewSafe) {
538     return [
539       <span>
540         <PersonListing person={person_view.person} showApubName />
541       </span>,
542       <span>{` - ${i18n.t("number_of_comments", {
543         count: person_view.counts.comment_count,
544       })}`}</span>,
545     ];
546   }
547
548   users() {
549     return (
550       <>
551         {this.state.searchResponse.users.map(user => (
552           <div class="row">
553             <div class="col-12">{this.userListing(user)}</div>
554           </div>
555         ))}
556       </>
557     );
558   }
559
560   communityFilter() {
561     return (
562       <div class="form-group col-sm-6">
563         <label class="col-form-label" htmlFor="community-filter">
564           {i18n.t("community")}
565         </label>
566         <div>
567           <select
568             class="form-control"
569             id="community-filter"
570             value={this.state.communityId}
571           >
572             <option value="0">{i18n.t("all")}</option>
573             {this.state.communities.map(cv => (
574               <option value={cv.community.id}>{communitySelectName(cv)}</option>
575             ))}
576           </select>
577         </div>
578       </div>
579     );
580   }
581
582   creatorFilter() {
583     return (
584       <div class="form-group col-sm-6">
585         <label class="col-form-label" htmlFor="creator-filter">
586           {capitalizeFirstLetter(i18n.t("creator"))}
587         </label>
588         <div>
589           <select
590             class="form-control"
591             id="creator-filter"
592             value={this.state.creatorId}
593           >
594             <option value="0">{i18n.t("all")}</option>
595             {this.state.creator && (
596               <option value={this.state.creator.person.id}>
597                 {personSelectName(this.state.creator)}
598               </option>
599             )}
600           </select>
601         </div>
602       </div>
603     );
604   }
605
606   paginator() {
607     return (
608       <div class="mt-2">
609         {this.state.page > 1 && (
610           <button
611             class="btn btn-secondary mr-1"
612             onClick={linkEvent(this, this.prevPage)}
613           >
614             {i18n.t("prev")}
615           </button>
616         )}
617
618         {this.resultsCount() > 0 && (
619           <button
620             class="btn btn-secondary"
621             onClick={linkEvent(this, this.nextPage)}
622           >
623             {i18n.t("next")}
624           </button>
625         )}
626       </div>
627     );
628   }
629
630   resultsCount(): number {
631     let res = this.state.searchResponse;
632     return (
633       res.posts.length +
634       res.comments.length +
635       res.communities.length +
636       res.users.length
637     );
638   }
639
640   nextPage(i: Search) {
641     i.updateUrl({ page: i.state.page + 1 });
642   }
643
644   prevPage(i: Search) {
645     i.updateUrl({ page: i.state.page - 1 });
646   }
647
648   search() {
649     let form: SearchForm = {
650       q: this.state.q,
651       type_: this.state.type_,
652       sort: this.state.sort,
653       listing_type: this.state.listingType,
654       page: this.state.page,
655       limit: fetchLimit,
656       auth: authField(false),
657     };
658     if (this.state.communityId !== 0) {
659       form.community_id = this.state.communityId;
660     }
661     if (this.state.creatorId !== 0) {
662       form.creator_id = this.state.creatorId;
663     }
664
665     if (this.state.q != "") {
666       WebSocketService.Instance.send(wsClient.search(form));
667     }
668   }
669
670   setupCommunityFilter() {
671     if (isBrowser()) {
672       let selectId: any = document.getElementById("community-filter");
673       if (selectId) {
674         this.communityChoices = new Choices(selectId, choicesConfig);
675         this.communityChoices.passedElement.element.addEventListener(
676           "choice",
677           (e: any) => {
678             this.handleCommunityFilterChange(Number(e.detail.choice.value));
679           },
680           false
681         );
682         this.communityChoices.passedElement.element.addEventListener(
683           "search",
684           debounce(async (e: any) => {
685             let communities = (await fetchCommunities(e.detail.value))
686               .communities;
687             let choices = communities.map(cv => communityToChoice(cv));
688             choices.unshift({ value: "0", label: i18n.t("all") });
689             this.communityChoices.setChoices(choices, "value", "label", true);
690           }, 400),
691           false
692         );
693       }
694     }
695   }
696
697   setupCreatorFilter() {
698     if (isBrowser()) {
699       let selectId: any = document.getElementById("creator-filter");
700       if (selectId) {
701         this.creatorChoices = new Choices(selectId, choicesConfig);
702         this.creatorChoices.passedElement.element.addEventListener(
703           "choice",
704           (e: any) => {
705             this.handleCreatorFilterChange(Number(e.detail.choice.value));
706           },
707           false
708         );
709         this.creatorChoices.passedElement.element.addEventListener(
710           "search",
711           debounce(async (e: any) => {
712             let creators = (await fetchUsers(e.detail.value)).users;
713             let choices = creators.map(pvs => personToChoice(pvs));
714             choices.unshift({ value: "0", label: i18n.t("all") });
715             this.creatorChoices.setChoices(choices, "value", "label", true);
716           }, 400),
717           false
718         );
719       }
720     }
721   }
722
723   handleSortChange(val: SortType) {
724     this.updateUrl({ sort: val, page: 1 });
725   }
726
727   handleTypeChange(i: Search, event: any) {
728     i.updateUrl({
729       type_: SearchType[event.target.value],
730       page: 1,
731     });
732   }
733
734   handleListingTypeChange(val: ListingType) {
735     this.updateUrl({
736       listingType: val,
737       page: 1,
738     });
739   }
740
741   handleCommunityFilterChange(communityId: number) {
742     this.updateUrl({
743       communityId,
744       page: 1,
745     });
746   }
747
748   handleCreatorFilterChange(creatorId: number) {
749     this.updateUrl({
750       creatorId,
751       page: 1,
752     });
753   }
754
755   handleSearchSubmit(i: Search, event: any) {
756     event.preventDefault();
757     i.updateUrl({
758       q: i.state.searchText,
759       type_: i.state.type_,
760       listingType: i.state.listingType,
761       communityId: i.state.communityId,
762       creatorId: i.state.creatorId,
763       sort: i.state.sort,
764       page: i.state.page,
765     });
766   }
767
768   handleQChange(i: Search, event: any) {
769     i.setState({ searchText: event.target.value });
770   }
771
772   updateUrl(paramUpdates: UrlParams) {
773     const qStr = paramUpdates.q || this.state.q;
774     const qStrEncoded = encodeURIComponent(qStr);
775     const typeStr = paramUpdates.type_ || this.state.type_;
776     const listingTypeStr = paramUpdates.listingType || this.state.listingType;
777     const sortStr = paramUpdates.sort || this.state.sort;
778     const communityId =
779       paramUpdates.communityId == 0
780         ? 0
781         : paramUpdates.communityId || this.state.communityId;
782     const creatorId =
783       paramUpdates.creatorId == 0
784         ? 0
785         : paramUpdates.creatorId || this.state.creatorId;
786     const page = paramUpdates.page || this.state.page;
787     this.props.history.push(
788       `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/listing_type/${listingTypeStr}/community_id/${communityId}/creator_id/${creatorId}/page/${page}`
789     );
790   }
791
792   parseMessage(msg: any) {
793     console.log(msg);
794     let op = wsUserOp(msg);
795     if (msg.error) {
796       toast(i18n.t(msg.error), "danger");
797       return;
798     } else if (op == UserOperation.Search) {
799       let data = wsJsonToRes<SearchResponse>(msg).data;
800       this.state.searchResponse = data;
801       this.state.loading = false;
802       window.scrollTo(0, 0);
803       this.setState(this.state);
804       restoreScrollPosition(this.context);
805     } else if (op == UserOperation.CreateCommentLike) {
806       let data = wsJsonToRes<CommentResponse>(msg).data;
807       createCommentLikeRes(
808         data.comment_view,
809         this.state.searchResponse.comments
810       );
811       this.setState(this.state);
812     } else if (op == UserOperation.CreatePostLike) {
813       let data = wsJsonToRes<PostResponse>(msg).data;
814       createPostLikeFindRes(data.post_view, this.state.searchResponse.posts);
815       this.setState(this.state);
816     } else if (op == UserOperation.ListCommunities) {
817       let data = wsJsonToRes<ListCommunitiesResponse>(msg).data;
818       this.state.communities = data.communities;
819       this.setState(this.state);
820       this.setupCommunityFilter();
821     }
822   }
823 }