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