]> Untitled Git - lemmy.git/blob - ui/src/components/search.tsx
Finishing up interpolation rework.
[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   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
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       type_: null,
52       posts: [],
53       comments: [],
54       communities: [],
55       users: [],
56     },
57     loading: false,
58   };
59
60   getSearchQueryFromProps(props: any): string {
61     return props.match.params.q ? props.match.params.q : '';
62   }
63
64   getSearchTypeFromProps(props: any): SearchType {
65     return props.match.params.type
66       ? routeSearchTypeToEnum(props.match.params.type)
67       : SearchType.All;
68   }
69
70   getSortTypeFromProps(props: any): SortType {
71     return props.match.params.sort
72       ? routeSortTypeToEnum(props.match.params.sort)
73       : SortType.TopAll;
74   }
75
76   getPageFromProps(props: any): number {
77     return props.match.params.page ? Number(props.match.params.page) : 1;
78   }
79
80   constructor(props: any, context: any) {
81     super(props, context);
82
83     this.state = this.emptyState;
84     this.handleSortChange = this.handleSortChange.bind(this);
85
86     this.subscription = WebSocketService.Instance.subject
87       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
88       .subscribe(
89         msg => this.parseMessage(msg),
90         err => console.error(err),
91         () => console.log('complete')
92       );
93
94     if (this.state.q) {
95       this.search();
96     }
97   }
98
99   componentWillUnmount() {
100     this.subscription.unsubscribe();
101   }
102
103   // Necessary for back button for some reason
104   componentWillReceiveProps(nextProps: any) {
105     if (
106       nextProps.history.action == 'POP' ||
107       nextProps.history.action == 'PUSH'
108     ) {
109       this.state = this.emptyState;
110       this.state.q = this.getSearchQueryFromProps(nextProps);
111       this.state.type_ = this.getSearchTypeFromProps(nextProps);
112       this.state.sort = this.getSortTypeFromProps(nextProps);
113       this.state.page = this.getPageFromProps(nextProps);
114       this.setState(this.state);
115       this.search();
116     }
117   }
118
119   componentDidMount() {
120     document.title = `${i18n.t('search')} - ${
121       WebSocketService.Instance.site.name
122     }`;
123   }
124
125   render() {
126     return (
127       <div class="container">
128         <h5>{i18n.t('search')}</h5>
129         {this.selects()}
130         {this.searchForm()}
131         {this.state.type_ == SearchType.All && this.all()}
132         {this.state.type_ == SearchType.Comments && this.comments()}
133         {this.state.type_ == SearchType.Posts && this.posts()}
134         {this.state.type_ == SearchType.Communities && this.communities()}
135         {this.state.type_ == SearchType.Users && this.users()}
136         {this.noResults()}
137         {this.paginator()}
138       </div>
139     );
140   }
141
142   searchForm() {
143     return (
144       <form
145         class="form-inline"
146         onSubmit={linkEvent(this, this.handleSearchSubmit)}
147       >
148         <input
149           type="text"
150           class="form-control mr-2"
151           value={this.state.q}
152           placeholder={`${i18n.t('search')}...`}
153           onInput={linkEvent(this, this.handleQChange)}
154           required
155           minLength={3}
156         />
157         <button type="submit" class="btn btn-secondary mr-2">
158           {this.state.loading ? (
159             <svg class="icon icon-spinner spin">
160               <use xlinkHref="#icon-spinner"></use>
161             </svg>
162           ) : (
163             <span>{i18n.t('search')}</span>
164           )}
165         </button>
166       </form>
167     );
168   }
169
170   selects() {
171     return (
172       <div className="mb-2">
173         <select
174           value={this.state.type_}
175           onChange={linkEvent(this, this.handleTypeChange)}
176           class="custom-select custom-select-sm w-auto"
177         >
178           <option disabled>{i18n.t('type')}</option>
179           <option value={SearchType.All}>{i18n.t('all')}</option>
180           <option value={SearchType.Comments}>{i18n.t('comments')}</option>
181           <option value={SearchType.Posts}>{i18n.t('posts')}</option>
182           <option value={SearchType.Communities}>
183             {i18n.t('communities')}
184           </option>
185           <option value={SearchType.Users}>{i18n.t('users')}</option>
186         </select>
187         <span class="ml-2">
188           <SortSelect
189             sort={this.state.sort}
190             onChange={this.handleSortChange}
191             hideHot
192           />
193         </span>
194       </div>
195     );
196   }
197
198   all() {
199     let combined: Array<{
200       type_: string;
201       data: Comment | Post | Community | UserView;
202     }> = [];
203     let comments = this.state.searchResponse.comments.map(e => {
204       return { type_: 'comments', data: e };
205     });
206     let posts = this.state.searchResponse.posts.map(e => {
207       return { type_: 'posts', data: e };
208     });
209     let communities = this.state.searchResponse.communities.map(e => {
210       return { type_: 'communities', data: e };
211     });
212     let users = this.state.searchResponse.users.map(e => {
213       return { type_: 'users', data: e };
214     });
215
216     combined.push(...comments);
217     combined.push(...posts);
218     combined.push(...communities);
219     combined.push(...users);
220
221     // Sort it
222     if (this.state.sort == SortType.New) {
223       combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
224     } else {
225       combined.sort(
226         (a, b) =>
227           ((b.data as Comment | Post).score |
228             (b.data as Community).number_of_subscribers |
229             (b.data as UserView).comment_score) -
230           ((a.data as Comment | Post).score |
231             (a.data as Community).number_of_subscribers |
232             (a.data as UserView).comment_score)
233       );
234     }
235
236     return (
237       <div>
238         {combined.map(i => (
239           <div class="row">
240             <div class="col-12">
241               {i.type_ == 'posts' && (
242                 <PostListing post={i.data as Post} showCommunity />
243               )}
244               {i.type_ == 'comments' && (
245                 <CommentNodes
246                   nodes={[{ comment: i.data as Comment }]}
247                   locked
248                   noIndent
249                 />
250               )}
251               {i.type_ == 'communities' && (
252                 <div>
253                   <span>
254                     <Link to={`/c/${(i.data as Community).name}`}>{`/c/${
255                       (i.data as Community).name
256                     }`}</Link>
257                   </span>
258                   <span>{` - ${(i.data as Community).title} - ${
259                     (i.data as Community).number_of_subscribers
260                   } subscribers`}</span>
261                 </div>
262               )}
263               {i.type_ == 'users' && (
264                 <div>
265                   <span>
266                     <Link
267                       className="text-info"
268                       to={`/u/${(i.data as UserView).name}`}
269                     >
270                       {(i.data as UserView).avatar && showAvatars() && (
271                         <img
272                           height="32"
273                           width="32"
274                           src={pictshareAvatarThumbnail(
275                             (i.data as UserView).avatar
276                           )}
277                           class="rounded-circle mr-1"
278                         />
279                       )}
280                       <span>{`/u/${(i.data as UserView).name}`}</span>
281                     </Link>
282                   </span>
283                   <span>{` - ${
284                     (i.data as UserView).comment_score
285                   } comment karma`}</span>
286                 </div>
287               )}
288             </div>
289           </div>
290         ))}
291       </div>
292     );
293   }
294
295   comments() {
296     return (
297       <>
298         {this.state.searchResponse.comments.map(comment => (
299           <div class="row">
300             <div class="col-12">
301               <CommentNodes nodes={[{ comment: comment }]} locked noIndent />
302             </div>
303           </div>
304         ))}
305       </>
306     );
307   }
308
309   posts() {
310     return (
311       <>
312         {this.state.searchResponse.posts.map(post => (
313           <div class="row">
314             <div class="col-12">
315               <PostListing post={post} showCommunity />
316             </div>
317           </div>
318         ))}
319       </>
320     );
321   }
322
323   // Todo possibly create UserListing and CommunityListing
324   communities() {
325     return (
326       <>
327         {this.state.searchResponse.communities.map(community => (
328           <div class="row">
329             <div class="col-12">
330               <span>
331                 <Link
332                   to={`/c/${community.name}`}
333                 >{`/c/${community.name}`}</Link>
334               </span>
335               <span>{` - ${community.title} - ${community.number_of_subscribers} subscribers`}</span>
336             </div>
337           </div>
338         ))}
339       </>
340     );
341   }
342
343   users() {
344     return (
345       <>
346         {this.state.searchResponse.users.map(user => (
347           <div class="row">
348             <div class="col-12">
349               <span>
350                 <Link
351                   className="text-info"
352                   to={`/u/${user.name}`}
353                 >{`/u/${user.name}`}</Link>
354               </span>
355               <span>{` - ${user.comment_score} comment karma`}</span>
356             </div>
357           </div>
358         ))}
359       </>
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             {i18n.t('prev')}
372           </button>
373         )}
374         <button
375           class="btn btn-sm btn-secondary"
376           onClick={linkEvent(this, this.nextPage)}
377         >
378           {i18n.t('next')}
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 && <span>{i18n.t('no_results')}</span>}
393       </div>
394     );
395   }
396
397   nextPage(i: Search) {
398     i.state.page++;
399     i.setState(i.state);
400     i.updateUrl();
401     i.search();
402   }
403
404   prevPage(i: Search) {
405     i.state.page--;
406     i.setState(i.state);
407     i.updateUrl();
408     i.search();
409   }
410
411   search() {
412     let form: SearchForm = {
413       q: this.state.q,
414       type_: SearchType[this.state.type_],
415       sort: SortType[this.state.sort],
416       page: this.state.page,
417       limit: fetchLimit,
418     };
419
420     if (this.state.q != '') {
421       WebSocketService.Instance.search(form);
422     }
423   }
424
425   handleSortChange(val: SortType) {
426     this.state.sort = val;
427     this.state.page = 1;
428     this.setState(this.state);
429     this.updateUrl();
430   }
431
432   handleTypeChange(i: Search, event: any) {
433     i.state.type_ = Number(event.target.value);
434     i.state.page = 1;
435     i.setState(i.state);
436     i.updateUrl();
437   }
438
439   handleSearchSubmit(i: Search, event: any) {
440     event.preventDefault();
441     i.state.loading = true;
442     i.search();
443     i.setState(i.state);
444     i.updateUrl();
445   }
446
447   handleQChange(i: Search, event: any) {
448     i.state.q = event.target.value;
449     i.setState(i.state);
450   }
451
452   updateUrl() {
453     let typeStr = SearchType[this.state.type_].toLowerCase();
454     let sortStr = SortType[this.state.sort].toLowerCase();
455     this.props.history.push(
456       `/search/q/${this.state.q}/type/${typeStr}/sort/${sortStr}/page/${this.state.page}`
457     );
458   }
459
460   parseMessage(msg: WebSocketJsonResponse) {
461     console.log(msg);
462     let res = wsJsonToRes(msg);
463     if (msg.error) {
464       toast(i18n.t(msg.error), 'danger');
465       return;
466     } else if (res.op == UserOperation.Search) {
467       let data = res.data as SearchResponse;
468       this.state.searchResponse = data;
469       this.state.loading = false;
470       document.title = `${i18n.t('search')} - ${this.state.q} - ${
471         WebSocketService.Instance.site.name
472       }`;
473       window.scrollTo(0, 0);
474       this.setState(this.state);
475     } else if (res.op == UserOperation.CreateCommentLike) {
476       let data = res.data as CommentResponse;
477       let found: Comment = this.state.searchResponse.comments.find(
478         c => c.id === data.comment.id
479       );
480       found.score = data.comment.score;
481       found.upvotes = data.comment.upvotes;
482       found.downvotes = data.comment.downvotes;
483       if (data.comment.my_vote !== null) {
484         found.my_vote = data.comment.my_vote;
485         found.upvoteLoading = false;
486         found.downvoteLoading = false;
487       }
488       this.setState(this.state);
489     } else if (res.op == UserOperation.CreatePostLike) {
490       let data = res.data as PostResponse;
491       let found = this.state.searchResponse.posts.find(
492         c => c.id == data.post.id
493       );
494       found.my_vote = data.post.my_vote;
495       found.score = data.post.score;
496       found.upvotes = data.post.upvotes;
497       found.downvotes = data.post.downvotes;
498       this.setState(this.state);
499     }
500   }
501 }