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