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