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