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