]> Untitled Git - lemmy.git/blob - ui/src/components/search.tsx
Merge remote-tracking branch 'LemmyNet/master'
[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.noResults()}
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                   <span>{` - ${
286                     (i.data as UserView).comment_score
287                   } comment karma`}</span>
288                 </div>
289               )}
290             </div>
291           </div>
292         ))}
293       </div>
294     );
295   }
296
297   comments() {
298     return (
299       <CommentNodes
300         nodes={commentsToFlatNodes(this.state.searchResponse.comments)}
301         locked
302         noIndent
303         enableDownvotes={this.state.site.enable_downvotes}
304       />
305     );
306   }
307
308   posts() {
309     return (
310       <>
311         {this.state.searchResponse.posts.map(post => (
312           <div class="row">
313             <div class="col-12">
314               <PostListing
315                 post={post}
316                 showCommunity
317                 enableDownvotes={this.state.site.enable_downvotes}
318                 enableNsfw={this.state.site.enable_nsfw}
319               />
320             </div>
321           </div>
322         ))}
323       </>
324     );
325   }
326
327   // Todo possibly create UserListing and CommunityListing
328   communities() {
329     return (
330       <>
331         {this.state.searchResponse.communities.map(community => (
332           <div class="row">
333             <div class="col-12">{this.communityListing(community)}</div>
334           </div>
335         ))}
336       </>
337     );
338   }
339
340   communityListing(community: Community) {
341     return (
342       <>
343         <span>
344           <CommunityLink community={community} />
345         </span>
346         <span>{` - ${community.title} - 
347         ${i18n.t('number_of_subscribers', {
348           count: community.number_of_subscribers,
349         })}
350       `}</span>
351       </>
352     );
353   }
354
355   users() {
356     return (
357       <>
358         {this.state.searchResponse.users.map(user => (
359           <div class="row">
360             <div class="col-12">
361               <span>
362                 <Link
363                   className="text-info"
364                   to={`/u/${user.name}`}
365                 >{`/u/${user.name}`}</Link>
366               </span>
367               <span>{` - ${user.comment_score} comment karma`}</span>
368             </div>
369           </div>
370         ))}
371       </>
372     );
373   }
374
375   paginator() {
376     return (
377       <div class="mt-2">
378         {this.state.page > 1 && (
379           <button
380             class="btn btn-sm btn-secondary mr-1"
381             onClick={linkEvent(this, this.prevPage)}
382           >
383             {i18n.t('prev')}
384           </button>
385         )}
386         <button
387           class="btn btn-sm btn-secondary"
388           onClick={linkEvent(this, this.nextPage)}
389         >
390           {i18n.t('next')}
391         </button>
392       </div>
393     );
394   }
395
396   noResults() {
397     let res = this.state.searchResponse;
398     return (
399       <div>
400         {res &&
401           res.posts.length == 0 &&
402           res.comments.length == 0 &&
403           res.communities.length == 0 &&
404           res.users.length == 0 && <span>{i18n.t('no_results')}</span>}
405       </div>
406     );
407   }
408
409   nextPage(i: Search) {
410     i.state.page++;
411     i.setState(i.state);
412     i.updateUrl();
413     i.search();
414   }
415
416   prevPage(i: Search) {
417     i.state.page--;
418     i.setState(i.state);
419     i.updateUrl();
420     i.search();
421   }
422
423   search() {
424     let form: SearchForm = {
425       q: this.state.q,
426       type_: SearchType[this.state.type_],
427       sort: SortType[this.state.sort],
428       page: this.state.page,
429       limit: fetchLimit,
430     };
431
432     if (this.state.q != '') {
433       WebSocketService.Instance.search(form);
434     }
435   }
436
437   handleSortChange(val: SortType) {
438     this.state.sort = val;
439     this.state.page = 1;
440     this.setState(this.state);
441     this.updateUrl();
442   }
443
444   handleTypeChange(i: Search, event: any) {
445     i.state.type_ = Number(event.target.value);
446     i.state.page = 1;
447     i.setState(i.state);
448     i.updateUrl();
449   }
450
451   handleSearchSubmit(i: Search, event: any) {
452     event.preventDefault();
453     i.state.loading = true;
454     i.search();
455     i.setState(i.state);
456     i.updateUrl();
457   }
458
459   handleQChange(i: Search, event: any) {
460     i.state.q = event.target.value;
461     i.setState(i.state);
462   }
463
464   updateUrl() {
465     let typeStr = SearchType[this.state.type_].toLowerCase();
466     let sortStr = SortType[this.state.sort].toLowerCase();
467     this.props.history.push(
468       `/search/q/${this.state.q}/type/${typeStr}/sort/${sortStr}/page/${this.state.page}`
469     );
470   }
471
472   parseMessage(msg: WebSocketJsonResponse) {
473     console.log(msg);
474     let res = wsJsonToRes(msg);
475     if (msg.error) {
476       toast(i18n.t(msg.error), 'danger');
477       return;
478     } else if (res.op == UserOperation.Search) {
479       let data = res.data as SearchResponse;
480       this.state.searchResponse = data;
481       this.state.loading = false;
482       document.title = `${i18n.t('search')} - ${this.state.q} - ${
483         this.state.site.name
484       }`;
485       window.scrollTo(0, 0);
486       this.setState(this.state);
487     } else if (res.op == UserOperation.CreateCommentLike) {
488       let data = res.data as CommentResponse;
489       createCommentLikeRes(data, this.state.searchResponse.comments);
490       this.setState(this.state);
491     } else if (res.op == UserOperation.CreatePostLike) {
492       let data = res.data as PostResponse;
493       createPostLikeFindRes(data, this.state.searchResponse.posts);
494       this.setState(this.state);
495     } else if (res.op == UserOperation.GetSite) {
496       let data = res.data as GetSiteResponse;
497       this.state.site = data.site;
498       this.setState(this.state);
499       document.title = `${i18n.t('search')} - ${data.site.name}`;
500     }
501   }
502 }