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