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