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