]> Untitled Git - lemmy.git/blob - ui/src/components/search.tsx
Allow comment/post upvoting from other pages.
[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 } from '../utils';
27 import { PostListing } from './post-listing';
28 import { SortSelect } from './sort-select';
29 import { CommentNodes } from './comment-nodes';
30 import { i18n } from '../i18next';
31 import { T } from 'inferno-i18next';
32
33 interface SearchState {
34   q: string;
35   type_: SearchType;
36   sort: SortType;
37   page: number;
38   searchResponse: SearchResponse;
39   loading: boolean;
40 }
41
42 export class Search extends Component<any, SearchState> {
43   private subscription: Subscription;
44   private emptyState: SearchState = {
45     q: this.getSearchQueryFromProps(this.props),
46     type_: this.getSearchTypeFromProps(this.props),
47     sort: this.getSortTypeFromProps(this.props),
48     page: this.getPageFromProps(this.props),
49     searchResponse: {
50       op: null,
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>
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     );
142   }
143
144   searchForm() {
145     return (
146       <form
147         class="form-inline"
148         onSubmit={linkEvent(this, this.handleSearchSubmit)}
149       >
150         <input
151           type="text"
152           class="form-control mr-2"
153           value={this.state.q}
154           placeholder={`${i18n.t('search')}...`}
155           onInput={linkEvent(this, this.handleQChange)}
156           required
157           minLength={3}
158         />
159         <button type="submit" class="btn btn-secondary mr-2">
160           {this.state.loading ? (
161             <svg class="icon icon-spinner spin">
162               <use xlinkHref="#icon-spinner"></use>
163             </svg>
164           ) : (
165             <span>
166               <T i18nKey="search">#</T>
167             </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>
183             <T i18nKey="type">#</T>
184           </option>
185           <option value={SearchType.All}>
186             <T i18nKey="all">#</T>
187           </option>
188           <option value={SearchType.Comments}>
189             <T i18nKey="comments">#</T>
190           </option>
191           <option value={SearchType.Posts}>
192             <T i18nKey="posts">#</T>
193           </option>
194           <option value={SearchType.Communities}>
195             <T i18nKey="communities">#</T>
196           </option>
197           <option value={SearchType.Users}>
198             <T i18nKey="users">#</T>
199           </option>
200         </select>
201         <span class="ml-2">
202           <SortSelect
203             sort={this.state.sort}
204             onChange={this.handleSortChange}
205             hideHot
206           />
207         </span>
208       </div>
209     );
210   }
211
212   all() {
213     let combined: Array<{
214       type_: string;
215       data: Comment | Post | Community | UserView;
216     }> = [];
217     let comments = this.state.searchResponse.comments.map(e => {
218       return { type_: 'comments', data: e };
219     });
220     let posts = this.state.searchResponse.posts.map(e => {
221       return { type_: 'posts', data: e };
222     });
223     let communities = this.state.searchResponse.communities.map(e => {
224       return { type_: 'communities', data: e };
225     });
226     let users = this.state.searchResponse.users.map(e => {
227       return { type_: 'users', data: e };
228     });
229
230     combined.push(...comments);
231     combined.push(...posts);
232     combined.push(...communities);
233     combined.push(...users);
234
235     // Sort it
236     if (this.state.sort == SortType.New) {
237       combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
238     } else {
239       combined.sort(
240         (a, b) =>
241           ((b.data as Comment | Post).score |
242             (b.data as Community).number_of_subscribers |
243             (b.data as UserView).comment_score) -
244           ((a.data as Comment | Post).score |
245             (a.data as Community).number_of_subscribers |
246             (a.data as UserView).comment_score)
247       );
248     }
249
250     return (
251       <div>
252         {combined.map(i => (
253           <div class="row">
254             <div class="col-12">
255               {i.type_ == 'posts' && (
256                 <PostListing post={i.data as Post} showCommunity />
257               )}
258               {i.type_ == 'comments' && (
259                 <CommentNodes
260                   nodes={[{ comment: i.data as Comment }]}
261                   locked
262                   noIndent
263                 />
264               )}
265               {i.type_ == 'communities' && (
266                 <div>
267                   <span>
268                     <Link to={`/c/${(i.data as Community).name}`}>{`/c/${
269                       (i.data as Community).name
270                     }`}</Link>
271                   </span>
272                   <span>{` - ${(i.data as Community).title} - ${
273                     (i.data as Community).number_of_subscribers
274                   } subscribers`}</span>
275                 </div>
276               )}
277               {i.type_ == 'users' && (
278                 <div>
279                   <span>
280                     <Link
281                       className="text-info"
282                       to={`/u/${(i.data as UserView).name}`}
283                     >
284                       {(i.data as UserView).avatar && showAvatars() && (
285                         <img
286                           height="32"
287                           width="32"
288                           src={pictshareAvatarThumbnail(
289                             (i.data as UserView).avatar
290                           )}
291                           class="rounded-circle mr-1"
292                         />
293                       )}
294                       <span>{`/u/${(i.data as UserView).name}`}</span>
295                     </Link>
296                   </span>
297                   <span>{` - ${
298                     (i.data as UserView).comment_score
299                   } comment karma`}</span>
300                 </div>
301               )}
302             </div>
303           </div>
304         ))}
305       </div>
306     );
307   }
308
309   comments() {
310     return (
311       <>
312         {this.state.searchResponse.comments.map(comment => (
313           <div class="row">
314             <div class="col-12">
315               <CommentNodes nodes={[{ comment: comment }]} locked noIndent />
316             </div>
317           </div>
318         ))}
319       </>
320     );
321   }
322
323   posts() {
324     return (
325       <>
326         {this.state.searchResponse.posts.map(post => (
327           <div class="row">
328             <div class="col-12">
329               <PostListing post={post} showCommunity />
330             </div>
331           </div>
332         ))}
333       </>
334     );
335   }
336
337   // Todo possibly create UserListing and CommunityListing
338   communities() {
339     return (
340       <>
341         {this.state.searchResponse.communities.map(community => (
342           <div class="row">
343             <div class="col-12">
344               <span>
345                 <Link
346                   to={`/c/${community.name}`}
347                 >{`/c/${community.name}`}</Link>
348               </span>
349               <span>{` - ${community.title} - ${community.number_of_subscribers} subscribers`}</span>
350             </div>
351           </div>
352         ))}
353       </>
354     );
355   }
356
357   users() {
358     return (
359       <>
360         {this.state.searchResponse.users.map(user => (
361           <div class="row">
362             <div class="col-12">
363               <span>
364                 <Link
365                   className="text-info"
366                   to={`/u/${user.name}`}
367                 >{`/u/${user.name}`}</Link>
368               </span>
369               <span>{` - ${user.comment_score} comment karma`}</span>
370             </div>
371           </div>
372         ))}
373       </>
374     );
375   }
376
377   paginator() {
378     return (
379       <div class="mt-2">
380         {this.state.page > 1 && (
381           <button
382             class="btn btn-sm btn-secondary mr-1"
383             onClick={linkEvent(this, this.prevPage)}
384           >
385             <T i18nKey="prev">#</T>
386           </button>
387         )}
388         <button
389           class="btn btn-sm btn-secondary"
390           onClick={linkEvent(this, this.nextPage)}
391         >
392           <T i18nKey="next">#</T>
393         </button>
394       </div>
395     );
396   }
397
398   noResults() {
399     let res = this.state.searchResponse;
400     return (
401       <div>
402         {res &&
403           res.op &&
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: any) {
480     console.log(msg);
481     let op: UserOperation = msgOp(msg);
482     if (msg.error) {
483       alert(i18n.t(msg.error));
484       return;
485     } else if (op == UserOperation.Search) {
486       let res: SearchResponse = msg;
487       this.state.searchResponse = res;
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 (op == UserOperation.CreateCommentLike) {
495       let res: CommentResponse = msg;
496       let found: Comment = this.state.searchResponse.comments.find(
497         c => c.id === res.comment.id
498       );
499       found.score = res.comment.score;
500       found.upvotes = res.comment.upvotes;
501       found.downvotes = res.comment.downvotes;
502       if (res.comment.my_vote !== null) {
503         found.my_vote = res.comment.my_vote;
504         found.upvoteLoading = false;
505         found.downvoteLoading = false;
506       }
507       this.setState(this.state);
508     } else if (op == UserOperation.CreatePostLike) {
509       let res: CreatePostLikeResponse = msg;
510       let found = this.state.searchResponse.posts.find(
511         c => c.id == res.post.id
512       );
513       found.my_vote = res.post.my_vote;
514       found.score = res.post.score;
515       found.upvotes = res.post.upvotes;
516       found.downvotes = res.post.downvotes;
517       this.setState(this.state);
518     }
519   }
520 }