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