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