]> Untitled Git - lemmy.git/blob - ui/src/components/search.tsx
8ab7f599b16426ad42f17838df719e32c9dadfb7
[lemmy.git] / ui / src / components / search.tsx
1 import { Component, linkEvent } from 'inferno';
2 import { Helmet } from 'inferno-helmet';
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 'lemmy-js-client';
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_?: SearchType;
61   sort?: SortType;
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   get documentTitle(): string {
160     if (this.state.site.name) {
161       if (this.state.q) {
162         return `${i18n.t('search')} - ${this.state.q} - ${
163           this.state.site.name
164         }`;
165       } else {
166         return `${i18n.t('search')} - ${this.state.site.name}`;
167       }
168     } else {
169       return 'Lemmy';
170     }
171   }
172
173   render() {
174     return (
175       <div class="container">
176         <Helmet title={this.documentTitle} />
177         <h5>{i18n.t('search')}</h5>
178         {this.selects()}
179         {this.searchForm()}
180         {this.state.type_ == SearchType.All && this.all()}
181         {this.state.type_ == SearchType.Comments && this.comments()}
182         {this.state.type_ == SearchType.Posts && this.posts()}
183         {this.state.type_ == SearchType.Communities && this.communities()}
184         {this.state.type_ == SearchType.Users && this.users()}
185         {this.resultsCount() == 0 && <span>{i18n.t('no_results')}</span>}
186         {this.paginator()}
187       </div>
188     );
189   }
190
191   searchForm() {
192     return (
193       <form
194         class="form-inline"
195         onSubmit={linkEvent(this, this.handleSearchSubmit)}
196       >
197         <input
198           type="text"
199           class="form-control mr-2 mb-2"
200           value={this.state.searchText}
201           placeholder={`${i18n.t('search')}...`}
202           onInput={linkEvent(this, this.handleQChange)}
203           required
204           minLength={3}
205         />
206         <button type="submit" class="btn btn-secondary mr-2 mb-2">
207           {this.state.loading ? (
208             <svg class="icon icon-spinner spin">
209               <use xlinkHref="#icon-spinner"></use>
210             </svg>
211           ) : (
212             <span>{i18n.t('search')}</span>
213           )}
214         </button>
215       </form>
216     );
217   }
218
219   selects() {
220     return (
221       <div className="mb-2">
222         <select
223           value={this.state.type_}
224           onChange={linkEvent(this, this.handleTypeChange)}
225           class="custom-select w-auto mb-2"
226         >
227           <option disabled>{i18n.t('type')}</option>
228           <option value={SearchType.All}>{i18n.t('all')}</option>
229           <option value={SearchType.Comments}>{i18n.t('comments')}</option>
230           <option value={SearchType.Posts}>{i18n.t('posts')}</option>
231           <option value={SearchType.Communities}>
232             {i18n.t('communities')}
233           </option>
234           <option value={SearchType.Users}>{i18n.t('users')}</option>
235         </select>
236         <span class="ml-2">
237           <SortSelect
238             sort={this.state.sort}
239             onChange={this.handleSortChange}
240             hideHot
241           />
242         </span>
243       </div>
244     );
245   }
246
247   all() {
248     let combined: Array<{
249       type_: string;
250       data: Comment | Post | Community | UserView;
251     }> = [];
252     let comments = this.state.searchResponse.comments.map(e => {
253       return { type_: 'comments', data: e };
254     });
255     let posts = this.state.searchResponse.posts.map(e => {
256       return { type_: 'posts', data: e };
257     });
258     let communities = this.state.searchResponse.communities.map(e => {
259       return { type_: 'communities', data: e };
260     });
261     let users = this.state.searchResponse.users.map(e => {
262       return { type_: 'users', data: e };
263     });
264
265     combined.push(...comments);
266     combined.push(...posts);
267     combined.push(...communities);
268     combined.push(...users);
269
270     // Sort it
271     if (this.state.sort == SortType.New) {
272       combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
273     } else {
274       combined.sort(
275         (a, b) =>
276           ((b.data as Comment | Post).score |
277             (b.data as Community).number_of_subscribers |
278             (b.data as UserView).comment_score) -
279           ((a.data as Comment | Post).score |
280             (a.data as Community).number_of_subscribers |
281             (a.data as UserView).comment_score)
282       );
283     }
284
285     return (
286       <div>
287         {combined.map(i => (
288           <div class="row">
289             <div class="col-12">
290               {i.type_ == 'posts' && (
291                 <PostListing
292                   key={(i.data as Post).id}
293                   post={i.data as Post}
294                   showCommunity
295                   enableDownvotes={this.state.site.enable_downvotes}
296                   enableNsfw={this.state.site.enable_nsfw}
297                 />
298               )}
299               {i.type_ == 'comments' && (
300                 <CommentNodes
301                   key={(i.data as Comment).id}
302                   nodes={[{ comment: i.data as Comment }]}
303                   locked
304                   noIndent
305                   enableDownvotes={this.state.site.enable_downvotes}
306                 />
307               )}
308               {i.type_ == 'communities' && (
309                 <div>{this.communityListing(i.data as Community)}</div>
310               )}
311               {i.type_ == 'users' && (
312                 <div>
313                   <span>
314                     <UserListing
315                       user={{
316                         name: (i.data as UserView).name,
317                         preferred_username: (i.data as UserView)
318                           .preferred_username,
319                         avatar: (i.data as UserView).avatar,
320                       }}
321                     />
322                   </span>
323                   <span>{` - ${i18n.t('number_of_comments', {
324                     count: (i.data as UserView).number_of_comments,
325                   })}`}</span>
326                 </div>
327               )}
328             </div>
329           </div>
330         ))}
331       </div>
332     );
333   }
334
335   comments() {
336     return (
337       <CommentNodes
338         nodes={commentsToFlatNodes(this.state.searchResponse.comments)}
339         locked
340         noIndent
341         enableDownvotes={this.state.site.enable_downvotes}
342       />
343     );
344   }
345
346   posts() {
347     return (
348       <>
349         {this.state.searchResponse.posts.map(post => (
350           <div class="row">
351             <div class="col-12">
352               <PostListing
353                 post={post}
354                 showCommunity
355                 enableDownvotes={this.state.site.enable_downvotes}
356                 enableNsfw={this.state.site.enable_nsfw}
357               />
358             </div>
359           </div>
360         ))}
361       </>
362     );
363   }
364
365   // Todo possibly create UserListing and CommunityListing
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: Community) {
379     return (
380       <>
381         <span>
382           <CommunityLink community={community} />
383         </span>
384         <span>{` - ${community.title} - 
385         ${i18n.t('number_of_subscribers', {
386           count: community.number_of_subscribers,
387         })}
388       `}</span>
389       </>
390     );
391   }
392
393   users() {
394     return (
395       <>
396         {this.state.searchResponse.users.map(user => (
397           <div class="row">
398             <div class="col-12">
399               <span>
400                 <UserListing
401                   user={{
402                     name: user.name,
403                     avatar: user.avatar,
404                     preferred_username: user.preferred_username,
405                   }}
406                 />
407               </span>
408               <span>{` - ${i18n.t('number_of_comments', {
409                 count: user.number_of_comments,
410               })}`}</span>
411             </div>
412           </div>
413         ))}
414       </>
415     );
416   }
417
418   paginator() {
419     return (
420       <div class="mt-2">
421         {this.state.page > 1 && (
422           <button
423             class="btn btn-secondary mr-1"
424             onClick={linkEvent(this, this.prevPage)}
425           >
426             {i18n.t('prev')}
427           </button>
428         )}
429
430         {this.resultsCount() > 0 && (
431           <button
432             class="btn btn-secondary"
433             onClick={linkEvent(this, this.nextPage)}
434           >
435             {i18n.t('next')}
436           </button>
437         )}
438       </div>
439     );
440   }
441
442   resultsCount(): number {
443     let res = this.state.searchResponse;
444     return (
445       res.posts.length +
446       res.comments.length +
447       res.communities.length +
448       res.users.length
449     );
450   }
451
452   nextPage(i: Search) {
453     i.updateUrl({ page: i.state.page + 1 });
454   }
455
456   prevPage(i: Search) {
457     i.updateUrl({ page: i.state.page - 1 });
458   }
459
460   search() {
461     let form: SearchForm = {
462       q: this.state.q,
463       type_: this.state.type_,
464       sort: this.state.sort,
465       page: this.state.page,
466       limit: fetchLimit,
467     };
468
469     if (this.state.q != '') {
470       WebSocketService.Instance.search(form);
471     }
472   }
473
474   handleSortChange(val: SortType) {
475     this.updateUrl({ sort: val, page: 1 });
476   }
477
478   handleTypeChange(i: Search, event: any) {
479     i.updateUrl({
480       type_: SearchType[event.target.value],
481       page: 1,
482     });
483   }
484
485   handleSearchSubmit(i: Search, event: any) {
486     event.preventDefault();
487     i.updateUrl({
488       q: i.state.searchText,
489       type_: i.state.type_,
490       sort: i.state.sort,
491       page: i.state.page,
492     });
493   }
494
495   handleQChange(i: Search, event: any) {
496     i.setState({ searchText: event.target.value });
497   }
498
499   updateUrl(paramUpdates: UrlParams) {
500     const qStr = paramUpdates.q || this.state.q;
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/${qStr}/type/${typeStr}/sort/${sortStr}/page/${page}`
506     );
507   }
508
509   parseMessage(msg: WebSocketJsonResponse) {
510     console.log(msg);
511     let res = wsJsonToRes(msg);
512     if (msg.error) {
513       toast(i18n.t(msg.error), 'danger');
514       return;
515     } else if (res.op == UserOperation.Search) {
516       let data = res.data as SearchResponse;
517       this.state.searchResponse = data;
518       this.state.loading = false;
519       window.scrollTo(0, 0);
520       this.setState(this.state);
521     } else if (res.op == UserOperation.CreateCommentLike) {
522       let data = res.data as CommentResponse;
523       createCommentLikeRes(data, this.state.searchResponse.comments);
524       this.setState(this.state);
525     } else if (res.op == UserOperation.CreatePostLike) {
526       let data = res.data as PostResponse;
527       createPostLikeFindRes(data, this.state.searchResponse.posts);
528       this.setState(this.state);
529     } else if (res.op == UserOperation.GetSite) {
530       let data = res.data as GetSiteResponse;
531       this.state.site = data.site;
532       this.setState(this.state);
533     }
534   }
535 }