]> Untitled Git - lemmy.git/blob - ui/src/components/search.tsx
Merge branch 'master' into federation
[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   pictshareAvatarThumbnail,
23   showAvatars,
24 } from '../utils';
25 import { PostListing } from './post-listing';
26 import { SortSelect } from './sort-select';
27 import { CommentNodes } from './comment-nodes';
28 import { i18n } from '../i18next';
29 import { T } from 'inferno-i18next';
30
31 interface SearchState {
32   q: string;
33   type_: SearchType;
34   sort: SortType;
35   page: number;
36   searchResponse: SearchResponse;
37   loading: boolean;
38 }
39
40 export class Search extends Component<any, SearchState> {
41   private subscription: Subscription;
42   private emptyState: SearchState = {
43     q: this.getSearchQueryFromProps(this.props),
44     type_: this.getSearchTypeFromProps(this.props),
45     sort: this.getSortTypeFromProps(this.props),
46     page: this.getPageFromProps(this.props),
47     searchResponse: {
48       op: null,
49       type_: null,
50       posts: [],
51       comments: [],
52       communities: [],
53       users: [],
54     },
55     loading: false,
56   };
57
58   getSearchQueryFromProps(props: any): string {
59     return props.match.params.q ? props.match.params.q : '';
60   }
61
62   getSearchTypeFromProps(props: any): SearchType {
63     return props.match.params.type
64       ? routeSearchTypeToEnum(props.match.params.type)
65       : SearchType.All;
66   }
67
68   getSortTypeFromProps(props: any): SortType {
69     return props.match.params.sort
70       ? routeSortTypeToEnum(props.match.params.sort)
71       : SortType.TopAll;
72   }
73
74   getPageFromProps(props: any): number {
75     return props.match.params.page ? Number(props.match.params.page) : 1;
76   }
77
78   constructor(props: any, context: any) {
79     super(props, context);
80
81     this.state = this.emptyState;
82     this.handleSortChange = this.handleSortChange.bind(this);
83
84     this.subscription = WebSocketService.Instance.subject
85       .pipe(
86         retryWhen(errors =>
87           errors.pipe(
88             delay(3000),
89             take(10)
90           )
91         )
92       )
93       .subscribe(
94         msg => this.parseMessage(msg),
95         err => console.error(err),
96         () => console.log('complete')
97       );
98
99     if (this.state.q) {
100       this.search();
101     }
102   }
103
104   componentWillUnmount() {
105     this.subscription.unsubscribe();
106   }
107
108   // Necessary for back button for some reason
109   componentWillReceiveProps(nextProps: any) {
110     if (
111       nextProps.history.action == 'POP' ||
112       nextProps.history.action == 'PUSH'
113     ) {
114       this.state = this.emptyState;
115       this.state.q = this.getSearchQueryFromProps(nextProps);
116       this.state.type_ = this.getSearchTypeFromProps(nextProps);
117       this.state.sort = this.getSortTypeFromProps(nextProps);
118       this.state.page = this.getPageFromProps(nextProps);
119       this.setState(this.state);
120       this.search();
121     }
122   }
123
124   componentDidMount() {
125     document.title = `${i18n.t('search')} - ${
126       WebSocketService.Instance.site.name
127     }`;
128   }
129
130   render() {
131     return (
132       <div class="container">
133         <div class="row">
134           <div class="col-12">
135             <h5>
136               <T i18nKey="search">#</T>
137             </h5>
138             {this.selects()}
139             {this.searchForm()}
140             {this.state.type_ == SearchType.All && this.all()}
141             {this.state.type_ == SearchType.Comments && this.comments()}
142             {this.state.type_ == SearchType.Posts && this.posts()}
143             {this.state.type_ == SearchType.Communities && this.communities()}
144             {this.state.type_ == SearchType.Users && this.users()}
145             {this.noResults()}
146             {this.paginator()}
147           </div>
148         </div>
149       </div>
150     );
151   }
152
153   searchForm() {
154     return (
155       <form
156         class="form-inline"
157         onSubmit={linkEvent(this, this.handleSearchSubmit)}
158       >
159         <input
160           type="text"
161           class="form-control mr-2"
162           value={this.state.q}
163           placeholder={`${i18n.t('search')}...`}
164           onInput={linkEvent(this, this.handleQChange)}
165           required
166           minLength={3}
167         />
168         <button type="submit" class="btn btn-secondary mr-2">
169           {this.state.loading ? (
170             <svg class="icon icon-spinner spin">
171               <use xlinkHref="#icon-spinner"></use>
172             </svg>
173           ) : (
174             <span>
175               <T i18nKey="search">#</T>
176             </span>
177           )}
178         </button>
179       </form>
180     );
181   }
182
183   selects() {
184     return (
185       <div className="mb-2">
186         <select
187           value={this.state.type_}
188           onChange={linkEvent(this, this.handleTypeChange)}
189           class="custom-select custom-select-sm w-auto"
190         >
191           <option disabled>
192             <T i18nKey="type">#</T>
193           </option>
194           <option value={SearchType.All}>
195             <T i18nKey="all">#</T>
196           </option>
197           <option value={SearchType.Comments}>
198             <T i18nKey="comments">#</T>
199           </option>
200           <option value={SearchType.Posts}>
201             <T i18nKey="posts">#</T>
202           </option>
203           <option value={SearchType.Communities}>
204             <T i18nKey="communities">#</T>
205           </option>
206           <option value={SearchType.Users}>
207             <T i18nKey="users">#</T>
208           </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>
263             {i.type_ == 'posts' && (
264               <PostListing post={i.data as Post} showCommunity viewOnly />
265             )}
266             {i.type_ == 'comments' && (
267               <CommentNodes
268                 nodes={[{ comment: i.data as Comment }]}
269                 viewOnly
270                 noIndent
271               />
272             )}
273             {i.type_ == 'communities' && (
274               <div>
275                 <span>
276                   <Link to={`/c/${(i.data as Community).name}`}>{`/c/${
277                     (i.data as Community).name
278                   }`}</Link>
279                 </span>
280                 <span>{` - ${(i.data as Community).title} - ${
281                   (i.data as Community).number_of_subscribers
282                 } subscribers`}</span>
283               </div>
284             )}
285             {i.type_ == 'users' && (
286               <div>
287                 <span>
288                   <Link
289                     className="text-info"
290                     to={`/u/${(i.data as UserView).name}`}
291                   >
292                     {(i.data as UserView).avatar && showAvatars() && (
293                       <img
294                         height="32"
295                         width="32"
296                         src={pictshareAvatarThumbnail(
297                           (i.data as UserView).avatar
298                         )}
299                         class="rounded-circle mr-1"
300                       />
301                     )}
302                     <span>{`/u/${(i.data as UserView).name}`}</span>
303                   </Link>
304                 </span>
305                 <span>{` - ${
306                   (i.data as UserView).comment_score
307                 } comment karma`}</span>
308               </div>
309             )}
310           </div>
311         ))}
312       </div>
313     );
314   }
315
316   comments() {
317     return (
318       <div>
319         {this.state.searchResponse.comments.map(comment => (
320           <CommentNodes nodes={[{ comment: comment }]} noIndent viewOnly />
321         ))}
322       </div>
323     );
324   }
325
326   posts() {
327     return (
328       <div>
329         {this.state.searchResponse.posts.map(post => (
330           <PostListing post={post} showCommunity viewOnly />
331         ))}
332       </div>
333     );
334   }
335
336   // Todo possibly create UserListing and CommunityListing
337   communities() {
338     return (
339       <div>
340         {this.state.searchResponse.communities.map(community => (
341           <div>
342             <span>
343               <Link to={`/c/${community.name}`}>{`/c/${community.name}`}</Link>
344             </span>
345             <span>{` - ${community.title} - ${community.number_of_subscribers} subscribers`}</span>
346           </div>
347         ))}
348       </div>
349     );
350   }
351
352   users() {
353     return (
354       <div>
355         {this.state.searchResponse.users.map(user => (
356           <div>
357             <span>
358               <Link
359                 className="text-info"
360                 to={`/u/${user.name}`}
361               >{`/u/${user.name}`}</Link>
362             </span>
363             <span>{` - ${user.comment_score} comment karma`}</span>
364           </div>
365         ))}
366       </div>
367     );
368   }
369
370   paginator() {
371     return (
372       <div class="mt-2">
373         {this.state.page > 1 && (
374           <button
375             class="btn btn-sm btn-secondary mr-1"
376             onClick={linkEvent(this, this.prevPage)}
377           >
378             <T i18nKey="prev">#</T>
379           </button>
380         )}
381         <button
382           class="btn btn-sm btn-secondary"
383           onClick={linkEvent(this, this.nextPage)}
384         >
385           <T i18nKey="next">#</T>
386         </button>
387       </div>
388     );
389   }
390
391   noResults() {
392     let res = this.state.searchResponse;
393     return (
394       <div>
395         {res &&
396           res.op &&
397           res.posts.length == 0 &&
398           res.comments.length == 0 &&
399           res.communities.length == 0 &&
400           res.users.length == 0 && (
401             <span>
402               <T i18nKey="no_results">#</T>
403             </span>
404           )}
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: any) {
473     console.log(msg);
474     let op: UserOperation = msgOp(msg);
475     if (msg.error) {
476       alert(i18n.t(msg.error));
477       return;
478     } else if (op == UserOperation.Search) {
479       let res: SearchResponse = msg;
480       this.state.searchResponse = res;
481       this.state.loading = false;
482       document.title = `${i18n.t('search')} - ${this.state.q} - ${
483         WebSocketService.Instance.site.name
484       }`;
485       window.scrollTo(0, 0);
486       this.setState(this.state);
487     }
488   }
489 }