]> Untitled Git - lemmy.git/blob - ui/src/components/search.tsx
Squashed commit of the following:
[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   WebSocketJsonResponse,
18 } from '../interfaces';
19 import { WebSocketService } from '../services';
20 import {
21   wsJsonToRes,
22   fetchLimit,
23   routeSearchTypeToEnum,
24   routeSortTypeToEnum,
25   pictshareAvatarThumbnail,
26   showAvatars,
27   toast,
28 } from '../utils';
29 import { PostListing } from './post-listing';
30 import { SortSelect } from './sort-select';
31 import { CommentNodes } from './comment-nodes';
32 import { i18n } from '../i18next';
33 import { T } from 'inferno-i18next';
34
35 interface SearchState {
36   q: string;
37   type_: SearchType;
38   sort: SortType;
39   page: number;
40   searchResponse: SearchResponse;
41   loading: boolean;
42 }
43
44 export class Search extends Component<any, SearchState> {
45   private subscription: Subscription;
46   private emptyState: SearchState = {
47     q: this.getSearchQueryFromProps(this.props),
48     type_: this.getSearchTypeFromProps(this.props),
49     sort: this.getSortTypeFromProps(this.props),
50     page: this.getPageFromProps(this.props),
51     searchResponse: {
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.posts.length == 0 &&
405           res.comments.length == 0 &&
406           res.communities.length == 0 &&
407           res.users.length == 0 && (
408             <span>
409               <T i18nKey="no_results">#</T>
410             </span>
411           )}
412       </div>
413     );
414   }
415
416   nextPage(i: Search) {
417     i.state.page++;
418     i.setState(i.state);
419     i.updateUrl();
420     i.search();
421   }
422
423   prevPage(i: Search) {
424     i.state.page--;
425     i.setState(i.state);
426     i.updateUrl();
427     i.search();
428   }
429
430   search() {
431     let form: SearchForm = {
432       q: this.state.q,
433       type_: SearchType[this.state.type_],
434       sort: SortType[this.state.sort],
435       page: this.state.page,
436       limit: fetchLimit,
437     };
438
439     if (this.state.q != '') {
440       WebSocketService.Instance.search(form);
441     }
442   }
443
444   handleSortChange(val: SortType) {
445     this.state.sort = val;
446     this.state.page = 1;
447     this.setState(this.state);
448     this.updateUrl();
449   }
450
451   handleTypeChange(i: Search, event: any) {
452     i.state.type_ = Number(event.target.value);
453     i.state.page = 1;
454     i.setState(i.state);
455     i.updateUrl();
456   }
457
458   handleSearchSubmit(i: Search, event: any) {
459     event.preventDefault();
460     i.state.loading = true;
461     i.search();
462     i.setState(i.state);
463     i.updateUrl();
464   }
465
466   handleQChange(i: Search, event: any) {
467     i.state.q = event.target.value;
468     i.setState(i.state);
469   }
470
471   updateUrl() {
472     let typeStr = SearchType[this.state.type_].toLowerCase();
473     let sortStr = SortType[this.state.sort].toLowerCase();
474     this.props.history.push(
475       `/search/q/${this.state.q}/type/${typeStr}/sort/${sortStr}/page/${this.state.page}`
476     );
477   }
478
479   parseMessage(msg: WebSocketJsonResponse) {
480     console.log(msg);
481     let res = wsJsonToRes(msg);
482     if (res.error) {
483       toast(i18n.t(msg.error), 'danger');
484       return;
485     } else if (res.op == UserOperation.Search) {
486       let data = res.data as SearchResponse;
487       this.state.searchResponse = data;
488       this.state.loading = false;
489       document.title = `${i18n.t('search')} - ${this.state.q} - ${
490         WebSocketService.Instance.site.name
491       }`;
492       window.scrollTo(0, 0);
493       this.setState(this.state);
494     } else if (res.op == UserOperation.CreateCommentLike) {
495       let data = res.data as CommentResponse;
496       let found: Comment = this.state.searchResponse.comments.find(
497         c => c.id === data.comment.id
498       );
499       found.score = data.comment.score;
500       found.upvotes = data.comment.upvotes;
501       found.downvotes = data.comment.downvotes;
502       if (data.comment.my_vote !== null) {
503         found.my_vote = data.comment.my_vote;
504         found.upvoteLoading = false;
505         found.downvoteLoading = false;
506       }
507       this.setState(this.state);
508     } else if (res.op == UserOperation.CreatePostLike) {
509       let data = res.data as CreatePostLikeResponse;
510       let found = this.state.searchResponse.posts.find(
511         c => c.id == data.post.id
512       );
513       found.my_vote = data.post.my_vote;
514       found.score = data.post.score;
515       found.upvotes = data.post.upvotes;
516       found.downvotes = data.post.downvotes;
517       this.setState(this.state);
518     }
519   }
520 }