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