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