]> Untitled Git - lemmy.git/blob - ui/src/components/search.tsx
Adding a toaster to replace alerts. Fixes #457
[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   CreatePostLikeResponse,
16   CommentResponse,
17 } from '../interfaces';
18 import { WebSocketService } from '../services';
19 import {
20   msgOp,
21   fetchLimit,
22   routeSearchTypeToEnum,
23   routeSortTypeToEnum,
24   pictshareAvatarThumbnail,
25   showAvatars,
26   toast,
27 } from '../utils';
28 import { PostListing } from './post-listing';
29 import { SortSelect } from './sort-select';
30 import { CommentNodes } from './comment-nodes';
31 import { i18n } from '../i18next';
32 import { T } from 'inferno-i18next';
33
34 interface SearchState {
35   q: string;
36   type_: SearchType;
37   sort: SortType;
38   page: number;
39   searchResponse: SearchResponse;
40   loading: boolean;
41 }
42
43 export class Search extends Component<any, SearchState> {
44   private subscription: Subscription;
45   private emptyState: SearchState = {
46     q: this.getSearchQueryFromProps(this.props),
47     type_: this.getSearchTypeFromProps(this.props),
48     sort: this.getSortTypeFromProps(this.props),
49     page: this.getPageFromProps(this.props),
50     searchResponse: {
51       op: null,
52       type_: null,
53       posts: [],
54       comments: [],
55       communities: [],
56       users: [],
57     },
58     loading: false,
59   };
60
61   getSearchQueryFromProps(props: any): string {
62     return props.match.params.q ? props.match.params.q : '';
63   }
64
65   getSearchTypeFromProps(props: any): SearchType {
66     return props.match.params.type
67       ? routeSearchTypeToEnum(props.match.params.type)
68       : SearchType.All;
69   }
70
71   getSortTypeFromProps(props: any): SortType {
72     return props.match.params.sort
73       ? routeSortTypeToEnum(props.match.params.sort)
74       : SortType.TopAll;
75   }
76
77   getPageFromProps(props: any): number {
78     return props.match.params.page ? Number(props.match.params.page) : 1;
79   }
80
81   constructor(props: any, context: any) {
82     super(props, context);
83
84     this.state = this.emptyState;
85     this.handleSortChange = this.handleSortChange.bind(this);
86
87     this.subscription = WebSocketService.Instance.subject
88       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
89       .subscribe(
90         msg => this.parseMessage(msg),
91         err => console.error(err),
92         () => console.log('complete')
93       );
94
95     if (this.state.q) {
96       this.search();
97     }
98   }
99
100   componentWillUnmount() {
101     this.subscription.unsubscribe();
102   }
103
104   // Necessary for back button for some reason
105   componentWillReceiveProps(nextProps: any) {
106     if (
107       nextProps.history.action == 'POP' ||
108       nextProps.history.action == 'PUSH'
109     ) {
110       this.state = this.emptyState;
111       this.state.q = this.getSearchQueryFromProps(nextProps);
112       this.state.type_ = this.getSearchTypeFromProps(nextProps);
113       this.state.sort = this.getSortTypeFromProps(nextProps);
114       this.state.page = this.getPageFromProps(nextProps);
115       this.setState(this.state);
116       this.search();
117     }
118   }
119
120   componentDidMount() {
121     document.title = `${i18n.t('search')} - ${
122       WebSocketService.Instance.site.name
123     }`;
124   }
125
126   render() {
127     return (
128       <div class="container">
129         <h5>
130           <T i18nKey="search">#</T>
131         </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>
167               <T i18nKey="search">#</T>
168             </span>
169           )}
170         </button>
171       </form>
172     );
173   }
174
175   selects() {
176     return (
177       <div className="mb-2">
178         <select
179           value={this.state.type_}
180           onChange={linkEvent(this, this.handleTypeChange)}
181           class="custom-select custom-select-sm w-auto"
182         >
183           <option disabled>
184             <T i18nKey="type">#</T>
185           </option>
186           <option value={SearchType.All}>
187             <T i18nKey="all">#</T>
188           </option>
189           <option value={SearchType.Comments}>
190             <T i18nKey="comments">#</T>
191           </option>
192           <option value={SearchType.Posts}>
193             <T i18nKey="posts">#</T>
194           </option>
195           <option value={SearchType.Communities}>
196             <T i18nKey="communities">#</T>
197           </option>
198           <option value={SearchType.Users}>
199             <T i18nKey="users">#</T>
200           </option>
201         </select>
202         <span class="ml-2">
203           <SortSelect
204             sort={this.state.sort}
205             onChange={this.handleSortChange}
206             hideHot
207           />
208         </span>
209       </div>
210     );
211   }
212
213   all() {
214     let combined: Array<{
215       type_: string;
216       data: Comment | Post | Community | UserView;
217     }> = [];
218     let comments = this.state.searchResponse.comments.map(e => {
219       return { type_: 'comments', data: e };
220     });
221     let posts = this.state.searchResponse.posts.map(e => {
222       return { type_: 'posts', data: e };
223     });
224     let communities = this.state.searchResponse.communities.map(e => {
225       return { type_: 'communities', data: e };
226     });
227     let users = this.state.searchResponse.users.map(e => {
228       return { type_: 'users', data: e };
229     });
230
231     combined.push(...comments);
232     combined.push(...posts);
233     combined.push(...communities);
234     combined.push(...users);
235
236     // Sort it
237     if (this.state.sort == SortType.New) {
238       combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
239     } else {
240       combined.sort(
241         (a, b) =>
242           ((b.data as Comment | Post).score |
243             (b.data as Community).number_of_subscribers |
244             (b.data as UserView).comment_score) -
245           ((a.data as Comment | Post).score |
246             (a.data as Community).number_of_subscribers |
247             (a.data as UserView).comment_score)
248       );
249     }
250
251     return (
252       <div>
253         {combined.map(i => (
254           <div class="row">
255             <div class="col-12">
256               {i.type_ == 'posts' && (
257                 <PostListing post={i.data as Post} showCommunity />
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           </div>
305         ))}
306       </div>
307     );
308   }
309
310   comments() {
311     return (
312       <>
313         {this.state.searchResponse.comments.map(comment => (
314           <div class="row">
315             <div class="col-12">
316               <CommentNodes nodes={[{ comment: comment }]} locked noIndent />
317             </div>
318           </div>
319         ))}
320       </>
321     );
322   }
323
324   posts() {
325     return (
326       <>
327         {this.state.searchResponse.posts.map(post => (
328           <div class="row">
329             <div class="col-12">
330               <PostListing post={post} showCommunity />
331             </div>
332           </div>
333         ))}
334       </>
335     );
336   }
337
338   // Todo possibly create UserListing and CommunityListing
339   communities() {
340     return (
341       <>
342         {this.state.searchResponse.communities.map(community => (
343           <div class="row">
344             <div class="col-12">
345               <span>
346                 <Link
347                   to={`/c/${community.name}`}
348                 >{`/c/${community.name}`}</Link>
349               </span>
350               <span>{` - ${community.title} - ${community.number_of_subscribers} subscribers`}</span>
351             </div>
352           </div>
353         ))}
354       </>
355     );
356   }
357
358   users() {
359     return (
360       <>
361         {this.state.searchResponse.users.map(user => (
362           <div class="row">
363             <div class="col-12">
364               <span>
365                 <Link
366                   className="text-info"
367                   to={`/u/${user.name}`}
368                 >{`/u/${user.name}`}</Link>
369               </span>
370               <span>{` - ${user.comment_score} comment karma`}</span>
371             </div>
372           </div>
373         ))}
374       </>
375     );
376   }
377
378   paginator() {
379     return (
380       <div class="mt-2">
381         {this.state.page > 1 && (
382           <button
383             class="btn btn-sm btn-secondary mr-1"
384             onClick={linkEvent(this, this.prevPage)}
385           >
386             <T i18nKey="prev">#</T>
387           </button>
388         )}
389         <button
390           class="btn btn-sm btn-secondary"
391           onClick={linkEvent(this, this.nextPage)}
392         >
393           <T i18nKey="next">#</T>
394         </button>
395       </div>
396     );
397   }
398
399   noResults() {
400     let res = this.state.searchResponse;
401     return (
402       <div>
403         {res &&
404           res.op &&
405           res.posts.length == 0 &&
406           res.comments.length == 0 &&
407           res.communities.length == 0 &&
408           res.users.length == 0 && (
409             <span>
410               <T i18nKey="no_results">#</T>
411             </span>
412           )}
413       </div>
414     );
415   }
416
417   nextPage(i: Search) {
418     i.state.page++;
419     i.setState(i.state);
420     i.updateUrl();
421     i.search();
422   }
423
424   prevPage(i: Search) {
425     i.state.page--;
426     i.setState(i.state);
427     i.updateUrl();
428     i.search();
429   }
430
431   search() {
432     let form: SearchForm = {
433       q: this.state.q,
434       type_: SearchType[this.state.type_],
435       sort: SortType[this.state.sort],
436       page: this.state.page,
437       limit: fetchLimit,
438     };
439
440     if (this.state.q != '') {
441       WebSocketService.Instance.search(form);
442     }
443   }
444
445   handleSortChange(val: SortType) {
446     this.state.sort = val;
447     this.state.page = 1;
448     this.setState(this.state);
449     this.updateUrl();
450   }
451
452   handleTypeChange(i: Search, event: any) {
453     i.state.type_ = Number(event.target.value);
454     i.state.page = 1;
455     i.setState(i.state);
456     i.updateUrl();
457   }
458
459   handleSearchSubmit(i: Search, event: any) {
460     event.preventDefault();
461     i.state.loading = true;
462     i.search();
463     i.setState(i.state);
464     i.updateUrl();
465   }
466
467   handleQChange(i: Search, event: any) {
468     i.state.q = event.target.value;
469     i.setState(i.state);
470   }
471
472   updateUrl() {
473     let typeStr = SearchType[this.state.type_].toLowerCase();
474     let sortStr = SortType[this.state.sort].toLowerCase();
475     this.props.history.push(
476       `/search/q/${this.state.q}/type/${typeStr}/sort/${sortStr}/page/${this.state.page}`
477     );
478   }
479
480   parseMessage(msg: any) {
481     console.log(msg);
482     let op: UserOperation = msgOp(msg);
483     if (msg.error) {
484       toast(i18n.t(msg.error), 'danger');
485       return;
486     } else if (op == UserOperation.Search) {
487       let res: SearchResponse = msg;
488       this.state.searchResponse = res;
489       this.state.loading = false;
490       document.title = `${i18n.t('search')} - ${this.state.q} - ${
491         WebSocketService.Instance.site.name
492       }`;
493       window.scrollTo(0, 0);
494       this.setState(this.state);
495     } else if (op == UserOperation.CreateCommentLike) {
496       let res: CommentResponse = msg;
497       let found: Comment = this.state.searchResponse.comments.find(
498         c => c.id === res.comment.id
499       );
500       found.score = res.comment.score;
501       found.upvotes = res.comment.upvotes;
502       found.downvotes = res.comment.downvotes;
503       if (res.comment.my_vote !== null) {
504         found.my_vote = res.comment.my_vote;
505         found.upvoteLoading = false;
506         found.downvoteLoading = false;
507       }
508       this.setState(this.state);
509     } else if (op == UserOperation.CreatePostLike) {
510       let res: CreatePostLikeResponse = msg;
511       let found = this.state.searchResponse.posts.find(
512         c => c.id == res.post.id
513       );
514       found.my_vote = res.post.my_vote;
515       found.score = res.post.score;
516       found.upvotes = res.post.upvotes;
517       found.downvotes = res.post.downvotes;
518       this.setState(this.state);
519     }
520   }
521 }