]> Untitled Git - lemmy-ui.git/blob - src/shared/components/search.tsx
Merge branch 'main' into add_password_length
[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 } from "lemmy-js-client";
17 import { WebSocketService } from "../services";
18 import {
19   wsJsonToRes,
20   fetchLimit,
21   routeSearchTypeToEnum,
22   routeSortTypeToEnum,
23   toast,
24   createCommentLikeRes,
25   createPostLikeFindRes,
26   commentsToFlatNodes,
27   setIsoData,
28   wsSubscribe,
29   wsUserOp,
30   wsClient,
31   authField,
32   setOptionalAuth,
33   saveScrollPosition,
34   restoreScrollPosition,
35 } from "../utils";
36 import { PostListing } from "./post-listing";
37 import { HtmlTags } from "./html-tags";
38 import { Spinner } from "./icon";
39 import { PersonListing } from "./person-listing";
40 import { CommunityLink } from "./community-link";
41 import { SortSelect } from "./sort-select";
42 import { CommentNodes } from "./comment-nodes";
43 import { i18n } from "../i18next";
44 import { InitialFetchRequest } from "shared/interfaces";
45
46 interface SearchProps {
47   q: string;
48   type_: SearchType;
49   sort: SortType;
50   page: number;
51 }
52
53 interface SearchState {
54   q: string;
55   type_: SearchType;
56   sort: SortType;
57   page: number;
58   searchResponse: SearchResponse;
59   loading: boolean;
60   site: Site;
61   searchText: string;
62 }
63
64 interface UrlParams {
65   q?: string;
66   type_?: SearchType;
67   sort?: SortType;
68   page?: number;
69 }
70
71 export class Search extends Component<any, SearchState> {
72   private isoData = setIsoData(this.context);
73   private subscription: Subscription;
74   private emptyState: SearchState = {
75     q: Search.getSearchQueryFromProps(this.props.match.params.q),
76     type_: Search.getSearchTypeFromProps(this.props.match.params.type),
77     sort: Search.getSortTypeFromProps(this.props.match.params.sort),
78     page: Search.getPageFromProps(this.props.match.params.page),
79     searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
80     searchResponse: {
81       type_: null,
82       posts: [],
83       comments: [],
84       communities: [],
85       users: [],
86     },
87     loading: false,
88     site: this.isoData.site_res.site_view.site,
89   };
90
91   static getSearchQueryFromProps(q: string): string {
92     return decodeURIComponent(q) || "";
93   }
94
95   static getSearchTypeFromProps(type_: string): SearchType {
96     return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
97   }
98
99   static getSortTypeFromProps(sort: string): SortType {
100     return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
101   }
102
103   static getPageFromProps(page: string): number {
104     return page ? Number(page) : 1;
105   }
106
107   constructor(props: any, context: any) {
108     super(props, context);
109
110     this.state = this.emptyState;
111     this.handleSortChange = this.handleSortChange.bind(this);
112
113     this.parseMessage = this.parseMessage.bind(this);
114     this.subscription = wsSubscribe(this.parseMessage);
115
116     // Only fetch the data if coming from another route
117     if (this.state.q != "") {
118       if (this.isoData.path == this.context.router.route.match.url) {
119         this.state.searchResponse = this.isoData.routeData[0];
120         this.state.loading = false;
121       } else {
122         this.search();
123       }
124     }
125   }
126
127   componentWillUnmount() {
128     this.subscription.unsubscribe();
129     saveScrollPosition(this.context);
130   }
131
132   static getDerivedStateFromProps(props: any): SearchProps {
133     return {
134       q: Search.getSearchQueryFromProps(props.match.params.q),
135       type_: Search.getSearchTypeFromProps(props.match.params.type),
136       sort: Search.getSortTypeFromProps(props.match.params.sort),
137       page: Search.getPageFromProps(props.match.params.page),
138     };
139   }
140
141   static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
142     let pathSplit = req.path.split("/");
143     let promises: Promise<any>[] = [];
144
145     let form: SearchForm = {
146       q: this.getSearchQueryFromProps(pathSplit[3]),
147       type_: this.getSearchTypeFromProps(pathSplit[5]),
148       sort: this.getSortTypeFromProps(pathSplit[7]),
149       page: this.getPageFromProps(pathSplit[9]),
150       limit: fetchLimit,
151     };
152     setOptionalAuth(form, req.auth);
153
154     if (form.q != "") {
155       promises.push(req.client.search(form));
156     }
157
158     return promises;
159   }
160
161   componentDidUpdate(_: any, lastState: SearchState) {
162     if (
163       lastState.q !== this.state.q ||
164       lastState.type_ !== this.state.type_ ||
165       lastState.sort !== this.state.sort ||
166       lastState.page !== this.state.page
167     ) {
168       this.setState({ loading: true, searchText: this.state.q });
169       this.search();
170     }
171   }
172
173   get documentTitle(): string {
174     if (this.state.q) {
175       return `${i18n.t("search")} - ${this.state.q} - ${this.state.site.name}`;
176     } else {
177       return `${i18n.t("search")} - ${this.state.site.name}`;
178     }
179   }
180
181   render() {
182     return (
183       <div class="container">
184         <HtmlTags
185           title={this.documentTitle}
186           path={this.context.router.route.match.url}
187         />
188         <h5>{i18n.t("search")}</h5>
189         {this.selects()}
190         {this.searchForm()}
191         {this.state.type_ == SearchType.All && this.all()}
192         {this.state.type_ == SearchType.Comments && this.comments()}
193         {this.state.type_ == SearchType.Posts && this.posts()}
194         {this.state.type_ == SearchType.Communities && this.communities()}
195         {this.state.type_ == SearchType.Users && this.users()}
196         {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
197         {this.paginator()}
198       </div>
199     );
200   }
201
202   searchForm() {
203     return (
204       <form
205         class="form-inline"
206         onSubmit={linkEvent(this, this.handleSearchSubmit)}
207       >
208         <input
209           type="text"
210           class="form-control mr-2 mb-2"
211           value={this.state.searchText}
212           placeholder={`${i18n.t("search")}...`}
213           aria-label={i18n.t("search")}
214           onInput={linkEvent(this, this.handleQChange)}
215           required
216           minLength={3}
217         />
218         <button type="submit" class="btn btn-secondary mr-2 mb-2">
219           {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
220         </button>
221       </form>
222     );
223   }
224
225   selects() {
226     return (
227       <div className="mb-2">
228         <select
229           value={this.state.type_}
230           onChange={linkEvent(this, this.handleTypeChange)}
231           class="custom-select w-auto mb-2"
232           aria-label={i18n.t("type")}
233         >
234           <option disabled aria-hidden="true">
235             {i18n.t("type")}
236           </option>
237           <option value={SearchType.All}>{i18n.t("all")}</option>
238           <option value={SearchType.Comments}>{i18n.t("comments")}</option>
239           <option value={SearchType.Posts}>{i18n.t("posts")}</option>
240           <option value={SearchType.Communities}>
241             {i18n.t("communities")}
242           </option>
243           <option value={SearchType.Users}>{i18n.t("users")}</option>
244         </select>
245         <span class="ml-2">
246           <SortSelect
247             sort={this.state.sort}
248             onChange={this.handleSortChange}
249             hideHot
250             hideMostComments
251           />
252         </span>
253       </div>
254     );
255   }
256
257   all() {
258     let combined: {
259       type_: string;
260       data: CommentView | PostView | CommunityView | PersonViewSafe;
261       published: string;
262     }[] = [];
263     let comments = this.state.searchResponse.comments.map(e => {
264       return { type_: "comments", data: e, published: e.comment.published };
265     });
266     let posts = this.state.searchResponse.posts.map(e => {
267       return { type_: "posts", data: e, published: e.post.published };
268     });
269     let communities = this.state.searchResponse.communities.map(e => {
270       return {
271         type_: "communities",
272         data: e,
273         published: e.community.published,
274       };
275     });
276     let users = this.state.searchResponse.users.map(e => {
277       return { type_: "users", data: e, published: e.person.published };
278     });
279
280     combined.push(...comments);
281     combined.push(...posts);
282     combined.push(...communities);
283     combined.push(...users);
284
285     // Sort it
286     if (this.state.sort == SortType.New) {
287       combined.sort((a, b) => b.published.localeCompare(a.published));
288     } else {
289       combined.sort(
290         (a, b) =>
291           ((b.data as CommentView | PostView).counts.score |
292             (b.data as CommunityView).counts.subscribers |
293             (b.data as PersonViewSafe).counts.comment_score) -
294           ((a.data as CommentView | PostView).counts.score |
295             (a.data as CommunityView).counts.subscribers |
296             (a.data as PersonViewSafe).counts.comment_score)
297       );
298     }
299
300     return (
301       <div>
302         {combined.map(i => (
303           <div class="row">
304             <div class="col-12">
305               {i.type_ == "posts" && (
306                 <PostListing
307                   key={(i.data as PostView).post.id}
308                   post_view={i.data as PostView}
309                   showCommunity
310                   enableDownvotes={this.state.site.enable_downvotes}
311                   enableNsfw={this.state.site.enable_nsfw}
312                 />
313               )}
314               {i.type_ == "comments" && (
315                 <CommentNodes
316                   key={(i.data as CommentView).comment.id}
317                   nodes={[{ comment_view: i.data as CommentView }]}
318                   locked
319                   noIndent
320                   enableDownvotes={this.state.site.enable_downvotes}
321                 />
322               )}
323               {i.type_ == "communities" && (
324                 <div>{this.communityListing(i.data as CommunityView)}</div>
325               )}
326               {i.type_ == "users" && (
327                 <div>{this.userListing(i.data as PersonViewSafe)}</div>
328               )}
329             </div>
330           </div>
331         ))}
332       </div>
333     );
334   }
335
336   comments() {
337     return (
338       <CommentNodes
339         nodes={commentsToFlatNodes(this.state.searchResponse.comments)}
340         locked
341         noIndent
342         enableDownvotes={this.state.site.enable_downvotes}
343       />
344     );
345   }
346
347   posts() {
348     return (
349       <>
350         {this.state.searchResponse.posts.map(post => (
351           <div class="row">
352             <div class="col-12">
353               <PostListing
354                 post_view={post}
355                 showCommunity
356                 enableDownvotes={this.state.site.enable_downvotes}
357                 enableNsfw={this.state.site.enable_nsfw}
358               />
359             </div>
360           </div>
361         ))}
362       </>
363     );
364   }
365
366   communities() {
367     return (
368       <>
369         {this.state.searchResponse.communities.map(community => (
370           <div class="row">
371             <div class="col-12">{this.communityListing(community)}</div>
372           </div>
373         ))}
374       </>
375     );
376   }
377
378   communityListing(community_view: CommunityView) {
379     return (
380       <>
381         <span>
382           <CommunityLink community={community_view.community} />
383         </span>
384         <span>{` -
385         ${i18n.t("number_of_subscribers", {
386           count: community_view.counts.subscribers,
387         })}
388       `}</span>
389       </>
390     );
391   }
392
393   userListing(person_view: PersonViewSafe) {
394     return [
395       <span>
396         <PersonListing person={person_view.person} showApubName />
397       </span>,
398       <span>{` - ${i18n.t("number_of_comments", {
399         count: person_view.counts.comment_count,
400       })}`}</span>,
401     ];
402   }
403
404   users() {
405     return (
406       <>
407         {this.state.searchResponse.users.map(user => (
408           <div class="row">
409             <div class="col-12">{this.userListing(user)}</div>
410           </div>
411         ))}
412       </>
413     );
414   }
415
416   paginator() {
417     return (
418       <div class="mt-2">
419         {this.state.page > 1 && (
420           <button
421             class="btn btn-secondary mr-1"
422             onClick={linkEvent(this, this.prevPage)}
423           >
424             {i18n.t("prev")}
425           </button>
426         )}
427
428         {this.resultsCount() > 0 && (
429           <button
430             class="btn btn-secondary"
431             onClick={linkEvent(this, this.nextPage)}
432           >
433             {i18n.t("next")}
434           </button>
435         )}
436       </div>
437     );
438   }
439
440   resultsCount(): number {
441     let res = this.state.searchResponse;
442     return (
443       res.posts.length +
444       res.comments.length +
445       res.communities.length +
446       res.users.length
447     );
448   }
449
450   nextPage(i: Search) {
451     i.updateUrl({ page: i.state.page + 1 });
452   }
453
454   prevPage(i: Search) {
455     i.updateUrl({ page: i.state.page - 1 });
456   }
457
458   search() {
459     let form: SearchForm = {
460       q: this.state.q,
461       type_: this.state.type_,
462       sort: this.state.sort,
463       page: this.state.page,
464       limit: fetchLimit,
465       auth: authField(false),
466     };
467
468     if (this.state.q != "") {
469       WebSocketService.Instance.send(wsClient.search(form));
470     }
471   }
472
473   handleSortChange(val: SortType) {
474     this.updateUrl({ sort: val, page: 1 });
475   }
476
477   handleTypeChange(i: Search, event: any) {
478     i.updateUrl({
479       type_: SearchType[event.target.value],
480       page: 1,
481     });
482   }
483
484   handleSearchSubmit(i: Search, event: any) {
485     event.preventDefault();
486     i.updateUrl({
487       q: i.state.searchText,
488       type_: i.state.type_,
489       sort: i.state.sort,
490       page: i.state.page,
491     });
492   }
493
494   handleQChange(i: Search, event: any) {
495     i.setState({ searchText: event.target.value });
496   }
497
498   updateUrl(paramUpdates: UrlParams) {
499     const qStr = paramUpdates.q || this.state.q;
500     const qStrEncoded = encodeURIComponent(qStr);
501     const typeStr = paramUpdates.type_ || this.state.type_;
502     const sortStr = paramUpdates.sort || this.state.sort;
503     const page = paramUpdates.page || this.state.page;
504     this.props.history.push(
505       `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/page/${page}`
506     );
507   }
508
509   parseMessage(msg: any) {
510     console.log(msg);
511     let op = wsUserOp(msg);
512     if (msg.error) {
513       toast(i18n.t(msg.error), "danger");
514       return;
515     } else if (op == UserOperation.Search) {
516       let data = wsJsonToRes<SearchResponse>(msg).data;
517       this.state.searchResponse = data;
518       this.state.loading = false;
519       window.scrollTo(0, 0);
520       this.setState(this.state);
521       restoreScrollPosition(this.context);
522     } else if (op == UserOperation.CreateCommentLike) {
523       let data = wsJsonToRes<CommentResponse>(msg).data;
524       createCommentLikeRes(
525         data.comment_view,
526         this.state.searchResponse.comments
527       );
528       this.setState(this.state);
529     } else if (op == UserOperation.CreatePostLike) {
530       let data = wsJsonToRes<PostResponse>(msg).data;
531       createPostLikeFindRes(data.post_view, this.state.searchResponse.posts);
532       this.setState(this.state);
533     }
534   }
535 }